From 3f6733c805d873a7af6a6064ace0cf5916f481da Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:06:31 -0300 Subject: A mouse event carries Shift and Alt as well as Ctrl, from SDL and from the tty's input; a program's wheel report carries them as xterm counts them, and Shift keeps the wheel pardes's own scroll The reports are ghostty's encoder now (shift+4, meta+8, ctrl+16), with the mode and format ghostty tracks. The detached wire carries the two new bits: version 9. The test shell's ESC]777;mouse takes an optional fifth field of modifiers. Co-Authored-By: Claude Opus 5.5 --- src/gui/gui.zig | 66 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 12 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 045f22d9..4ce554f3 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3139,8 +3139,8 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { - // ESC]777;mouse;;;;BEL - // ESC]777;mouse;wheel;;;BEL + // ESC]777;mouse;;;;[;]BEL + // ESC]777;mouse;wheel;;;[;]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 +3159,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;;;BEL: @@ -3181,7 +3184,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; @@ -3256,6 +3266,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; } @@ -3526,6 +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, @@ -3535,7 +3548,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .rule_above = mc.rule_above, - .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, + .ctrl = mods.ctrl, + .shift = mods.shift, + .alt = mods.alt, }, }); }, @@ -3559,6 +3574,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, @@ -3566,6 +3582,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, + .ctrl = mods.ctrl, + .shift = mods.shift, + .alt = mods.alt, } }); }, c.SDL_EVENT_MOUSE_WHEEL => { @@ -3581,7 +3600,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; @@ -3913,16 +3932,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 +3957,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 +3969,21 @@ 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 }; + +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); @@ -4934,6 +4964,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 +4998,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 +5211,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 +5385,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); -- cgit v1.3 From b546b578baadbd8fdd15b42988ad4d66c9c8ff21 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:12:51 -0300 Subject: 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 --- docs/tags.md | 9 ++ src/gui/gui.zig | 85 ++++++++++++------ src/mouse.zig | 203 ++++++++++++++++++++++++++++++++++++++++--- src/pardes.zig | 3 + src/terminal.zig | 7 ++ test/panes.zig | 10 ++- test/snapshot.zig | 5 ++ test/snapshots/chordcut.snap | 15 ++-- 8 files changed, 289 insertions(+), 48 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/docs/tags.md b/docs/tags.md index 93c8a6fa..dfe0e3c0 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/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; 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/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; -- cgit v1.3 From 17c0d06618085de8d0bbf8152750ffa862db0080 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:20:10 -0300 Subject: A program that asked for focus reports (1004) hears CSI I when its pane takes the keyboard and CSI O when it gives it up, to another pane, a tag or another window The core has a window-focus event now, from SDL's window focus and from the tty's own focus reports (vaxis turns 1004 on outside); the detached wire carries it: version 10. The test shell's ESC]777;mouse;focus;<1|0> fakes the window's. Co-Authored-By: Claude Opus 5.5 --- src/detached/wire.zig | 11 ++++++---- src/gui/gui.zig | 5 +++++ src/pardes.zig | 27 +++++++++++++++++++++++ src/terminal.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 14 ++++++++---- 5 files changed, 108 insertions(+), 8 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 9a1ab608..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 = 9; +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. @@ -1076,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, }, @@ -1138,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; @@ -1222,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, @@ -1455,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 @@ -1468,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/gui/gui.zig b/src/gui/gui.zig index 590d749d..81b68afd 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3172,6 +3172,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; @@ -3469,6 +3472,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; diff --git a/src/pardes.zig b/src/pardes.zig index dcf0bf55..1009d13d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3548,6 +3548,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, }; @@ -4322,6 +4324,10 @@ pub const Pardes = struct { /// 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, @@ -5007,6 +5013,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); @@ -5653,6 +5678,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; @@ -5785,6 +5811,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 63c5479f..af540202 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; } +/// 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; @@ -2738,3 +2745,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:\n1:\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 0b933065..67807cd5 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1469,8 +1469,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) @@ -2674,8 +2677,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; -- cgit v1.3 From 505d15e238e4e9c5020a8e567d700bdbf67a8528 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:23:50 -0300 Subject: A paste into a terminal is xterm's paste: bracketed when the program set 2004, and the control bytes a paste could smuggle a command in by, ESC first, become spaces, so no ESC[201~ in the text ends the brackets early Every paste path goes through edit.typeToTty (the GUI's and the tty's paste, p from a register, the acme chord's paste, a REPL's send), which now takes ghostty's paste encoder. A 9P write to a terminal's body or pty/data stays typed input, not a paste. The GUI test shell's stdin now takes a bracketed paste as one paste, as the tty does, not as keys. Co-Authored-By: Claude Opus 5.5 --- src/edit.zig | 39 +++++++++++++++++++++++++++++---------- src/gui/gui.zig | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/terminal.zig | 15 +++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) (limited to 'src/gui/gui.zig') 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 81b68afd..14205906 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, @@ -3241,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, @@ -4820,6 +4837,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 diff --git a/src/terminal.zig b/src/terminal.zig index af540202..4cdfa52d 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -380,6 +380,21 @@ pub fn reportsMouse(pane: *const Pane) bool { 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; -- cgit v1.3 From 8262f315538b70ab8a123ef1e9346f25b25ec939 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:29:44 -0300 Subject: The window's F1-F24, Insert, Print Screen, Scroll Lock, Pause and Menu keys reach a terminal's program, which they never did; every key a program gets follows its cursor keys' mode and kitty keyboard flags, pinned for both shells The keys went through ghostty's encoder already (DECCKM, the kitty flags); the GUI's native key map simply dropped the function and editing keys it did not name. Co-Authored-By: Claude Opus 5.5 --- src/gui/gui.zig | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/tty/tty.zig | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 14205906..1e58d3ba 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3444,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, @@ -3783,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); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 67807cd5..0a9d65c4 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -2211,6 +2211,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 }; -- cgit v1.3