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/detached/wire.zig | 10 ++++-- src/gui/gui.zig | 66 +++++++++++++++++++++++++++++------- src/mouse.zig | 39 +++++++++++++++++----- src/pardes.zig | 3 ++ src/terminal.zig | 92 ++++++++++++++++++++++++++++++++++----------------- src/tty/tty.zig | 2 ++ 6 files changed, 159 insertions(+), 53 deletions(-) (limited to 'src') diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 051e9317..9a1ab608 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -12,7 +12,7 @@ const ShaderAnimation = pardes.config.Runtime.ShaderAnimation; /// to be refused, which is what this number is for. /// 8: one layer type: a tag band carries all its rows (not a layer per /// line), and a frame carries the core's placed regions after its layers. -pub const version: u16 = 8; +pub const version: u16 = 9; /// 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. @@ -1052,6 +1052,8 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { try w.putU16(m.col); try w.putU16(m.row); try w.putBool(m.ctrl); + try w.putBool(m.shift); + try w.putBool(m.alt); try putBodyHit(&w, m.body_hit); try putTagHit(&w, m.tag_hit); }, @@ -1115,6 +1117,8 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg { .col = try r.getU16(), .row = try r.getU16(), .ctrl = try r.getBool(), + .shift = try r.getBool(), + .alt = try r.getBool(), .body_hit = try getBodyHit(&r), .tag_hit = try getTagHit(&r), } } }, @@ -1357,13 +1361,15 @@ test "detached wire: every Event variant round-trips" { // the only place a value can be mistranslated and still decode. for (std.enums.values(pardes.Mouse.Button)) |button| { for (std.enums.values(pardes.Mouse.Kind)) |kind| { - const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true }; + const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true, .shift = true, .alt = true }; const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } })).event.mouse; try testing.expectEqual(m.button, got.button); try testing.expectEqual(m.kind, got.kind); try testing.expectEqual(m.col, got.col); try testing.expectEqual(m.row, got.row); try testing.expectEqual(m.ctrl, got.ctrl); + try testing.expectEqual(m.shift, got.shift); + try testing.expectEqual(m.alt, got.alt); } } } 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); diff --git a/src/mouse.zig b/src/mouse.zig index a0ec6e47..e80ea7f8 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -313,16 +313,24 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { /// A wheel over a terminal whose program owns it (`terminal.ownsWheel`: /// codex, htop, less) goes to the program as the program asked, one turn /// per line, instead of scrolling a scrollback the alternate screen does -/// not have. False when pardes scrolls. -fn programWheel(p: *Pardes, id: usize, wheel: panes.terminal.Wheel, col: u16, row: u16, ctrl: bool) bool { +/// not have. Shift keeps it pardes's, as xterm's Shift does. False when +/// pardes scrolls. +fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool { + if (m.shift) return false; const pane = p.panes[id] orelse return false; if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false; const r = p.rects[id]; const body_y = p.bodyTop(pane, r); const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1); const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1); - var buf: [32]u8 = undefined; - const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, ctrl) orelse return true; + var buf: [64]u8 = undefined; + const wheel: panes.terminal.MouseButton = switch (m.button) { + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + else => .wheel_right, + }; + const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, .{ .alt = m.alt, .ctrl = m.ctrl }) orelse return true; for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes); return true; } @@ -525,7 +533,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const pane = p.panes[id].?; if (pane.hasPdf()) panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) - else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl)) + else if (!programWheel(p, id, m, mcol, mrow)) pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { @@ -533,7 +541,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; - if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) { + if (programWheel(p, id, m, mcol, mrow)) { // the program's } else if (pane.hasPdf()) { panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); @@ -1838,11 +1846,16 @@ test "a wheel over a program that owns it is the program's, in the form it asked // Grid cell 6,4 (1-based), as a program counts. const col = r.x + config.GUTTER + 5; const row = p.bodyTop(pane, r) + 3; - const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, sent: []const u8 }; + const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, alt: bool = false, shift: bool = false, sent: []const u8 }; const cases = [_]Case{ // codex's chat (0.158+): the alternate screen, any-event tracking, SGR. .{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" }, .{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" }, + // Meta is 8, both 24; Shift keeps the wheel pardes's, which has + // nothing to scroll on the alternate screen. + .{ .modes = "", .button = .wheel_up, .alt = true, .sent = "\x1b[<72;6;4M" }, + .{ .modes = "", .button = .wheel_up, .alt = true, .ctrl = true, .sent = "\x1b[<88;6;4M" }, + .{ .modes = "", .button = .wheel_up, .shift = true, .sent = "" }, // htop's legacy bytes (1000 alone), and 1015's. .{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" }, .{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" }, @@ -1857,7 +1870,7 @@ test "a wheel over a program that owns it is the program's, in the form it asked for (cases) |case| { p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } }); _ = try ptyWrites(p, &out); - p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl } }); + p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl, .alt = case.alt, .shift = case.shift } }); try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out)); } // A shell on the main screen: the wheel scrolls its scrollback, and @@ -1873,4 +1886,14 @@ test "a wheel over a program that owns it is the program's, in the form it asked p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } }); try std.testing.expectEqualStrings("", try ptyWrites(p, &out)); try std.testing.expect(pane.scroll() != was); + // A program tracking the mouse on the main screen: Shift and the + // wheel scroll the scrollback, the program hears nothing. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1000h\x1b[?1006h" } }); + _ = try ptyWrites(p, &out); + const tracked = pane.scroll(); + p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row, .shift = true } }); + try std.testing.expectEqualStrings("", try ptyWrites(p, &out)); + try std.testing.expect(pane.scroll() != tracked); + p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } }); + try std.testing.expectEqualStrings("\x1b[<64;6;4M", try ptyWrites(p, &out)); } diff --git a/src/pardes.zig b/src/pardes.zig index f0d22d57..d6533b3b 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3452,6 +3452,9 @@ pub const Mouse = struct { col: u16, row: u16, ctrl: bool = false, + shift: bool = false, + /// Alt, which xterm's mouse reports call meta. + alt: bool = false, body_hit: ?BodyHit = null, tag_hit: ?TagHit = null, /// A pixel frontend's press on the rule it draws at the top of this row, diff --git a/src/terminal.zig b/src/terminal.zig index 4751a09f..4d16bef2 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -124,6 +124,9 @@ pub const State = struct { replay: Replay, reply: [256]u8 = undefined, reply_len: u16 = 0, + /// The grid cell the program last heard the mouse at, so motion is + /// reported once per cell. + mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {}, }; const GColor = ghostty_vt.color; @@ -374,8 +377,7 @@ pub fn bracketedPaste(pane: *const Pane) bool { pub fn reportsMouse(pane: *const Pane) bool { if (comptime !enabled) return false; const state = pane.terminal orelse return false; - const m = &state.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); + return state.vt.flags.mouse_event != .none; } /// ...and wants them in SGR (1006) rather than the legacy X10 bytes. @@ -395,43 +397,71 @@ pub fn ownsWheel(pane: *const Pane) bool { (state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll)); } -pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 }; +pub const MouseMods = struct { shift: bool = false, alt: bool = false, ctrl: bool = false }; +pub const MouseButton = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right }; +pub const MouseAction = enum { press, release, motion }; + +/// One mouse event for the program, as xterm reports it (ctlseqs; ghostty's +/// encoder): the tracking mode it set (1000 clicks, 1002 drags, 1003 all +/// motion) decides whether it hears of it, the format it set (1006 SGR, +/// 1015 urxvt, 1005 UTF-8, else X10) how, modifiers as shift+4, meta+8, +/// ctrl+16. `col`,`row` are the 0-based grid cell; a drag that left the +/// grid is reported clamped to its edge, motion only when the cell +/// changed. Empty when the program does not hear of it. +pub fn mouseBytes(pane: *Pane, buf: *[64]u8, action: MouseAction, button: ?MouseButton, mods: MouseMods, col: i32, row: i32, any_pressed: bool) []const u8 { + if (comptime !enabled) return &.{}; + const state = pane.terminal orelse return &.{}; + const input = ghostty_vt.input; + var writer = std.Io.Writer.fixed(buf); + var opts: input.MouseEncodeOptions = .{ + .event = state.vt.flags.mouse_event, + .format = state.vt.flags.mouse_format, + .size = .{ + .screen = .{ .width = state.vt.cols, .height = state.vt.rows }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }, + .any_button_pressed = any_pressed, + .last_cell = &state.mouse_cell, + }; + // A release is always reported; the rest only over the grid, or held. + if (action == .release) opts.any_button_pressed = true; + input.encodeMouse(&writer, .{ + .action = switch (action) { + .press => .press, + .release => .release, + .motion => .motion, + }, + .button = if (button) |b| switch (b) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .four, + .wheel_down => .five, + .wheel_left => .six, + .wheel_right => .seven, + } else null, + .mods = .{ .shift = mods.shift, .alt = mods.alt, .ctrl = mods.ctrl }, + .pos = .{ .x = @as(f32, @floatFromInt(col)) + 0.5, .y = @as(f32, @floatFromInt(row)) + 0.5 }, + }, opts) catch return &.{}; + return writer.buffered(); +} /// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the -/// program, as xterm sends them: its mouse report in the format it asked -/// for, else, on the alternate screen, an arrow key (none for a sideways -/// turn). Null when the wheel is not the program's (`ownsWheel`) or the -/// cell cannot be said in the legacy format. -pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 { +/// program: its mouse report while it tracks the mouse, else, on the +/// alternate screen, an arrow key in the cursor keys' mode (none for a +/// sideways turn). Null when the wheel is not the program's (`ownsWheel`). +pub fn wheelBytes(pane: *Pane, buf: *[64]u8, wheel: MouseButton, col: u16, row: u16, mods: MouseMods) ?[]const u8 { if (comptime !enabled) return null; const state = pane.terminal orelse return null; if (!ownsWheel(pane)) return null; const m = &state.vt.modes; if (!reportsMouse(pane)) return switch (wheel) { - .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A", - .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B", - .left, .right => null, + .wheel_up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A", + .wheel_down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B", + else => null, }; - const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0); - const x = @as(u32, col) + 1; - const y = @as(u32, row) + 1; - if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null; - if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null; - buf[0..3].* = "\x1b[M".*; - buf[3] = 32 + code; - var n: usize = 4; - for ([_]u32{ x, y }) |v| { - const c = 32 + v; - if (m.get(.mouse_format_utf8)) { - if (c > 2047) return null; - n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null; - } else { - if (c > 255) return null; - buf[n] = @intCast(c); - n += 1; - } - } - return buf[0..n]; + return mouseBytes(pane, buf, .press, wheel, mods, col, row, false); } /// The whole scrollback as plain text, `gpa`-owned: what a terminal's body diff --git a/src/tty/tty.zig b/src/tty/tty.zig index ef7f2761..0b933065 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -2261,6 +2261,8 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { .col = @intCast(m.col), .row = @intCast(m.row), .ctrl = m.mods.ctrl, + .shift = m.mods.shift, + .alt = m.mods.alt, } }; } -- 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') 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') 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') 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 c04df904f2fa686cbe5ce0c078a96260af7b08bd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:39:20 -0300 Subject: A diff reads as the code it changes: hunks in their file's language, added and removed rows tinted A .diff or .patch pane, and a command pane whose output is a diff (`git diff`, `git show`, `diff -u`; told by a `diff --git` line, or `---`/`+++` with a `@@` under them, in its first rows), colour each hunk's code in the language of the file its section names (`+++ b/`, `--- a/` for a deleted file), with tree-sitter, as highlightLocations does for results. src/diff.zig walks a unified diff by its `@@` counts, so a removed line that reads `--- x` is no header. Each side of a hunk is parsed as one text (context and removed lines the old side, context and added lines the new), so a string or comment across lines colours as it does in the file; a side no line takes its colours from is not parsed. A section's hunks share a parse, in pieces of about 40 lines (a hunk past 80 is cut), only those in view, and each piece's colours are kept by its bytes, so scrolling back and a terminal's every repaint parse nothing again. Added and removed lines carry a flag in their style byte; the painter tints their rows to the pane's edge, a little way from the page toward the theme's ANSI green or red, and draws the prefix in that hue pushed to read on it. A file in no known language keeps the old line colours. A command pane is read once its command has finished: its rows are copied once (File.DiffOutput, dropped when the pane runs again), so no frame dumps the scrollback, and painted over what git printed. A running command is shown as it prints. Co-Authored-By: Claude Opus 5.5 --- docs/tags.md | 14 ++ src/CHANGELOG.md | 8 + src/File.zig | 232 ++++++++++++++++++- src/body_layer.zig | 1 + src/diff.zig | 337 ++++++++++++++++++++++++++++ src/exec.zig | 1 + src/pardes.zig | 4 + src/syntax.zig | 499 ++++++++++++++++++++++++++++++++++++++--- test/snapshots/diffview.golden | 119 ++++++++++ test/snapshots/diffview.snap | 27 +++ 10 files changed, 1204 insertions(+), 38 deletions(-) create mode 100644 src/diff.zig create mode 100644 test/snapshots/diffview.golden create mode 100644 test/snapshots/diffview.snap (limited to 'src') diff --git a/docs/tags.md b/docs/tags.md index 93c8a6fa..4d4e9178 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -64,6 +64,20 @@ Unsaved text shows on the pane's grip, as acme's modbutton, not in the tag. In a terminal the grip is two cells: the pane's mode (blank normal, `^` insert, `$` tty mode), then `*` while unsaved. +## Reviewing diffs + +Open a `.diff` or `.patch` file, or run `git diff` (or `git show`, `diff -u +old new`) as a command from any tag: once the command has finished, output +that starts as a diff does (a `diff --git` line, or a `--- ` line with `+++ +` and `@@` under it) is shown as one. Each hunk's code is coloured in the language of the file its +section names (`+++ b/`, or `--- a/` for a deleted file), its +old side (context and removed lines) and new side (context and added lines) +each parsed as one text, so a string or comment across lines colours as it +does in the file. Added and removed rows are tinted to the pane's edge, +their `+`/`-` in the tint's hue (the theme's ANSI green and red where it +has them); a file in no language pardes knows keeps the plain line colours. +Only the hunks in view are parsed, a few dozen lines at a time, each once. + ## Editing tags A tag is text like a body, with the body's normal and insert modes and undo. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 25740d16..5f53ff8a 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- A diff reads as the code it changes: a `.diff` or `.patch` file, and a + command's output that is a diff (`git diff`), colour each hunk in its + file's language, its old and new side each parsed whole, and tint added + and removed rows to the pane's edge with their `+`/`-` in the tint's hue. + Only the hunks in view are parsed, once each. + ## 0.0.8 - `~` and `~/` mean the home directory wherever a path is taken: look diff --git a/src/File.zig b/src/File.zig index 84cec879..2046a7cd 100644 --- a/src/File.zig +++ b/src/File.zig @@ -10,10 +10,12 @@ const Surface = pardes.Surface; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); +const diff = @import("diff.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); +const Colors = @import("colors.zig"); const Pane = panes.Pane; const Output = panes.Output; const Mini = panes.mini; @@ -1301,6 +1303,59 @@ pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, const SynStyle = struct { fg: [3]u8, bold: bool }; +/// How added and removed diff lines are told apart in every theme: a row +/// ground a little way from the page toward green or red (the theme's own +/// ANSI green and red where it has a palette), and the prefix column in +/// that hue, pushed toward the ink until it reads on that ground. The +/// syntax colours are drawn over the ground unchanged. +pub const DiffTints = struct { + added_bg: [3]u8, + removed_bg: [3]u8, + added_fg: [3]u8, + removed_fg: [3]u8, + + pub fn of(p: *Pardes) DiffTints { + const th = p.theme(); + const page = th.bg orelse th.tag_bg; + const ink = th.fg orelse th.tag_fg; + const green: [3]u8 = if (th.palette) |pal| pal[2] else .{ 0x2e, 0xa0, 0x43 }; + const red: [3]u8 = if (th.palette) |pal| pal[1] else .{ 0xcf, 0x22, 0x2e }; + const added_bg = Colors.mixOklab(page, green, 0.18); + const removed_bg = Colors.mixOklab(page, red, 0.18); + return .{ + .added_bg = added_bg, + .removed_bg = removed_bg, + .added_fg = legible(green, ink, added_bg), + .removed_fg = legible(red, ink, removed_bg), + }; + } + + fn legible(hue: [3]u8, ink: [3]u8, ground: [3]u8) [3]u8 { + var fg = hue; + var step: usize = 0; + while (step < 4 and Colors.contrast(fg, ground) < 3) : (step += 1) fg = Colors.mixOklab(fg, ink, 0.4); + return fg; + } + + /// The row ground and prefix ink for a line's style byte, if it is an + /// added or removed line. + pub fn forStyle(t: *const DiffTints, style: u8) ?struct { bg: [3]u8, fg: [3]u8 } { + if (style & syntax.diff_added != 0) return .{ .bg = t.added_bg, .fg = t.added_fg }; + if (style & syntax.diff_removed != 0) return .{ .bg = t.removed_bg, .fg = t.removed_fg }; + return null; + } +}; + +/// A diff row's tint: its ground from `x` to the pane's edge. +fn tintRow(s: *Surface, x: u16, y: u16, end: u16, bg: [3]u8) void { + var col = x; + while (col < end) : (col += 1) { + const cell = s.at(col, y); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + } +} + fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { return switch (sy) { .none => null, @@ -1320,6 +1375,7 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); + const tints: ?DiffTints = if (pane.colorAlgo() == .diff and highlights.len > 0) DiffTints.of(p) else null; // indexed start, same as bodyText — an empty tail simply paints nothing var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const total = nlines(p.gpa, f); @@ -1352,6 +1408,12 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; const decoration = Output.decorateRow(f.output, line, metadata); const chrome: ?*const pardes.ChromeTheme = if (decoration.plain()) null else p.bodyChrome(); + // An added or removed line's row, every wrapped row of it, is tinted. + const tint = if (tints) |*t| if (base >= highlight_start and base - highlight_start < highlights.len) + t.forStyle(highlights[base - highlight_start]) + else + null else null; + if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -1361,7 +1423,11 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. if (idx >= highlight_start) { const hidx = idx - highlight_start; if (hidx < highlights.len) { - if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| { + if (if (tint != null and hs + c == 0) + SynStyle{ .fg = tint.?.fg, .bold = true } + else + synStyle(p, @enumFromInt(highlights[hidx] & syntax.syn_bits))) |ss| + { var fill: usize = 0; while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); @@ -1390,6 +1456,170 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes. } } +/// What finished command panes printed, kept while it cannot change: a +/// copy of the terminal's rows, and whether they are a diff. Taken once +/// when a finished pane is first drawn, so neither a frame nor a look dumps +/// the whole scrollback again; a still running command is shown as it +/// printed. Keyed by the pane and its width (a resize rewraps every row); +/// a rerun in the pane drops it (exec.runCommand). +pub const DiffOutput = struct { + const Entry = struct { + serial: u32, + cols: u16, + is_diff: bool, + /// The rows joined by '\n'; `rows` are slices of it. + text: []u8, + rows: [][]const u8, + used: u64, + }; + slots: [4]?Entry = @splat(null), + clock: u64 = 0, + + pub fn reset(o: *DiffOutput, gpa: std.mem.Allocator) void { + for (&o.slots) |*slot| { + if (slot.*) |e| { + gpa.free(e.text); + gpa.free(e.rows); + } + slot.* = null; + } + } + + /// A pane's command runs again: what it printed before is gone. + pub fn drop(o: *DiffOutput, gpa: std.mem.Allocator, serial: u32) void { + for (&o.slots) |*slot| if (slot.*) |e| if (e.serial == serial) { + gpa.free(e.text); + gpa.free(e.rows); + slot.* = null; + }; + } + + /// A finished command pane's rows when they are a diff, else null. + pub fn lines(o: *DiffOutput, p: *Pardes, pane: *Pane) ?[]const []const u8 { + if (pane.command == null or !pane.command_done or pane.command_pty or pane.terminal == null) return null; + o.clock += 1; + for (&o.slots) |*slot| if (slot.*) |*e| if (e.serial == pane.serial and e.cols == pane.cols) { + e.used = o.clock; + return if (e.is_diff) e.rows else null; + }; + const shown = terminal.shellRows(p, pane) catch return null; + const is_diff = diff.looksLikeDiff(shown); + var total: usize = 0; + if (is_diff) for (shown) |row| { + total += row.len + 1; + }; + const text = p.gpa.alloc(u8, total) catch return null; + const rows = p.gpa.alloc([]const u8, if (is_diff) shown.len else 0) catch { + p.gpa.free(text); + return null; + }; + var at: usize = 0; + for (rows, 0..) |*row, i| { + @memcpy(text[at..][0..shown[i].len], shown[i]); + row.* = text[at..][0..shown[i].len]; + at += shown[i].len; + text[at] = '\n'; + at += 1; + } + var oldest: usize = 0; + for (&o.slots, 0..) |*slot, i| { + const e = slot.* orelse { + oldest = i; + break; + }; + if (o.slots[oldest]) |old| if (e.used < old.used) { + oldest = i; + }; + } + if (o.slots[oldest]) |old| { + p.gpa.free(old.text); + p.gpa.free(old.rows); + } + o.slots[oldest] = .{ .serial = pane.serial, .cols = pane.cols, .is_diff = is_diff, .text = text, .rows = rows, .used = o.clock }; + return if (is_diff) rows else null; + } +}; + +/// A command pane whose output is a diff (`git diff`, `diff -u`): its +/// rows coloured as a .diff file's are, over what the program printed. +/// The rows are the terminal's own, so a line it wrapped goes on in the +/// row below (syntax.highlightDiffRows). +pub fn recolorDiffOutput(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + if (pane.command == null or !p.settings.colors) return; + const tz = tracy.zone(@src(), "diffRecolor"); + defer tz.end(); + const rows = p.diff_output.lines(p, pane) orelse return; + // The copy's rows are one text, joined as a file's lines are. + const flat_start = @intFromPtr(rows[0].ptr); + const last = rows[rows.len - 1]; + const flat = rows[0].ptr[0 .. @intFromPtr(last.ptr) + last.len - flat_start]; + const raw = pane.body.mode == .tty; + const View = struct { row: usize, at: usize }; + const arena = p.scratch.allocator(); + const views = arena.alloc(?View, body_h) catch return; + var lo: usize = std.math.maxInt(usize); + var hi: usize = 0; + var shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |*view, vi| { + view.* = null; + const line = shown.next() orelse continue; + const vr: i32 = @intCast(vi); + const w = pane.wrapAt(vr); + const row_i: i32 = if (raw) terminal.gridOffset(pane) + vr else w.line; + if (row_i < 0 or row_i >= @as(i64, @intCast(rows.len))) continue; + const row: usize = @intCast(row_i); + const from: usize = if (raw) 0 else @intCast(@max(0, w.at)); + if (from > rows[row].len or !std.mem.startsWith(u8, rows[row][from..], line)) continue; + view.* = .{ .row = row, .at = from }; + const start = @intFromPtr(rows[row].ptr) - flat_start; + lo = @min(lo, start); + hi = @max(hi, start + rows[row].len); + } + if (lo > hi) return; + const styles = syntax.highlightDiffRows(arena, flat, lo, hi, true) catch return; + const tints = DiffTints.of(p); + const ink = p.theme().fg orelse p.theme().tag_fg; + const body_y = p.bodyTop(pane, r); + shown = std.mem.splitScalar(u8, body, '\n'); + for (views, 0..) |maybe, vr| { + const line = shown.next() orelse break; + const view = maybe orelse continue; + const base = @intFromPtr(rows[view.row].ptr) - flat_start - lo; + const tint = if (base < styles.len) tints.forStyle(styles[base]) else null; + if (tint) |t| tintRow(s, tx, body_y + @as(u16, @intCast(vr)), tx + tw, t.bg); + var c: usize = 0; + var screen_c: usize = 0; + while (c < line.len and screen_c < tw) { + const grapheme_end = modal.nextGrapheme(line, c); + if (grapheme_end <= c) break; + const cells = graphemeDisplayWidth(line[c..grapheme_end]); + const style = styles[base + view.at + c]; + const prefix = view.at + c == 0 and (line[0] == '+' or line[0] == '-'); + // On an added or removed line, what the program coloured red or + // green is the tint's to say now: its code takes the page's ink. + const paint: ?SynStyle = if (tint != null and prefix) + .{ .fg = tint.?.fg, .bold = true } + else if (synStyle(p, @enumFromInt(style & syntax.syn_bits))) |ss| + ss + else if (tint != null) + .{ .fg = ink, .bold = false } + else + null; + if (paint) |ss| { + var fill: usize = 0; + while (fill < cells and screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(screen_c + fill)), body_y + @as(u16, @intCast(vr))); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } + screen_c += cells; + c = grapheme_end; + } + } +} + pub fn drawWrapMarkers( p: *Pardes, s: *Surface, diff --git a/src/body_layer.zig b/src/body_layer.zig index cb26c1f2..8e7cd030 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -292,6 +292,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); + panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); diff --git a/src/diff.zig b/src/diff.zig new file mode 100644 index 00000000..98942469 --- /dev/null +++ b/src/diff.zig @@ -0,0 +1,337 @@ +//! A unified diff (`git diff`, `diff -u`), read a line at a time: which +//! file each section is about, what each line is, and where a hunk line +//! stands in the old and the new file. The syntax painter (syntax.zig +//! highlightDiff) walks it. +//! +//! A hunk's extent comes from the counts in its `@@` header, as patch +//! reads it, so a removed line that itself starts `--` (`--- x`) is not +//! taken for a file header. +const std = @import("std"); + +pub const Kind = enum { + /// Anything outside a file header and a hunk: a commit message, + /// `Only in ...`, a mail's headers. + other, + /// `diff ...`, `index ...`, `new file mode ...`, `rename from ...`, + /// `Binary files ... differ`: said of the section, naming no line. + meta, + /// `--- ` + old_path, + /// `+++ ` + new_path, + /// `@@ -a,b +c,d @@` + hunk, + context, + added, + removed, + /// `\ No newline at end of file` + no_newline, +}; + +/// One line, walked. +pub const Line = struct { + kind: Kind, + /// Where it is in the old and the new file, from 1. A hunk header has + /// its first lines; a removed line the new line now standing where it + /// was (the one after it); an added line the old line after it. + old: usize = 0, + new: usize = 0, +}; + +pub const Walk = struct { + /// The section's paths, as written, borrowed from the lines walked: + /// `a/` and `b/` kept, timestamps cut. + old_path: []const u8 = "", + new_path: []const u8 = "", + /// `diff --git a/x b/y`'s b side, for a section with no `+++` yet + /// (a mode change, a rename, a binary file). + git_path: []const u8 = "", + old_left: usize = 0, + new_left: usize = 0, + old_at: usize = 0, + new_at: usize = 0, + /// The hunk's header numbers, which a look past its end clamps to. + new_start: usize = 0, + new_count: usize = 0, + /// Rows a terminal wrapped: inside a hunk, a row that starts with no + /// prefix at all continues the line above instead of ending the hunk. + continued_rows: bool = false, + last: Kind = .other, + + pub fn inHunk(w: *const Walk) bool { + return w.old_left > 0 or w.new_left > 0; + } + + /// The file the section is about: the new one, else (deleted) the old + /// one, else what its `diff --git` line names. + pub fn path(w: *const Walk) []const u8 { + if (w.new_path.len > 0 and !isDevNull(w.new_path)) return w.new_path; + if (w.old_path.len > 0 and !isDevNull(w.old_path)) return w.old_path; + return w.git_path; + } + + /// Whether the section deletes its file (`+++ /dev/null`). + pub fn deleted(w: *const Walk) bool { + return isDevNull(w.new_path); + } + + pub fn step(w: *Walk, line_raw: []const u8) Line { + const line = std.mem.trimEnd(u8, line_raw, "\r"); + const got = w.stepInner(line); + w.last = got.kind; + return got; + } + + fn stepInner(w: *Walk, line: []const u8) Line { + if (w.inHunk()) { + // A blank context line, its space stripped by a mailer or an + // editor, is still context. + const c: u8 = if (line.len == 0) ' ' else line[0]; + switch (c) { + ' ' => if (w.old_left > 0 and w.new_left > 0) { + const at: Line = .{ .kind = .context, .old = w.old_at, .new = w.new_at }; + w.old_left -= 1; + w.new_left -= 1; + w.old_at += 1; + w.new_at += 1; + return at; + }, + '-' => if (w.old_left > 0) { + const at: Line = .{ .kind = .removed, .old = w.old_at, .new = w.new_at }; + w.old_left -= 1; + w.old_at += 1; + return at; + }, + '+' => if (w.new_left > 0) { + const at: Line = .{ .kind = .added, .old = w.old_at, .new = w.new_at }; + w.new_left -= 1; + w.new_at += 1; + return at; + }, + '\\' => return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at }, + else => if (w.continued_rows and w.last != .hunk and w.last != .other) + return .{ .kind = w.last, .old = w.old_at, .new = w.new_at }, + } + // Counts run out, or a line no hunk has: the hunk was cut short. + w.old_left = 0; + w.new_left = 0; + } + if (line.len > 0 and line[0] == '\\' and w.last != .other and w.last != .meta) + return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at }; + if (std.mem.startsWith(u8, line, "diff ")) { + w.* = .{ .continued_rows = w.continued_rows }; + w.git_path = gitPath(line); + return .{ .kind = .meta }; + } + if (std.mem.startsWith(u8, line, "--- ")) { + // A second `---` with no `+++` between starts another section + // (plain `diff -u` output has no `diff` line of its own). + w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path }; + w.old_path = cutPath(line[4..]); + return .{ .kind = .old_path }; + } + if (std.mem.startsWith(u8, line, "+++ ")) { + w.new_path = cutPath(line[4..]); + return .{ .kind = .new_path }; + } + if (parseHunk(line)) |h| { + w.old_left = h.old_count; + w.new_left = h.new_count; + w.old_at = h.old_start; + w.new_at = h.new_start; + w.new_start = h.new_start; + w.new_count = h.new_count; + return .{ .kind = .hunk, .old = h.old_start, .new = h.new_start }; + } + for (meta_words) |word| if (std.mem.startsWith(u8, line, word)) return .{ .kind = .meta }; + return .{ .kind = .other }; + } +}; + +const meta_words = [_][]const u8{ + "index ", "new file mode ", "deleted file mode ", "old mode ", "new mode ", + "similarity index ", "rename from ", "rename to ", "copy from ", "copy to ", + "Binary files ", "dissimilarity ", +}; + +fn isDevNull(path: []const u8) bool { + return std.mem.eql(u8, path, "/dev/null"); +} + +/// A `---`/`+++` path with what follows it cut: `diff -u`'s tab and +/// timestamp, and git's quotes round a name it had to escape. +fn cutPath(rest: []const u8) []const u8 { + var name = rest[0 .. std.mem.indexOfScalar(u8, rest, '\t') orelse rest.len]; + name = std.mem.trimEnd(u8, name, " \r"); + if (name.len >= 2 and name[0] == '"' and name[name.len - 1] == '"') name = name[1 .. name.len - 1]; + return name; +} + +/// `diff --git a/x b/y`: the b side. A name with a space in it is split at +/// the last ` b/`, which is right unless the old name has one too. +fn gitPath(line: []const u8) []const u8 { + if (!std.mem.startsWith(u8, line, "diff --git ")) return ""; + const rest = line["diff --git ".len..]; + const at = std.mem.lastIndexOf(u8, rest, " b/") orelse return ""; + return std.mem.trimEnd(u8, rest[at + 1 ..], " \r"); +} + +pub const HunkHeader = struct { old_start: usize, old_count: usize, new_start: usize, new_count: usize }; + +/// `@@ -a[,b] +c[,d] @@ ...`, a count left out meaning one. +pub fn parseHunk(line: []const u8) ?HunkHeader { + if (!std.mem.startsWith(u8, line, "@@ -")) return null; + var at: usize = "@@ -".len; + const old = range(line, &at) orelse return null; + if (at + 1 >= line.len or line[at] != ' ' or line[at + 1] != '+') return null; + at += 2; + const new = range(line, &at) orelse return null; + if (!std.mem.startsWith(u8, line[at..], " @@")) return null; + return .{ .old_start = old[0], .old_count = old[1], .new_start = new[0], .new_count = new[1] }; +} + +fn range(line: []const u8, at: *usize) ?[2]usize { + const start = number(line, at) orelse return null; + if (at.* < line.len and line[at.*] == ',') { + at.* += 1; + return .{ start, number(line, at) orelse return null }; + } + return .{ start, 1 }; +} + +fn number(line: []const u8, at: *usize) ?usize { + const from = at.*; + var n: usize = 0; + while (at.* < line.len and std.ascii.isDigit(line[at.*])) : (at.* += 1) + n = std.math.add(usize, std.math.mul(usize, n, 10) catch return null, line[at.*] - '0') catch return null; + return if (at.* > from) n else null; +} + +/// Where a walk that ends up at byte `pos` of `content` may safely start: +/// the start of the nearest `diff ` line before it (no hunk line starts +/// with a `d`), else the top. +pub fn anchorBefore(content: []const u8, pos: usize) usize { + var end = @min(pos, content.len); + while (end > 0) { + const nl = std.mem.lastIndexOfScalar(u8, content[0..end], '\n') orelse break; + if (std.mem.startsWith(u8, content[nl + 1 ..], "diff ") and nl + 1 <= pos) return nl + 1; + end = nl; + } + return 0; +} + +/// The same for a list of lines: the nearest `diff ` line at or above `row`. +pub fn anchorRow(lines: []const []const u8, row: usize) usize { + var r = @min(row, lines.len); + while (r > 0) { + r -= 1; + if (std.mem.startsWith(u8, lines[r], "diff ")) return r; + } + return 0; +} + +/// Whether text a command printed is a diff: a `diff --git` line, or a +/// `--- ` line with a `+++ ` line under it, in its first rows (after +/// `git show`'s commit header, say). +pub fn looksLikeDiff(lines: []const []const u8) bool { + const n = @min(lines.len, 200); + for (lines[0..n], 0..) |line, i| { + if (std.mem.startsWith(u8, line, "diff --git ")) return true; + if (std.mem.startsWith(u8, line, "--- ") and i + 1 < lines.len and std.mem.startsWith(u8, lines[i + 1], "+++ ") and + i + 2 < lines.len and parseHunk(lines[i + 2]) != null) return true; + } + return false; +} + +// ---- tests ---- + +const testing = std.testing; + +fn splitLines(comptime text: []const u8) [std.mem.count(u8, text, "\n")][]const u8 { + @setEvalBranchQuota(100_000); + var out: [std.mem.count(u8, text, "\n")][]const u8 = undefined; + var it = std.mem.splitScalar(u8, text, '\n'); + for (&out) |*line| line.* = it.next().?; + return out; +} + +const git_diff = + \\diff --git a/src/a.zig b/src/a.zig + \\index 1111111..2222222 100644 + \\--- a/src/a.zig + \\+++ b/src/a.zig + \\@@ -10,4 +10,4 @@ pub fn main() void { + \\ const a = 1; + \\-const b = 2; + \\+const b = 3; + \\+const c = 4; + \\ const d = 5; + \\--- x + \\@@ -40,2 +41,2 @@ + \\ keep(); + \\-gone(); + \\+added(); + \\diff --git a/old.py b/old.py + \\deleted file mode 100644 + \\--- a/old.py + \\+++ /dev/null + \\@@ -1,2 +0,0 @@ + \\-def f(): + \\- return 1 + \\ +; + +test "diff a walk follows the hunk counts, so a removed `--` line is no header" { + const lines = splitLines(git_diff); + var w: Walk = .{}; + var kinds: [lines.len]Kind = undefined; + for (lines, 0..) |line, i| kinds[i] = w.step(line).kind; + try testing.expectEqualSlices(Kind, &.{ + .meta, .meta, .old_path, .new_path, .hunk, + .context, .removed, .added, .added, .context, + .removed, .hunk, .context, .removed, .added, + .meta, .meta, .old_path, .new_path, .hunk, + .removed, .removed, + }, &kinds); +} + +test "diff plain diff -u output: no a/ b/, timestamps after the names" { + const lines = splitLines("--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++ + "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++ + "@@ -3 +3,2 @@\n" ++ + "-const a = 1;\n" ++ + "+const a = 2;\n" ++ + "+const b = 3;\n"); + try testing.expect(looksLikeDiff(&lines)); + var w: Walk = .{}; + for (lines) |line| _ = w.step(line); + try testing.expectEqualStrings("new/x.zig", w.path()); + try testing.expectEqualStrings("old/x.zig", w.old_path); +} + +test "diff output is told from other text by its first rows" { + const git = [_][]const u8{ "$ git diff", "diff --git a/x b/x", "index 1..2" }; + try testing.expect(looksLikeDiff(&git)); + const show = [_][]const u8{ "commit abc", "Author: a", "", " msg", "", "diff --git a/x b/x" }; + try testing.expect(looksLikeDiff(&show)); + const prose = [_][]const u8{ "--- a heading", "+++ not a path", "text" }; + try testing.expect(!looksLikeDiff(&prose)); + const nothing = [_][]const u8{ "ls", "a.zig b.zig" }; + try testing.expect(!looksLikeDiff(¬hing)); +} + +test "diff hunk headers: counts left out are one, a broken one is none" { + try testing.expectEqual(HunkHeader{ .old_start = 3, .old_count = 1, .new_start = 4, .new_count = 1 }, parseHunk("@@ -3 +4 @@").?); + try testing.expectEqual(HunkHeader{ .old_start = 1, .old_count = 0, .new_start = 0, .new_count = 0 }, parseHunk("@@ -1,0 +0,0 @@ fn x").?); + try testing.expect(parseHunk("@@ -a +1 @@") == null); + try testing.expect(parseHunk("@@ -1 +1") == null); + try testing.expect(parseHunk("@@@ -1 -1 +1 @@@") == null); +} + +test "diff a terminal's wrapped row stays in its line" { + const lines = [_][]const u8{ "--- a/x.c", "+++ b/x.c", "@@ -1,2 +1,2 @@", "+int a = 1; /* a long", "comment */", " int b;", "-int c;" }; + var w: Walk = .{ .continued_rows = true }; + var kinds: [lines.len]Kind = undefined; + for (lines, 0..) |line, k| kinds[k] = w.step(line).kind; + try testing.expectEqualSlices(Kind, &.{ .old_path, .new_path, .hunk, .added, .added, .context, .removed }, &kinds); +} diff --git a/src/exec.zig b/src/exec.zig index d114dbf8..5e223f04 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -738,6 +738,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { }; p.gpa.free(pane.command.?); pane.command = owned; + p.diff_output.drop(p.gpa, pane.serial); pane.command_done = false; pane.command_status = null; pane.command_pty = true; diff --git a/src/pardes.zig b/src/pardes.zig index b16d39b7..9fe6a1fb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4502,6 +4502,9 @@ pub const Pardes = struct { /// the terminal motion surface, memoized against the pane it was built /// for — see panes.terminal.RowsCache for the lifetime rule shell_rows: panes.terminal.RowsCache = .{}, + /// Finished command panes' output, and whether it is a diff + /// (File.DiffOutput). + diff_output: panes.File.DiffOutput = .{}, /// The message row's entry points, called from everywhere as /// `p.setMessage(...)`; they live with the rest of it in Messages.zig. @@ -4752,6 +4755,7 @@ pub const Pardes = struct { p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); + p.diff_output.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); diff --git a/src/syntax.zig b/src/syntax.zig index 8ed00bfa..b7406b04 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -5,6 +5,11 @@ const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); +const diff = @import("diff.zig"); + +test { + _ = diff; +} pub const enabled = config.syntax_highlighting; const zig_grammar = config.syntax_zig_grammar; @@ -183,6 +188,7 @@ pub fn stop() void { spec.objects = null; spec.objects_failed = false; } + dropDiffPieces(); ts_set_allocator(null, null, null, null); syntax_started = false; syntax_allocator = undefined; @@ -1029,35 +1035,269 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node { return null; } -pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { - const start_byte = @min(start_byte_raw, content.len); - const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); - const source = content[start_byte..end_byte]; - const styles = try gpa.alloc(u8, source.len); +/// A diff line's style byte carries whether the line was added or removed +/// over its syntax style (the low bits), so the painter can tint its row +/// and mark its prefix. +pub const diff_added: u8 = 0x80; +pub const diff_removed: u8 = 0x40; +pub const syn_bits: u8 = 0x3f; + +/// A file section's hunks are parsed together, each side as one text, in +/// pieces: a piece takes whole hunks until it holds `diff_piece_lines` +/// lines, and a hunk longer than `diff_piece_cut` is cut there, counted +/// from the section's first hunk. So a view parses about what a file's +/// view does (its rows and a margin), however many small hunks it shows, +/// and a 20k-line new file is parsed where it is shown, not whole. A string +/// or comment across a cut colours as a fragment would. +const diff_piece_lines = 40; +const diff_piece_cut = 80; + +/// Painted hunk pieces, keyed by their bytes and language: scrolling back +/// over a hunk, or a terminal repainting the same output every frame, +/// parses nothing again. Freed by `stop`. +const DiffPiece = struct { hash: u64, styles: []u8, used: u64 }; +var diff_pieces: [96]?DiffPiece = @splat(null); +var diff_clock: u64 = 0; + +fn dropDiffPieces() void { + for (&diff_pieces) |*slot| { + if (slot.*) |piece| syntax_allocator.free(piece.styles); + slot.* = null; + } + diff_clock = 0; +} + +/// The styles of `content[start..end)` read as a unified diff: headers +/// muted, `@@` lines as keywords, and each hunk's code in the language of +/// the file its section names. A hunk's old side (context and removed +/// lines) and new side (context and added lines) are each parsed whole, so +/// a string or comment that spans lines colours as it does in the file. +/// Added and removed lines carry `diff_added`/`diff_removed`. A file in no +/// language pardes knows keeps the plain colours: added lines as strings, +/// removed ones as numbers. +pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 { + return highlightDiffRows(gpa, content, start_raw, end_raw, false); +} + +/// The same for a terminal's rows, where a row the terminal wrapped +/// continues the diff line above it. +pub fn highlightDiffRows(gpa: std.mem.Allocator, content: []const u8, start_raw: usize, end_raw: usize, continued_rows: bool) ![]u8 { + const tz = tracy.zone(@src(), "highlightDiff"); + defer tz.end(); + const start_byte = @min(start_raw, content.len); + const end_byte = @max(start_byte, @min(end_raw, content.len)); + const styles = try gpa.alloc(u8, end_byte - start_byte); errdefer gpa.free(styles); @memset(styles, 0); + const Window = struct { + styles: []u8, + start: usize, + fn put(w: @This(), at: usize, bytes: []const u8) void { + const lo = @max(at, w.start); + const hi = @min(at + bytes.len, w.start + w.styles.len); + if (lo < hi) @memcpy(w.styles[lo - w.start .. hi - w.start], bytes[lo - at .. hi - at]); + } + fn fill(w: @This(), at: usize, len: usize, style: u8) void { + const lo = @max(at, w.start); + const hi = @min(at + len, w.start + w.styles.len); + if (lo < hi) @memset(w.styles[lo - w.start .. hi - w.start], style); + } + }; + const window: Window = .{ .styles = styles, .start = start_byte }; + + var walk: diff.Walk = .{ .continued_rows = continued_rows }; + // The run of hunk lines not painted yet, with each line's kind. + var piece_start: ?usize = null; + var piece_end: usize = 0; + var piece_path: []const u8 = ""; + var kinds: [diff_piece_cut]diff.Kind = undefined; + var nkinds: usize = 0; + var at = diff.anchorBefore(content, start_byte); + while (true) { + const nl = std.mem.indexOfScalarPos(u8, content, at, '\n') orelse content.len; + const got = walk.step(content[at..nl]); + const body = switch (got.kind) { + .context, .added, .removed, .no_newline => true, + // The next hunk of the section goes on in the same piece. + .hunk => piece_start != null and nkinds < diff_piece_lines, + else => false, + }; + if (piece_start) |ps| if (!body or nkinds == diff_piece_cut) { + if (piece_end >= start_byte) try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path); + piece_start = null; + nkinds = 0; + }; + if (at >= end_byte and piece_start == null) break; + if (body and (piece_start != null or got.kind != .hunk)) { + if (piece_start == null) { + piece_start = at; + piece_path = walk.path(); + } + kinds[nkinds] = got.kind; + nkinds += 1; + piece_end = nl; + } else if (nl >= start_byte) { + const style: Syn = switch (got.kind) { + .hunk => .keyword, + .meta, .old_path, .new_path => .comment, + else => .none, + }; + if (style != .none) window.fill(at, nl - at, @intFromEnum(style)); + } + if (nl >= content.len) break; + at = nl + 1; + } + if (piece_start) |ps| if (piece_end >= start_byte) + try paintDiffPiece(gpa, window, content[ps..piece_end], ps, kinds[0..nkinds], piece_path); + return styles; +} +fn paintDiffPiece(gpa: std.mem.Allocator, window: anytype, bytes: []const u8, at: usize, kinds: []const diff.Kind, path: []const u8) !void { + const selected: ?Selected = if (enabled and syntax_started) (forExt(std.fs.path.extension(path)) catch null) else null; + if (selected) |sel| { + const hash = std.hash.Wyhash.hash(std.hash.Wyhash.hash(0, sel.name), bytes); + diff_clock += 1; + for (&diff_pieces) |*slot| if (slot.*) |*piece| if (piece.hash == hash and piece.styles.len == bytes.len) { + piece.used = diff_clock; + return window.put(at, piece.styles); + }; + const colored = try syntax_allocator.alloc(u8, bytes.len); + errdefer syntax_allocator.free(colored); + try paintHunk(gpa, colored, bytes, kinds, sel); + window.put(at, colored); + var oldest: usize = 0; + for (&diff_pieces, 0..) |*slot, i| { + const piece = slot.* orelse { + oldest = i; + break; + }; + if (diff_pieces[oldest]) |o| if (piece.used < o.used) { + oldest = i; + }; + } + if (diff_pieces[oldest]) |old| syntax_allocator.free(old.styles); + diff_pieces[oldest] = .{ .hash = hash, .styles = colored, .used = diff_clock }; + return; + } + // No language: each line in one colour, as diffs were always shown. + var lines = std.mem.splitScalar(u8, bytes, '\n'); var offset: usize = 0; - var lines = std.mem.splitScalar(u8, source, '\n'); - while (lines.next()) |line| { - const syn = diffLineSyn(line); - if (syn != .none) @memset(styles[offset .. offset + line.len], @intFromEnum(syn)); + for (kinds) |kind| { + const line = lines.next() orelse break; + const style: u8 = switch (kind) { + .added => @intFromEnum(Syn.string) | diff_added, + .removed => @intFromEnum(Syn.number) | diff_removed, + .no_newline => @intFromEnum(Syn.comment), + .hunk => @intFromEnum(Syn.keyword), + else => 0, + }; + if (style != 0) window.fill(at + offset, line.len, style); offset += line.len + 1; } - return styles; } -fn diffLineSyn(line: []const u8) Syn { - if (std.mem.startsWith(u8, line, "@@")) return .keyword; - if (std.mem.startsWith(u8, line, "+++") or - std.mem.startsWith(u8, line, "---") or - std.mem.startsWith(u8, line, "diff ") or - std.mem.startsWith(u8, line, "index ") or - std.mem.startsWith(u8, line, "\\ No newline")) return .comment; - if (line.len == 0) return .none; - if (line[0] == '+') return .string; - if (line[0] == '-') return .number; - return .none; +/// One piece's styles: its old side and its new side each parsed as one +/// text, and every line's code coloured from its side, behind its +/// one-character prefix. A `@@` line between its hunks is in neither. +fn paintHunk(gpa: std.mem.Allocator, out: []u8, bytes: []const u8, kinds: []const diff.Kind, sel: Selected) !void { + @memset(out, 0); + const old_src = try gpa.alloc(u8, bytes.len); + defer gpa.free(old_src); + const new_src = try gpa.alloc(u8, bytes.len); + defer gpa.free(new_src); + var old_len: usize = 0; + var new_len: usize = 0; + var lines = std.mem.splitScalar(u8, bytes, '\n'); + for (kinds) |kind| { + const line = lines.next() orelse break; + const code = line[codeStart(line, kind)..]; + // A row a terminal wrapped goes on the line above, as it was printed. + const joined = continuesRow(line, kind); + if (kind == .context or kind == .removed) { + if (joined and old_len > 0) old_len -= 1; + @memcpy(old_src[old_len..][0..code.len], code); + old_len += code.len; + old_src[old_len] = '\n'; + old_len += 1; + } + if (kind == .context or kind == .added) { + if (joined and new_len > 0) new_len -= 1; + @memcpy(new_src[new_len..][0..code.len], code); + new_len += code.len; + new_src[new_len] = '\n'; + new_len += 1; + } + } + // A side is parsed only when a line takes its colours from it: the old + // side for removed lines, the new side for added lines, and context + // from the new side, else from the old. A piece that only adds or only + // removes is one parse. + var removes = false; + var adds = false; + for (kinds) |kind| { + removes = removes or kind == .removed; + adds = adds or kind == .added; + } + const context_new = adds or !removes; + const old_colors = try gpa.alloc(u8, old_len); + defer gpa.free(old_colors); + @memset(old_colors, 0); + if (!context_new or removes) paint(old_colors, old_src[0..old_len], sel); + const new_colors = try gpa.alloc(u8, new_len); + defer gpa.free(new_colors); + @memset(new_colors, 0); + if (context_new) paint(new_colors, new_src[0..new_len], sel); + + lines = std.mem.splitScalar(u8, bytes, '\n'); + var offset: usize = 0; + var old_at: usize = 0; + var new_at: usize = 0; + for (kinds) |kind| { + const line = lines.next() orelse break; + defer offset += line.len + 1; + const skip = codeStart(line, kind); + const code_len = line.len - skip; + const dst = out[offset + skip ..][0..code_len]; + if (continuesRow(line, kind)) { + if ((kind == .context or kind == .removed) and old_at > 0) old_at -= 1; + if ((kind == .context or kind == .added) and new_at > 0) new_at -= 1; + } + switch (kind) { + .removed => @memcpy(dst, old_colors[old_at..][0..code_len]), + .added => @memcpy(dst, new_colors[new_at..][0..code_len]), + .context => @memcpy(dst, if (context_new) new_colors[new_at..][0..code_len] else old_colors[old_at..][0..code_len]), + .no_newline => @memset(out[offset..][0..line.len], @intFromEnum(Syn.comment)), + .hunk => @memset(out[offset..][0..line.len], @intFromEnum(Syn.keyword)), + else => {}, + } + if (kind == .context or kind == .removed) old_at += code_len + 1; + if (kind == .context or kind == .added) new_at += code_len + 1; + const flag: u8 = switch (kind) { + .added => diff_added, + .removed => diff_removed, + else => 0, + }; + if (flag != 0) for (out[offset..][0..line.len]) |*b| { + b.* |= flag; + }; + } +} + +fn continuesRow(line: []const u8, kind: diff.Kind) bool { + return line.len > 0 and codeStart(line, kind) == 0 and + (kind == .context or kind == .added or kind == .removed); +} + +/// Where a hunk line's code starts: past its prefix, which a blank context +/// line (its space stripped) and a terminal's wrapped row have none of. +fn codeStart(line: []const u8, kind: diff.Kind) usize { + if (line.len == 0) return 0; + return switch (kind) { + .context => @intFromBool(line[0] == ' '), + .added => @intFromBool(line[0] == '+'), + .removed => @intFromBool(line[0] == '-'), + else => 0, + }; } test "syntax a results row is coloured by the file its location names" { @@ -1268,31 +1508,215 @@ test "syntax plain Markdown keeps block styles without starting the inline parse } test "syntax highlightDiff colors unified diff lines by prefix" { - const diff = + const diff_text = "diff --git a/x b/x\n" ++ "--- a/x\n" ++ "+++ b/x\n" ++ - "@@ -1,3 +1,3 @@\n" ++ + "@@ -1,2 +1,2 @@\n" ++ " context\n" ++ "-old line\n" ++ "+new line\n"; - const styles = try highlightDiff(std.testing.allocator, diff, 0, diff.len); + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "diff --git", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "--- a/x", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "+++ b/x", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -1,2", 0)); + try std.testing.expectEqual(Syn.none, diffSynAt(styles, diff_text, " context", 0)); + // A file in no known language: each line in one colour, and tinted. + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "-old line", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+new line", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "-old line").?] & diff_removed != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "+new line").? + 4] & diff_added != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, " context").?] & (diff_added | diff_removed) == 0); +} - const byteSyn = struct { - fn at(s: []const u8, src: []const u8, needle: []const u8) Syn { - const i = std.mem.indexOf(u8, src, needle).?; - return @enumFromInt(s[i]); - } - }.at; +fn diffSynAt(styles: []const u8, text: []const u8, needle: []const u8, offset: usize) Syn { + return @enumFromInt(styles[std.mem.indexOf(u8, text, needle).? + offset] & syn_bits); +} + +test "syntax a Zig hunk is coloured as Zig, each side parsed whole" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "diff --git a/src/shape.zig b/src/shape.zig\n" ++ + "index 1111111..2222222 100644\n" ++ + "--- a/src/shape.zig\n" ++ + "+++ b/src/shape.zig\n" ++ + "@@ -1,6 +1,6 @@\n" ++ + " const Shape = struct {\n" ++ + "- side: u8 = 7,\n" ++ + "+ side: u16 = 42,\n" ++ + " const note =\n" ++ + "- \\\\old words defer\n" ++ + "+ \\\\new words defer\n" ++ + " ;\n" ++ + " };\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "struct", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "7,", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "42", 0)); + // A multiline string, told apart only by parsing the side it is on: + // `defer` inside it is no keyword. + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "old words defer", 10)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "new words defer", 10)); + // The prefix is no code: it carries the tint alone. + const added = std.mem.indexOf(u8, diff_text, "+ side").?; + try std.testing.expectEqual(diff_added, styles[added]); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "42").?] & diff_added != 0); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "7,").?] & diff_removed != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "index 111", 0)); + + // A window in the middle is coloured as the whole is. + const from = std.mem.indexOf(u8, diff_text, " const note").?; + const part = try highlightDiff(std.testing.allocator, diff_text, from, diff_text.len); + defer std.testing.allocator.free(part); + try std.testing.expectEqualSlices(u8, styles[from..], part); +} + +test "syntax a Python hunk is coloured as Python" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "--- a/tool.py\n" ++ + "+++ b/tool.py\n" ++ + "@@ -1,3 +1,4 @@\n" ++ + " def run(n):\n" ++ + "- return n\n" ++ + "+ # doubled now\n" ++ + "+ return n * 2\n" ++ + " \n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "def", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "return n\n", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "# doubled", 2)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2\n", 0)); +} + +test "syntax a deleted file's lines are coloured in its language" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "diff --git a/gone.zig b/gone.zig\n" ++ + "deleted file mode 100644\n" ++ + "--- a/gone.zig\n" ++ + "+++ /dev/null\n" ++ + "@@ -1,2 +0,0 @@\n" ++ + "-const Gone = enum { a };\n" ++ + "-// the end\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "enum", 0)); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "// the end", 3)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "enum").?] & diff_removed != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, diff_text, "deleted file", 0)); +} + +test "syntax a hunk in no known language keeps the line colours" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const diff_text = + "--- a/notes.unknownext\n" ++ + "+++ b/notes.unknownext\n" ++ + "@@ -1 +1 @@\n" ++ + "-const struct 1\n" ++ + "+const struct 2\n"; + const styles = try highlightDiff(std.testing.allocator, diff_text, 0, diff_text.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "struct 1", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "struct 2", 0)); + try std.testing.expectEqual(Syn.string, diffSynAt(styles, diff_text, "+const struct 2", 0)); +} - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "diff --git")); - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "--- a/x")); - try std.testing.expectEqual(Syn.comment, byteSyn(styles, diff, "+++ b/x")); - try std.testing.expectEqual(Syn.keyword, byteSyn(styles, diff, "@@ -1,3")); - try std.testing.expectEqual(Syn.none, byteSyn(styles, diff, " context")); - try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line")); - try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line")); +test "syntax git diff output in a command pane is told and coloured by its rows" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + // A command pane's rows: its command line, then git's output, with one + // long added line the terminal wrapped onto a second row. + const rows = [_][]const u8{ + "git diff", + "diff --git a/main.zig b/main.zig", + "--- a/main.zig", + "+++ b/main.zig", + "@@ -1,2 +1,2 @@", + " pub fn main() void {", + "+ const answer: u32 = 42; // the ", + "wrapped rest", + "- return;", + "", + }; + try std.testing.expect(diff.looksLikeDiff(&rows)); + try std.testing.expect(!diff.looksLikeDiff(rows[5..])); + const text = try std.mem.join(std.testing.allocator, "\n", &rows); + defer std.testing.allocator.free(text); + const styles = try highlightDiffRows(std.testing.allocator, text, 0, text.len, true); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(Syn.none, diffSynAt(styles, text, "git diff", 0)); + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, text, "fn main", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, text, "42", 0)); + // The wrapped row stays an added line, and inside the comment. + try std.testing.expect(styles[std.mem.indexOf(u8, text, "wrapped rest").?] & diff_added != 0); + try std.testing.expectEqual(Syn.comment, diffSynAt(styles, text, "wrapped rest", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, text, "return").?] & diff_removed != 0); +} + +test "syntax a section's small hunks share one parse" { + if (!enabled) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const diff_text = + "--- a/x.zig\n+++ b/x.zig\n" ++ + "@@ -1 +1 @@\n-const a = 1;\n+const a = 2;\n" ++ + "@@ -10 +10 @@\n-const b = 1;\n+const b = 2;\n" ++ + "@@ -20 +20 @@\n-const c = 1;\n+const c = 2;\n" ++ + "--- a/y.zig\n+++ b/y.zig\n" ++ + "@@ -1 +1 @@\n-const d = 1;\n+const d = 2;\n"; + const styles = try highlightDiff(gpa, diff_text, 0, diff_text.len); + defer gpa.free(styles); + var cached: usize = 0; + for (diff_pieces) |slot| cached += @intFromBool(slot != null); + try std.testing.expectEqual(@as(usize, 2), cached); // x.zig's three hunks, and y.zig's + try std.testing.expectEqual(Syn.keyword, diffSynAt(styles, diff_text, "@@ -10", 0)); + try std.testing.expectEqual(Syn.number, diffSynAt(styles, diff_text, "2;\n@@ -20", 0)); + try std.testing.expect(styles[std.mem.indexOf(u8, diff_text, "@@ -10").?] & (diff_added | diff_removed) == 0); +} + +test "syntax a hunk painted once is kept, and a long one is parsed in pieces" { + if (!enabled) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + var text: std.Io.Writer.Allocating = .init(gpa); + defer text.deinit(); + const n = diff_piece_cut * 20 + 10; + try text.writer.print("--- /dev/null\n+++ b/big.zig\n@@ -0,0 +1,{d} @@\n", .{n}); + for (0..n) |i| try text.writer.print("+const v{d} = {d};\n", .{ i, i }); + const content = text.written(); + const at = std.mem.indexOf(u8, content, "+const v1000 ").?; + const window = try highlightDiff(gpa, content, at, at + 200); + defer gpa.free(window); + try std.testing.expectEqual(Syn.number, @as(Syn, @enumFromInt(window["+const v1000 = ".len] & syn_bits))); + var cached: usize = 0; + for (diff_pieces) |slot| if (slot) |piece| { + cached += 1; + // One piece of many, not the whole hunk. + try std.testing.expect(piece.styles.len < content.len / 4); + }; + try std.testing.expectEqual(@as(usize, 1), cached); + const again = try highlightDiff(gpa, content, at, at + 200); + defer gpa.free(again); + try std.testing.expectEqualSlices(u8, window, again); + cached = 0; + for (diff_pieces) |slot| cached += @intFromBool(slot != null); + try std.testing.expectEqual(@as(usize, 1), cached); } test "syntax result fragments preserve source indentation and inline markup" { @@ -1526,3 +1950,4 @@ test "syntax source analysis preserves declarations and injected colors" { try std.testing.expectEqual(@as(usize, 0), unsupported.declarations.len); try std.testing.expectEqual(@as(usize, 0), unsupported.colors.len); } + diff --git a/test/snapshots/diffview.golden b/test/snapshots/diffview.golden new file mode 100644 index 00000000..2da9f6e3 --- /dev/null +++ b/test/snapshots/diffview.golden @@ -0,0 +1,119 @@ +== snap diff-pane grid=100x31 cursor=7,3 +|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +| /tmp/pardes-snap/diffview/cwd/change.diff Save Tty Collapse Del +| 1 diff --git a/src/shape.zig b/src/shape.zig +| 2 index 1111111..2222222 100644 +| 3 --- a/src/shape.zig +| 4 +++ b/src/shape.zig +| 5 @@ -1,10 +1,12 @@ +| 6 const std = @import("std"); +| 7 +| 8 pub const Shape = union(enum) { +| 9 circle: f32, +| 10 - square: u8 = 7, +| 11 + square: f32, +| 12 + triangle: struct { base: f32, height: f32 }, +| 13 +| 14 const note = +| 15 - \\areas +| 16 + \\areas, defer +| 17 + \\summed +| 18 ; +| 19 }; +| 20 +== style diff-colours grid=100x31 +|0: 0-99 #b2c3b8,#1d2824, +|1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738, +|2: 0-1 d,#add38a, 2-2 d,#314738, 3-32 #becfc4,#314738, 33-44 #dfbce5,#314738, 45-99 #becfc4,#314738, +|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-99 #b9f480,#0d1410, +|4: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410, +|5: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410, +|6: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b9f480,#0d1410, +|7: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #c9a7ed,#0d1410, +|8: 0-0 d,#608269, 1-4 d,#0d1410, 5-7 #59675c,#0d1410, 8-19 #e5eee5,#0d1410, 20-26 #c9a7ed,#0d1410, 27-27 #e5eee5,#0d1410, 28-32 #b8d88a,#0d1410, 33-99 #e5eee5,#0d1410, +|9: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|10: 0-0 d,#608269, 1-4 d,#0d1410, 5-7 #59675c,#0d1410, 8-25 #e5eee5,#0d1410, 26-30 #c9a7ed,#0d1410, 31-31 #e5eee5,#0d1410, 32-35 #c9a7ed,#0d1410, 36-99 #e5eee5,#0d1410, +|11: 0-0 d,#608269, 1-4 d,#0d1410, 5-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410, +|12: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #f1a39b,#302b25,b 8-11 #f1a39b,#302b25, 12-24 #e5eee5,#302b25, 25-25 #f8c082,#302b25, 26-99 #e5eee5,#302b25, +|13: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-11 #b6ce94,#263025, 12-99 #e5eee5,#263025, +|14: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-11 #b6ce94,#263025, 12-21 #e5eee5,#263025, 22-28 #c9a7ed,#263025, 29-99 #e5eee5,#263025, +|15: 0-0 d,#608269, 1-3 d,#0d1410, 4-99 #59675c,#0d1410, +|16: 0-0 d,#608269, 1-3 d,#0d1410, 4-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410, +|17: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #f1a39b,#302b25,b 8-15 #f1a39b,#302b25, 16-99 #b8d88a,#302b25, +|18: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-15 #b6ce94,#263025, 16-99 #b8d88a,#263025, +|19: 0-0 d,#608269, 1-3 d,#0d1410, 4-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-15 #b6ce94,#263025, 16-99 #b8d88a,#263025, +|20: 0-0 d,#608269, 1-3 d,#0d1410, 4-11 #59675c,#0d1410, 12-99 #e5eee5,#0d1410, +|21: 0-0 d,#608269, 1-3 d,#0d1410, 4-7 #59675c,#0d1410, 8-99 #e5eee5,#0d1410, +|22: 0-0 d,#608269, 1-3 d,#0d1410, 4-99 #59675c,#0d1410, +|23: 0-0 d,#608269, 1-99 d,#0d1410, +|24: 0-0 d,#608269, 1-99 d,#0d1410, +|25: 0-0 d,#608269, 1-99 d,#0d1410, +|26: 0-0 d,#608269, 1-99 d,#0d1410, +|27: 0-0 d,#608269, 1-99 d,#0d1410, +|28: 0-0 d,#608269, 1-99 d,#0d1410, +|29: 0-0 d,#608269, 1-99 d,#0d1410, +|30: 0-0 d,#608269, 1-99 d,#0d1410, +== snap command-pane grid=100x31 cursor=7,3 +|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +| /tmp/pardes-snap/diffview/cwd/cmds.txt Save Tty Collapse Del +| 1 cat change.diff Exec +| 2 +| +| +| +| +| +| +| +| +| +| +| +| +| /tmp/pardes-snap/diffview/cwd (cat change.diff) exit 0 Kill Save Collapse Del +| circle: f32, +| - square: u8 = 7, +| + square: f32, +| + triangle: struct { base: f32, height: f32 }, +| +| const note = +| - \\areas +| + \\areas, defer +| + \\summed +| ; +| }; +| exit 0 +== style command-colours grid=100x31 +|0: 0-99 #b2c3b8,#1d2824, +|1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738, +|2: 0-1 d,#add38a, 2-2 d,#314738, 3-32 #becfc4,#314738, 33-41 #dfbce5,#314738, 42-99 #becfc4,#314738, +|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-4 d,#0d1410,b 5-6 #616f64,#0d1410,b 7-93 #e5eee5,#0d1410, 94-94 #e5eee5,#1d2824, 95-99 #b2c3b8,#1d2824, +|4: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410, +|5: 0-0 d,#608269, 1-99 d,#0d1410, +|6: 0-0 d,#608269, 1-99 d,#0d1410, +|7: 0-0 d,#608269, 1-99 d,#0d1410, +|8: 0-0 d,#608269, 1-99 d,#0d1410, +|9: 0-0 d,#608269, 1-99 d,#0d1410, +|10: 0-0 d,#608269, 1-99 d,#0d1410, +|11: 0-0 d,#608269, 1-99 d,#0d1410, +|12: 0-0 d,#608269, 1-99 d,#0d1410, +|13: 0-0 d,#608269, 1-99 d,#0d1410, +|14: 0-0 d,#608269, 1-99 d,#0d1410, +|15: 0-0 d,#608269, 1-99 d,#0d1410, +|16: 0-0 d,#608269, 1-99 d,#0d1410, +|17: 0-1 d,#496656, 2-2 d,#1d2824, 3-99 #b2c3b8,#1d2824, +|18: 0-0 d,#152019, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410, +|19: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #f1a39b,#302b25,b 3-6 #f1a39b,#302b25, 7-19 #e5eee5,#302b25, 20-20 #f8c082,#302b25, 21-99 #e5eee5,#302b25, +|20: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-6 #b6ce94,#263025, 7-99 #e5eee5,#263025, +|21: 0-0 d,#152019, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-6 #b6ce94,#263025, 7-16 #e5eee5,#263025, 17-23 #c9a7ed,#263025, 24-99 #e5eee5,#263025, +|22: 0-0 d,#152019, 1-99 d,#0d1410, +|23: 0-0 d,#608269, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410, +|24: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #f1a39b,#302b25,b 3-10 #f1a39b,#302b25, 11-99 #b8d88a,#302b25, +|25: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-10 #b6ce94,#263025, 11-99 #b8d88a,#263025, +|26: 0-0 d,#608269, 1-1 d,#0d1410, 2-2 #b6ce94,#263025,b 3-10 #b6ce94,#263025, 11-99 #b8d88a,#263025, +|27: 0-0 d,#608269, 1-6 d,#0d1410, 7-99 #e5eee5,#0d1410, +|28: 0-0 d,#608269, 1-2 d,#0d1410, 3-99 #e5eee5,#0d1410, +|29: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410, +|30: 0-0 d,#152019, 1-99 d,#0d1410, diff --git a/test/snapshots/diffview.snap b/test/snapshots/diffview.snap new file mode 100644 index 00000000..b5900db9 --- /dev/null +++ b/test/snapshots/diffview.snap @@ -0,0 +1,27 @@ +# A diff pane, for reviewing a change: each hunk's code coloured as Zig (a +# multi-line string over three lines, `defer` inside it no keyword), added and +# removed rows tinted to the pane's edge with their prefix in the tint's hue, +# headers muted. +dirmk .git +dirmk src +file src/shape.zig const std = @import("std");\n\npub const Shape = union(enum) {\n circle: f32,\n square: f32,\n triangle: struct { base: f32, height: f32 },\n\n const note =\n \\\\areas, defer\n \\\\summed\n ;\n};\n +file change.diff diff --git a/src/shape.zig b/src/shape.zig\nindex 1111111..2222222 100644\n--- a/src/shape.zig\n+++ b/src/shape.zig\n@@ -1,10 +1,12 @@\n const std = @import("std");\n \n pub const Shape = union(enum) {\n circle: f32,\n- square: u8 = 7,\n+ square: f32,\n+ triangle: struct { base: f32, height: f32 },\n \n const note =\n- \\\\areas\n+ \\\\areas, defer\n+ \\\\summed\n ;\n };\n +start 31 100 change.diff +wait 8000 Newcol Joincol +stable 700 20000 +snap diff-pane +snapstyle diff-colours +# The same diff printed by a command (`git diff`, here `cat`) into a command +# pane: its output is told for a diff and coloured the same, over the colours +# the program printed. +file cmds.txt cat change.diff\n +start 31 100 cmds.txt +wait 8000 cmds.txt +stable 700 20000 +press middle 7 4 +drag middle 22 4 +release middle 22 4 +wait 10000 exit 0 +stable 700 15000 +snap command-pane +snapstyle command-colours -- 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') 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 From a898c0cce8377150417e7d8dda8f952ffdf241ab Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 15:39:27 -0300 Subject: A right click on a diff line opens the place it names: its file, the hunk's new line, the line itself In a diff pane (a .diff/.patch file, or a command pane's diff output), the look's own expansion, which its hover shows, takes a whole line for a `diff --git`, `---`, `+++` or `@@` line wherever the pointer is on it, markers included, and for a hunk line when the pointer is on its `+`, `-` or space. Then the look opens the file (a/ b/ dropped, timestamps cut) at the line from the hunk header and the lines above it: `@@` the hunk's first new line, context and added lines their own, a removed line the new line now where it was. `diff --git` and `+++` open the new file; `---` the old one, unless the `+++` under it names another. On a hunk line's code the expansion is its words, the prefix never one of them, and looks as ever. Other panes are untouched. Paths resolve in the repository (walking up to `.git` or `.jj` from the diff's directory, or a command pane's), then that directory. A file not here opens nothing and says `Look: b/x: no such file here`, or `deleted by this diff`. A 9P look writing a whole line of the diff does the same. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 2 + docs/tags.md | 18 ++ src/CHANGELOG.md | 4 + src/diff.zig | 177 +++++++++++++++++- src/exec.zig | 46 ++++- src/look.zig | 395 ++++++++++++++++++++++++++++++++++++++++- src/pardes.zig | 4 + test/snapshots/diffview.golden | 29 +++ test/snapshots/diffview.snap | 14 +- 9 files changed, 672 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/docs/fs.md b/docs/fs.md index 146ff17b..864bfd12 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -208,6 +208,8 @@ A line written to `look` is a right click on it: too. - a directory types `ls` into a terminal idle there, else opens one there. - a URL opens in the browser. +- in a diff pane, a whole line of the diff opens the place it names, as a + right click on its first column does ([tags.md](tags.md#reviewing-diffs)). - a plain word selects its next place after dot, wrapping (`LookWord list` on the root ctl lists every place in a `+Search` pane instead). In a terminal a word is always listed, rows spelled `@p3:12:5-9`. diff --git a/docs/tags.md b/docs/tags.md index 4d4e9178..993e8c0d 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -78,6 +78,24 @@ their `+`/`-` in the tint's hue (the theme's ANSI green and red where it has them); a file in no language pardes knows keeps the plain line colours. Only the hunks in view are parsed, a few dozen lines at a time, each once. +Then right-click to jump; the look's hover shows what the click takes. On +a `diff --git`, `---` or `+++` line, anywhere on it, markers included, that +is the whole line, and it opens its file: `diff --git a/x b/y` and `+++` +the new one, `---` the old one unless the `+++` under it names another (a +rename, a new file, `diff -u old/x new/x`), since the old name is the one +likely gone. On a `@@ -a,b +c,d @@` line it is the whole line again, and +opens the new file at line c. On a hunk line, the `+`, `-` or space in its +first column takes the whole line and opens the new file at that line; for +a removed line, at the new line now standing where it was (the one after +it, or the hunk's last when it went from the end of the file). The code +after it is words, looked at as ever. Paths are the repository's: pardes +walks up from the diff's directory (a command pane's own) to a `.git` or +`.jj` and drops git's `a/`/`b/`, else takes them from that directory as +written; `diff -u`'s timestamps are cut. A file that is not here (a branch +not checked out) opens nothing and says so: `Look: b/x.zig: no such file +here`, or `deleted by this diff` for a deleted one. A 9P `look` of a whole +line of the diff does the same. + ## Editing tags A tag is text like a body, with the body's normal and insert modes and undo. diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 5f53ff8a..d6b4846b 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -7,6 +7,10 @@ file's language, its old and new side each parsed whole, and tint added and removed rows to the pane's edge with their `+`/`-` in the tint's hue. Only the hunks in view are parsed, once each. +- A right click on a diff's `+++`/`---`/`diff --git` line opens its file, + on a `@@` line the new file at the hunk, and on a hunk line's `+`, `-` or + space the new file at that line (a removed line: where it was). Paths are + the repository's; a file not here says so and opens nothing. ## 0.0.8 diff --git a/src/diff.zig b/src/diff.zig index 98942469..d129c222 100644 --- a/src/diff.zig +++ b/src/diff.zig @@ -1,7 +1,7 @@ //! A unified diff (`git diff`, `diff -u`), read a line at a time: which //! file each section is about, what each line is, and where a hunk line //! stands in the old and the new file. The syntax painter (syntax.zig -//! highlightDiff) walks it. +//! highlightDiff) and a look on a diff line (look.zig) both walk it. //! //! A hunk's extent comes from the counts in its `@@` header, as patch //! reads it, so a removed line that itself starts `--` (`--- x`) is not @@ -243,6 +243,85 @@ pub fn looksLikeDiff(lines: []const []const u8) bool { return false; } +/// A look on one line of a diff: the file and line it names. +pub const Target = struct { + /// The line's own kind. + kind: Kind, + /// As written in the diff, `a/`/`b/` kept. + path: []const u8, + /// From 1; 0 for the file alone (a `---`/`+++` line). + line: usize, + /// The section deletes the file, and this is its old side. + old_side: bool = false, +}; + +/// What a look at `lines[row]` opens, or null for a line that is no part +/// of a file section (a commit message, `index ...`). The whole line is +/// the look's: a click on a `+++`, `---` or `@@` marker is one on its path +/// or its numbers. +/// +/// `diff --git a/x b/y` opens y, and `+++ y` opens y. `--- x` opens x, +/// unless the `+++` under it names another file (a rename, a new file, +/// `diff -u old/x new/x`): then that one, the old name being the one +/// likely gone. +/// +/// A removed line opens the new file at the line now standing where it +/// was: the new line after it, or the hunk's last new line when it went +/// from the end of the file. In a section that deletes its file there is +/// no new file, so a removed or context line opens the old one at its old +/// line (it is there while the diff is not applied). +pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Target { + if (row >= lines.len) return null; + var w: Walk = .{ .continued_rows = continued_rows }; + var r = anchorRow(lines, row); + var got: Line = .{ .kind = .other }; + while (r <= row) : (r += 1) got = w.step(lines[r]); + const line = std.mem.trimEnd(u8, lines[row], "\r"); + switch (got.kind) { + .other, .meta, .no_newline => { + // `diff --git a/x b/y`: the file, as its `+++` would. + if (got.kind == .meta and std.mem.startsWith(u8, line, "diff ") and w.git_path.len > 0) + return .{ .kind = got.kind, .path = w.git_path, .line = 0 }; + return null; + }, + .old_path, .new_path => { + const own = cutPath(line[4..]); + // `---` names the old file, but the new one on the `+++` under + // it wins when they differ: a new file (`--- /dev/null`), a + // rename, `diff -u old/x new/x`. The old name is the one + // likely gone. + if (got.kind == .old_path and row + 1 < lines.len and std.mem.startsWith(u8, lines[row + 1], "+++ ")) { + const new = cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]); + if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, stripSide(new), stripSide(own)))) + return .{ .kind = got.kind, .path = new, .line = 0 }; + } + if (!isDevNull(own)) return .{ .kind = got.kind, .path = own, .line = 0, .old_side = got.kind == .old_path and w.deleted() }; + if (w.path().len == 0) return null; + return .{ .kind = got.kind, .path = w.path(), .line = 0, .old_side = w.deleted() }; + }, + .hunk => { + if (w.path().len == 0) return null; + if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true }; + return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.new) }; + }, + .context, .added, .removed => { + if (w.path().len == 0) return null; + if (w.deleted()) return .{ .kind = got.kind, .path = w.path(), .line = @max(1, got.old), .old_side = true }; + var at = got.new; + // Gone from the end of the file: nothing stands after it. + if (got.kind == .removed and w.new_count > 0 and at >= w.new_start + w.new_count) + at = w.new_start + w.new_count - 1; + return .{ .kind = got.kind, .path = w.path(), .line = @max(1, at) }; + }, + } +} + +/// `a/x` and `b/x` as git writes them: the name in the repository. +pub fn stripSide(path: []const u8) []const u8 { + if (path.len > 2 and (path[0] == 'a' or path[0] == 'b') and path[1] == '/') return path[2..]; + return path; +} + // ---- tests ---- const testing = std.testing; @@ -295,6 +374,52 @@ test "diff a walk follows the hunk counts, so a removed `--` line is no header" }, &kinds); } +test "diff a look on each kind of line names the new file and its line" { + const lines = splitLines(git_diff); + const expect = struct { + fn at(ls: []const []const u8, row: usize, path: []const u8, line: usize) !void { + const t = targetAt(ls, row, false) orelse return error.NoTarget; + try testing.expectEqualStrings(path, t.path); + try testing.expectEqual(line, t.line); + } + }; + try expect.at(&lines, 0, "b/src/a.zig", 0); // diff --git + try testing.expect(targetAt(&lines, 1, false) == null); // index + try expect.at(&lines, 2, "a/src/a.zig", 0); // --- + try expect.at(&lines, 3, "b/src/a.zig", 0); // +++ + try expect.at(&lines, 4, "b/src/a.zig", 10); // @@ + try expect.at(&lines, 5, "b/src/a.zig", 10); // context + try expect.at(&lines, 6, "b/src/a.zig", 11); // removed: the line now there + try expect.at(&lines, 7, "b/src/a.zig", 11); // added + try expect.at(&lines, 8, "b/src/a.zig", 12); + try expect.at(&lines, 9, "b/src/a.zig", 13); + // `--- x` is a removed line, gone from past the hunk's new lines. + try expect.at(&lines, 10, "b/src/a.zig", 13); + try expect.at(&lines, 11, "b/src/a.zig", 41); + try expect.at(&lines, 12, "b/src/a.zig", 41); + try expect.at(&lines, 13, "b/src/a.zig", 42); + try expect.at(&lines, 14, "b/src/a.zig", 42); + // A removed line of a deleted file: its old line in the old file. + const gone = targetAt(&lines, 21, false).?; + try testing.expectEqualStrings("a/old.py", gone.path); + try testing.expectEqual(@as(usize, 2), gone.line); + try testing.expect(gone.old_side); + try testing.expect(targetAt(&lines, 1, false) == null); +} + +test "diff a removed line at the end of a file opens the hunk's last new line" { + const lines = splitLines( + \\--- a/f.c + \\+++ b/f.c + \\@@ -1,3 +1,2 @@ + \\ one + \\ two + \\-three + \\ + ); + try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, false).?.line); +} + test "diff plain diff -u output: no a/ b/, timestamps after the names" { const lines = splitLines("--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++ "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++ @@ -303,10 +428,45 @@ test "diff plain diff -u output: no a/ b/, timestamps after the names" { "+const a = 2;\n" ++ "+const b = 3;\n"); try testing.expect(looksLikeDiff(&lines)); - var w: Walk = .{}; - for (lines) |line| _ = w.step(line); - try testing.expectEqualStrings("new/x.zig", w.path()); - try testing.expectEqualStrings("old/x.zig", w.old_path); + const t = targetAt(&lines, 5, false).?; + try testing.expectEqualStrings("new/x.zig", t.path); + try testing.expectEqual(@as(usize, 4), t.line); + // `--- old/x.zig` with `+++ new/x.zig` under it: the new file. + try testing.expectEqualStrings("new/x.zig", targetAt(&lines, 0, false).?.path); + try testing.expectEqualStrings("new/x.zig", stripSide("new/x.zig")); + try testing.expectEqualStrings("x.zig", stripSide("b/x.zig")); +} + +test "diff a --- line opens the new name when the +++ under it names another" { + const lines = splitLines( + \\diff --git a/old name.zig b/new name.zig + \\similarity index 90% + \\rename from old name.zig + \\rename to new name.zig + \\--- a/old name.zig + \\+++ b/new name.zig + \\@@ -1 +1 @@ + \\-a + \\+b + \\ + ); + try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 0, false).?.path); + try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 4, false).?.path); + try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 5, false).?.path); + try testing.expect(targetAt(&lines, 2, false) == null); +} + +test "diff a new file's --- /dev/null opens the file it makes" { + const lines = splitLines( + \\--- /dev/null + \\+++ b/made.zig + \\@@ -0,0 +1 @@ + \\+const x = 1; + \\ + ); + try testing.expectEqualStrings("b/made.zig", targetAt(&lines, 0, false).?.path); + try testing.expectEqual(@as(usize, 1), targetAt(&lines, 2, false).?.line); + try testing.expectEqual(@as(usize, 1), targetAt(&lines, 3, false).?.line); } test "diff output is told from other text by its first rows" { @@ -330,8 +490,9 @@ test "diff hunk headers: counts left out are one, a broken one is none" { test "diff a terminal's wrapped row stays in its line" { const lines = [_][]const u8{ "--- a/x.c", "+++ b/x.c", "@@ -1,2 +1,2 @@", "+int a = 1; /* a long", "comment */", " int b;", "-int c;" }; + try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, true).?.line); var w: Walk = .{ .continued_rows = true }; - var kinds: [lines.len]Kind = undefined; - for (lines, 0..) |line, k| kinds[k] = w.step(line).kind; - try testing.expectEqualSlices(Kind, &.{ .old_path, .new_path, .hunk, .added, .added, .context, .removed }, &kinds); + var last: Kind = .other; + for (lines[0..5]) |line| last = w.step(line).kind; + try testing.expectEqual(Kind.added, last); } diff --git a/src/exec.zig b/src/exec.zig index 5e223f04..5a421f19 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -169,10 +169,54 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand result.text = edit.currentSelText(p, pane); return result; } + // In a diff, a file or hunk header, and a hunk line pointed at on its + // `+`/`-`/` ` prefix, are looked at whole: the place the line names + // (look.diffLook). A hunk line's code is words, looked at as ever: + // the code's, the prefix never one of them. + var word_from: usize = 0; + if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: { + const lines = look.diffLines(p, pane) orelse break :diff; + const at: usize = @intCast(row); + if (at >= lines.len) break :diff; + const line = lines[at]; + const target = @import("diff.zig").targetAt(lines, at, pane.file == null) orelse break :diff; + const on_prefix = col == 0 and line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null; + switch (target.kind) { + .context, .added, .removed => if (!on_prefix) { + word_from = @intFromBool(line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null); + break :diff; + }, + else => {}, + } + result.text = line; + p.diff_look = .{ .serial = pane.serial, .row = at }; + const hi = std.math.cast(i32, line.len) orelse return result; + if (hi == 0) return result; + var expanded = clicked; + if (pane.file != null) { + result.file_word = .{ .row = row, .lo = 0, .hi = hi }; + const first = pane.wrapRow(row, 0); + const last = pane.wrapRow(row, hi - 1); + if (first.row < 0 or last.row < 0) return result; + expanded.r0 = first.row + @as(i32, pane.tag_rows); + expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, 0); + expanded.r1 = last.row + @as(i32, pane.tag_rows); + expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); + } else { + const shown = if (raw) raw_line else line; + expanded.c0 = 0; + expanded.c1 = @as(i32, @intCast(panes.File.rawDisplayCol(shown, shown.len))) - 1; + } + result.preview = expanded; + result.expanded = expanded; + return result; + } if (pane.file != null and clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); - const b = look.expandedWord(line, source_col) orelse return result; + if (source_col < word_from or word_from > line.len) return result; + const word = look.expandedWord(line[word_from..], source_col - word_from) orelse return result; + const b: look.ExpandedWord = .{ .lo = word.lo + word_from, .hi = word.hi + word_from }; const lo = std.math.cast(i32, b.lo) orelse return result; const hi = std.math.cast(i32, b.hi) orelse return result; result.text = line[b.lo..b.hi]; diff --git a/src/look.zig b/src/look.zig index 357a9c63..76b58fbe 100644 --- a/src/look.zig +++ b/src/look.zig @@ -6,6 +6,7 @@ const exec = @import("exec.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; const fs = @import("fs.zig"); +const diff = @import("diff.zig"); const platform_has_fs = fs.platform_has_fs; const layout = @import("layout.zig"); const animation = @import("animation.zig"); @@ -457,7 +458,7 @@ pub fn invalidateLookHover(p: *Pardes, id: usize) void { cancelLookHover(p); } -const ExpandedWord = struct { lo: usize, hi: usize }; +pub const ExpandedWord = struct { lo: usize, hi: usize }; pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { if (col >= line.len) return null; @@ -1499,6 +1500,101 @@ fn isRecentList(pane: *const Pane) bool { return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }); } +// ---- a look on a diff line ---- + +/// The lines of a pane showing a diff, a .diff or .patch file or a +/// finished command pane whose output is one (File.DiffOutput); null for +/// any other pane. Good until the next step. +pub fn diffLines(p: *Pardes, pane: *Pane) ?[]const []const u8 { + if (pane.file) |*f| { + if (pane.colorAlgo() != .diff) return null; + return panes.File.cursorLines(p.scratch.allocator(), pane, f) catch null; + } + if (!pane.isTerminal()) return null; + return p.diff_output.lines(p, pane); +} + +const DiffLook = union(enum) { none, done, at: []const u8 }; + +/// A look in a diff pane at one of its lines opens the file and line that +/// line names (diff.targetAt): the new file at a `@@` line's first new +/// line, at a context or added line's own line, and at a removed line's +/// place, the new line now standing where it was. `---`/`+++` open their +/// file. The path is the repository's (walking up from the diff's +/// directory to a `.git` or `.jj`), `a/` and `b/` dropped, else the +/// directory's own. A file that is not there opens nothing and says so. +/// +/// Which line: the one a click just gave (`clicked`: exec.pointerOperand +/// expanded to it and kept its row), else, for a look written over 9P, a +/// line the text is the whole of, the cursor's first, then the first after +/// it. A word on a hunk line is looked at as ever. +fn diffLook(p: *Pardes, id: usize, pane: *Pane, operand: []const u8, clicked: bool, buf: []u8) DiffLook { + const text = std.mem.trimEnd(u8, operand, "\r\n"); + if (std.mem.trim(u8, text, " \t").len == 0) return .none; + const lines = diffLines(p, pane) orelse return .none; + const row: usize = row: { + if (p.diff_look) |at| if (at.serial == pane.serial and at.row < lines.len and + std.mem.eql(u8, std.mem.trimEnd(u8, lines[at.row], "\r"), text)) + { + p.diff_look = null; + break :row at.row; + }; + if (clicked) return .none; + const cur: usize = @intCast(@max(0, pane.body.cur_row)); + for (0..lines.len) |k| { + const i = (cur + k) % lines.len; + if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) break :row i; + } + return .none; + }; + const target = diff.targetAt(lines, row, pane.file == null) orelse return .none; + const base = if (pane.file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice(); + var realbuf: [4096]u8 = undefined; + const found = diffFile(base, target.path, &realbuf) orelse { + var cut: [miss_path_cap + "…".len]u8 = undefined; + if (target.old_side) + missed(p, id, "{s}: deleted by this diff", .{missText(target.path, &cut)}) + else + missed(p, id, "{s}: no such file here", .{missText(target.path, &cut)}); + return .done; + }; + const place = if (target.line > 0) + std.fmt.bufPrint(buf, "{s}:{d}", .{ found, target.line }) catch return .none + else + std.fmt.bufPrint(buf, "{s}", .{found}) catch return .none; + return .{ .at = place }; +} + +/// The file a diff names, from `base` (the diff's directory): tried in the +/// repository `base` is in, then in `base`, as git writes it (`b/x`, the +/// repository's `x`) and as written. +fn diffFile(base: []const u8, path: []const u8, out: *[4096]u8) ?[]const u8 { + if (comptime !platform_has_fs) return null; + if (path.len == 0) return null; + if (path[0] == '/') { + const found = fs.resolveOs(path, out) orelse return null; + return if (found.dir) null else found.path; + } + var root_buf: [4096]u8 = undefined; + var dir: ?[]const u8 = base; + const root: ?[]const u8 = while (dir) |d| : (dir = std.fs.path.dirname(d)) { + var probe: [4096]u8 = undefined; + const git = std.fmt.bufPrint(&probe, "{s}/.git", .{d}) catch break null; + if (fs.resolveOs(git, &root_buf) != null) break d; + const jj = std.fmt.bufPrint(&probe, "{s}/.jj", .{d}) catch break null; + if (fs.resolveOs(jj, &root_buf) != null) break d; + } else null; + for ([_]?[]const u8{ root, base }) |maybe| { + const at = maybe orelse continue; + for ([_][]const u8{ diff.stripSide(path), path }) |name| { + var joined: [4096]u8 = undefined; + const full = std.fmt.bufPrint(&joined, "{s}/{s}", .{ at, name }) catch continue; + if (fs.resolveOs(full, out)) |found| if (!found.dir) return found.path; + } + } + return null; +} + pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const pane = p.panes[id] orelse return; // `~/x`, `~/x:12`, `~/x:/re/`: home, before anything reads the text. @@ -1515,12 +1611,27 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { p.noteHaptic(.look); noteLookSource(p, id); const command = std.mem.trim(u8, operand, " \t\r\n"); - if (config.commandWord(command)) |cmd| { - if (exec.execute(p, id, cmd)) |dst| p.active = dst; - return; - } + // A click on a diff line (a context line ` Del` included) is a look at + // the place it names, never the word run. + var diff_buf: [4096 + 32]u8 = undefined; + const looked: []const u8 = looked: { + switch (diffLook(p, id, pane, operand_raw, true, &diff_buf)) { + .none => {}, + .done => return, + .at => |place| break :looked place, + } + if (config.commandWord(command)) |cmd| { + if (exec.execute(p, id, cmd)) |dst| p.active = dst; + return; + } + switch (diffLook(p, id, pane, operand_raw, false, &diff_buf)) { + .none => break :looked operand, + .done => return, + .at => |place| break :looked place, + } + }; // The operand may borrow selected terminal/PDF text released below. - const txt = p.scratch.allocator().dupe(u8, operand) catch return; + const txt = p.scratch.allocator().dupe(u8, looked) catch return; const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // acme's `file:addr` and `:addr` (look.c:450-543): an address after // the colon, evaluated in that file from its dot (openfile, look.c:675). @@ -1909,3 +2020,275 @@ test "a pointer resting where a look-hover found nothing does not wake the core try std.testing.expect(frames < 20); try std.testing.expect(p.look_hover_wait == null and p.look_hover_preview == null); } + +// A diff pane on `diff_text`, saved as change.diff in a fresh directory +// that holds `files` (path, content) and, if `repo`, a `.git`. +const DiffFixture = struct { + tmp: std.testing.TmpDir, + dir: []const u8, + dir_buf: [4096]u8 = undefined, + + fn init(f: *DiffFixture, repo: bool, files: []const [2][]const u8, diff_text: []const u8) !void { + const io = std.testing.io; + f.tmp = std.testing.tmpDir(.{}); + errdefer f.tmp.cleanup(); + if (repo) try f.tmp.dir.createDir(io, ".git", .default_dir); + for (files) |file| { + if (std.fs.path.dirname(file[0])) |sub| try f.tmp.dir.createDirPath(io, sub); + try f.tmp.dir.writeFile(io, .{ .sub_path = file[0], .data = file[1] }); + } + try f.tmp.dir.writeFile(io, .{ .sub_path = "change.diff", .data = diff_text }); + const len = try f.tmp.dir.realPathFile(io, "change.diff", &f.dir_buf); + f.dir = std.fs.path.dirname(f.dir_buf[0..len]).?; + } + + fn open(f: *DiffFixture, p: *Pardes, diff_text: []const u8) !*Pane { + const pane = try p.setTestFile(diff_text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try std.fs.path.join(p.gpa, &.{ f.dir, "change.diff" }); + return pane; + } + + /// A right click's look at `row` of the diff pane (slot 0). + fn click(p: *Pardes, diff_pane: *Pane, row: usize) void { + p.active = 0; + const lines = diffLines(p, diff_pane).?; + p.diff_look = .{ .serial = diff_pane.serial, .row = row }; + p.look_missed = false; + lookAt(p, 0, lines[row]); + } +}; + +fn expectAt(p: *Pardes, suffix: []const u8, line: usize) !void { + const pane = p.panes[p.active].?; + const f = pane.file orelse return error.NoFileOpened; + if (!std.mem.endsWith(u8, f.path, suffix)) { + std.debug.print("opened {s}, not {s}\n", .{ f.path, suffix }); + return error.WrongFile; + } + try std.testing.expectEqual(@as(i32, @intCast(line)) - 1, pane.body.cur_row); +} + +test "Look on each kind of diff line opens the new file at the line it names" { + if (!platform_has_fs) return; + const numbered = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\n" ++ + "line 10\nline 11\nline 12\nline 13\nline 14\nline 15\n"; + const diff_text = + \\diff --git a/src/a.zig b/src/a.zig + \\index 1111111..2222222 100644 + \\--- a/src/a.zig + \\+++ b/src/a.zig + \\@@ -10,3 +10,4 @@ fn x() void { + \\ line 10 + \\-Del + \\+line 11 + \\+line 12 + \\ line 13 + \\ + ; + var fx: DiffFixture = undefined; + try fx.init(true, &.{.{ "src/a.zig", numbered }}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + + DiffFixture.click(p, pane, 4); // @@: the hunk's first new line + try expectAt(p, "/src/a.zig", 10); + DiffFixture.click(p, pane, 5); // a context line + try expectAt(p, "/src/a.zig", 10); + // A removed line (whose text is a builtin: no Del runs) opens the new + // line now standing where it was. + DiffFixture.click(p, pane, 6); + try expectAt(p, "/src/a.zig", 11); + try std.testing.expect(p.panes[0] != null); + DiffFixture.click(p, pane, 7); // added lines + try expectAt(p, "/src/a.zig", 11); + DiffFixture.click(p, pane, 8); + try expectAt(p, "/src/a.zig", 12); + DiffFixture.click(p, pane, 9); + try expectAt(p, "/src/a.zig", 13); + DiffFixture.click(p, pane, 3); // +++ b/path: the file, a/ b/ dropped + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig")); + DiffFixture.click(p, pane, 2); // --- a/path + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/src/a.zig")); + try std.testing.expect(!p.look_missed); + + // No click: a 9P look writes a line, found after the cursor, or the + // text is on the cursor's line. + p.active = 0; + pane.body.cur_row = 0; + p.diff_look = null; + lookAt(p, 0, "+line 12"); + try expectAt(p, "/src/a.zig", 12); + // A word on a hunk line is a word, looked for in the diff as ever. + p.active = 0; + pane.body.cur_row = 9; + lookAt(p, 0, "line"); + try std.testing.expectEqual(@as(usize, 0), p.active); + // A line that is no part of a hunk is looked at as ever. + p.active = 0; + pane.body.cur_row = 1; + lookAt(p, 0, "1111111"); + try std.testing.expectEqual(@as(usize, 0), p.active); +} + +test "Look on a diff line of a file that is not here says so and opens nothing" { + if (!platform_has_fs) return; + const diff_text = + \\diff --git a/src/elsewhere.zig b/src/elsewhere.zig + \\--- a/src/elsewhere.zig + \\+++ b/src/elsewhere.zig + \\@@ -1 +1 @@ + \\-const a = 1; + \\+const a = 2; + \\diff --git a/old.zig b/old.zig + \\deleted file mode 100644 + \\--- a/old.zig + \\+++ /dev/null + \\@@ -1 +0,0 @@ + \\-const gone = 1; + \\ + ; + var fx: DiffFixture = undefined; + try fx.init(true, &.{}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + var before: usize = 0; + for (p.panes) |slot| before += @intFromBool(slot != null); + DiffFixture.click(p, pane, 5); + try std.testing.expect(p.look_missed); + try std.testing.expectEqual(@as(usize, 0), p.active); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try std.testing.expectEqual(before, after); + try std.testing.expectEqualStrings("Look: b/src/elsewhere.zig: no such file here", pane.msg[0..pane.msg_len]); + DiffFixture.click(p, pane, 11); + try std.testing.expect(p.look_missed); + try std.testing.expectEqualStrings("Look: a/old.zig: deleted by this diff", pane.msg[0..pane.msg_len]); +} + +test "Look on plain diff -u output: names as written, timestamps cut, next to the diff" { + if (!platform_has_fs) return; + const diff_text = "--- old/x.zig\t2026-09-30 10:00:00.000000000 +0000\n" ++ + "+++ new/x.zig\t2026-09-30 11:00:00.000000000 +0000\n" ++ + "@@ -1,2 +1,3 @@\n" ++ + " const a = 1;\n" ++ + "+const b = 2;\n" ++ + " const c = 3;\n"; + var fx: DiffFixture = undefined; + try fx.init(false, &.{.{ "new/x.zig", "const a = 1;\nconst b = 2;\nconst c = 3;\n" }}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + DiffFixture.click(p, pane, 4); + try expectAt(p, "/new/x.zig", 2); + DiffFixture.click(p, pane, 5); + try expectAt(p, "/new/x.zig", 3); +} + +test "Look on a diff: headers and a hunk line's prefix are the whole line, hover and click alike" { + if (!platform_has_fs) return; + const numbered = "one\ntwo\nthree\nfour\nfive\nsix\n"; + const diff_text = + \\diff --git a/src/old.zig b/src/new.zig + \\similarity index 80% + \\rename from src/old.zig + \\rename to src/new.zig + \\--- a/src/old.zig + \\+++ b/src/new.zig + \\@@ -2,2 +2,2 @@ fn x() void { + \\ two + \\-drei + \\+three + \\ + ; + var fx: DiffFixture = undefined; + try fx.init(true, &.{.{ "src/new.zig", numbered }}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + pane.tag_expanded = false; + // `word`: what the pointer there expands to when it is not the whole + // line; `line` 0: that word is looked at in the diff, nothing opened. + const Point = struct { row: u16, col: u16, line: usize, word: ?[]const u8 = null }; + const points = [_]Point{ + .{ .row = 0, .col = 0, .line = 1 }, // `diff`: the new side + .{ .row = 0, .col = 35, .line = 1 }, // on b/src/new.zig + .{ .row = 4, .col = 1, .line = 1 }, // `---`: renamed, so the new name + .{ .row = 4, .col = 5, .line = 1 }, // the `a/` of the old name + .{ .row = 5, .col = 0, .line = 1 }, // `+++` + .{ .row = 5, .col = 4, .line = 1 }, // its `b/` + .{ .row = 5, .col = 10, .line = 1 }, // its path + .{ .row = 6, .col = 0, .line = 2 }, // the first `@@` + .{ .row = 6, .col = 9, .line = 2 }, // `+2,2` + .{ .row = 6, .col = 13, .line = 2 }, // the second `@@` + .{ .row = 6, .col = 20, .line = 2 }, // the function after it + .{ .row = 7, .col = 0, .line = 2 }, // a context line's ` ` + .{ .row = 8, .col = 0, .line = 3 }, // a removed line's `-`: where it was + .{ .row = 9, .col = 0, .line = 3 }, // an added line's `+` + .{ .row = 9, .col = 3, .line = 0, .word = "three" }, // its code: a word + .{ .row = 7, .col = 2, .line = 0, .word = "two" }, + }; + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for (points) |at| { + // Back to the diff, scrolled to its top again. + p.active = 0; + pane.file.?.scroll = 0; + p.sync(); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + at.col; + const y = p.bodyTop(pane, rect) + at.row; + // The hover's span is the click's operand. + const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed; + const operand = exec.pointerOperand(p, pane, pointed.sel); + const lines = diffLines(p, pane).?; // scratch: good until the next update + const span = operand.file_word orelse return error.NoHoverSpan; + try std.testing.expectEqual(@as(i32, at.row), span.row); + if (at.word) |word| { + try std.testing.expectEqualStrings(word, operand.text.?); + try std.testing.expectEqualStrings(word, lines[at.row][@intCast(span.lo)..@intCast(span.hi)]); + } else { + try std.testing.expectEqualStrings(lines[at.row], operand.text.?); + try std.testing.expectEqual(@as(i32, 0), span.lo); + try std.testing.expectEqual(@as(i32, @intCast(lines[at.row].len)), span.hi); + } + p.look_missed = false; + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x, .row = y } }); + if (at.line == 0) { + try std.testing.expectEqual(@as(usize, 0), p.active); + continue; + } + try std.testing.expect(!p.look_missed); + if (p.active == 0) { + std.debug.print("a look at row {d} col {d} stayed in the diff\n", .{ at.row, at.col }); + return error.LookStayed; + } + try expectAt(p, "/src/new.zig", at.line); + } +} + +test "Look outside a diff pane expands and looks as ever" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("+++ b/x.zig\n@@ -1 +1 @@\n+const a = 1;\n"); + pane.tag_expanded = false; + try std.testing.expect(diffLines(p, pane) == null); + p.sync(); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + 0; + const pointed = edit.pointerTextSelection(p, 0, x, p.bodyTop(pane, rect) + 2, null, null).?; + const operand = exec.pointerOperand(p, pane, pointed.sel); + try std.testing.expect(operand.text == null or !std.mem.eql(u8, operand.text.?, "+const a = 1;")); +} diff --git a/src/pardes.zig b/src/pardes.zig index 9fe6a1fb..04854a37 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4361,6 +4361,10 @@ pub const Pardes = struct { chord_hold: bool = false, /// The look just run found nothing (look.zig missed). look_missed: bool = false, + /// The diff line a right click just expanded to, by row: a look at its + /// text goes to the place that row names, not the first equal line + /// (look.zig diffLook). + diff_look: ?struct { serial: u32, row: usize } = null, look_hover_wait: ?look.LookHoverWait = null, /// The place a look-hover last found nothing to open: the pointer /// resting there does not ask again every frame (look.noteLookHover). diff --git a/test/snapshots/diffview.golden b/test/snapshots/diffview.golden index 2da9f6e3..9e171fd8 100644 --- a/test/snapshots/diffview.golden +++ b/test/snapshots/diffview.golden @@ -54,6 +54,21 @@ |28: 0-0 d,#608269, 1-99 d,#0d1410, |29: 0-0 d,#608269, 1-99 d,#0d1410, |30: 0-0 d,#608269, 1-99 d,#0d1410, +== snap look-added-line grid=100x31 cursor=55,22 +|16: /tmp/pardes-snap/diffview/cwd/src/shape.zig Save Tty Collapse Del TreeContext +|17: 1 const std = @import("std"); +|18: 2 +|19: 3 pub const Shape = union(enum) { +|20: 4 circle: f32, +|21: 5 square: f32, +|22: 6 triangle: struct { base: f32, height: f32 }, +|23: 7 +|24: 8 const note = +|25: 9 \\areas, defer +|26: 10 \\summed +|27: 11 ; +|28: 12 }; +|29: 13 == snap command-pane grid=100x31 cursor=7,3 |Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit | New Tty Find Grep Joincol Delcol @@ -117,3 +132,17 @@ |28: 0-0 d,#608269, 1-2 d,#0d1410, 3-99 #e5eee5,#0d1410, |29: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410, |30: 0-0 d,#152019, 1-99 d,#0d1410, +== snap command-look grid=100x31 cursor=23,10 +|3: 1 cat change.diff +|5: /tmp/pardes-snap/diffview/cwd/src/shape.zig Save Tty Collapse Del TreeContext +|6: 1 const std = @import("std"); +|7: 2 +|8: 3 pub const Shape = union(enum) { +|9: 4 circle: f32, +|10: 5 square: f32, +|11: 6 triangle: struct { base: f32, height: f32 }, +|12: 7 +|13: 8 const note = +|14: 9 \\areas, defer +|15: 10 \\summed +|16: 11 ; diff --git a/test/snapshots/diffview.snap b/test/snapshots/diffview.snap index b5900db9..23d060b7 100644 --- a/test/snapshots/diffview.snap +++ b/test/snapshots/diffview.snap @@ -1,7 +1,9 @@ # A diff pane, for reviewing a change: each hunk's code coloured as Zig (a # multi-line string over three lines, `defer` inside it no keyword), added and # removed rows tinted to the pane's edge with their prefix in the tint's hue, -# headers muted. +# headers muted. Then a right click on an added line's `+` opens the new file +# at that line: the path is the repository's (a `.git` above the diff), b/ +# dropped. dirmk .git dirmk src file src/shape.zig const std = @import("std");\n\npub const Shape = union(enum) {\n circle: f32,\n square: f32,\n triangle: struct { base: f32, height: f32 },\n\n const note =\n \\\\areas, defer\n \\\\summed\n ;\n};\n @@ -11,9 +13,13 @@ wait 8000 Newcol Joincol stable 700 20000 snap diff-pane snapstyle diff-colours +press right 8 15 +release right 8 15 +stable 700 15000 +snap look-added-line # The same diff printed by a command (`git diff`, here `cat`) into a command # pane: its output is told for a diff and coloured the same, over the colours -# the program printed. +# the program printed, and a right click on a `+` there opens the file too. file cmds.txt cat change.diff\n start 31 100 cmds.txt wait 8000 cmds.txt @@ -25,3 +31,7 @@ wait 10000 exit 0 stable 700 15000 snap command-pane snapstyle command-colours +press right 3 21 +release right 3 21 +stable 700 15000 +snap command-look -- cgit v1.3