summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md23
-rw-r--r--src/File.zig232
-rw-r--r--src/body_layer.zig1
-rw-r--r--src/detached/wire.zig19
-rw-r--r--src/diff.zig498
-rw-r--r--src/edit.zig39
-rw-r--r--src/exec.zig47
-rw-r--r--src/gui/gui.zig240
-rw-r--r--src/look.zig395
-rw-r--r--src/mouse.zig242
-rw-r--r--src/pardes.zig41
-rw-r--r--src/syntax.zig499
-rw-r--r--src/terminal.zig173
-rw-r--r--src/tty/tty.zig54
14 files changed, 2358 insertions, 145 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 25740d16..72607fd2 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## 0.0.9
+
+- A terminal's program that tracks the mouse gets its clicks, drags and
+ motion as well as its wheel, in the format it asked for; hold Shift for
+ pardes's own select, execute, look and scrollback. Mouse reports carry
+ Shift, Alt and Ctrl.
+- A program that asked for focus reports hears when its pane or the window
+ gains or loses focus. Every paste into a terminal is bracketed when the
+ program asked, with control bytes turned to spaces. The GUI's F1-F24,
+ Insert, Print Screen, Scroll Lock, Pause and Menu reach terminals.
+- A diff reads as the code it changes: a `.diff` or `.patch` file, and a
+ command's output that is a diff (`git diff`), colour each hunk in its
+ file's language, its old and new side each parsed whole, and tint added
+ 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.
+- A write through a mount that fills a writer's buffer (a multiple of 4096
+ bytes) keeps its cut last line for the next write, so `seq … > exec`
+ runs every line once.
+
## 0.0.8
- `~` 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/detached/wire.zig b/src/detached/wire.zig
index 051e9317..c9d9b578 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -12,7 +12,7 @@ const ShaderAnimation = pardes.config.Runtime.ShaderAnimation;
/// to be refused, which is what this number is for.
/// 8: one layer type: a tag band carries all its rows (not a layer per
/// line), and a frame carries the core's placed regions after its layers.
-pub const version: u16 = 8;
+pub const version: u16 = 10;
/// The Chrome palette is carried field by field as its declaration lists
/// them, so any change to it changes the wire: bump `version`, then this.
@@ -58,6 +58,7 @@ pub const ClientTag = enum(u8) {
pinch = 0x1b,
touch_scroll = 0x1c,
pointer_leave = 0x1d,
+ focus = 0x1e,
};
// Session control precedes display-local effects.
@@ -1052,6 +1053,8 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
try w.putU16(m.col);
try w.putU16(m.row);
try w.putBool(m.ctrl);
+ try w.putBool(m.shift);
+ try w.putBool(m.alt);
try putBodyHit(&w, m.body_hit);
try putTagHit(&w, m.tag_hit);
},
@@ -1074,6 +1077,7 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 {
.pinch => |v| try w.putF32(v),
.touch_scroll => |v| try w.putF32(v),
.pointer_leave => {},
+ .focus => |focused| try w.putBool(focused),
// See `clientTag`: no tag, so nothing to encode.
.output, .eof, .exited, .lsp_resp, .pipe_resp, .file_changed, .tick => unreachable,
},
@@ -1115,6 +1119,8 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
.col = try r.getU16(),
.row = try r.getU16(),
.ctrl = try r.getBool(),
+ .shift = try r.getBool(),
+ .alt = try r.getBool(),
.body_hit = try getBodyHit(&r),
.tag_hit = try getTagHit(&r),
} } },
@@ -1134,6 +1140,7 @@ pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg {
.pinch => .{ .event = .{ .pinch = try r.getF32() } },
.touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } },
.pointer_leave => .{ .event = .pointer_leave },
+ .focus => .{ .event = .{ .focus = try r.getBool() } },
};
try r.end();
return msg;
@@ -1218,7 +1225,7 @@ pub fn clientBound(msg: ClientMsg) usize {
return msg_slack + switch (msg) {
.hello, .bye => 0,
.event => |ev| switch (ev) {
- .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0,
+ .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .focus => 0,
.key => |k| k.text.len,
.paste => |b| b.len,
.command => |line| line.len,
@@ -1357,13 +1364,15 @@ test "detached wire: every Event variant round-trips" {
// the only place a value can be mistranslated and still decode.
for (std.enums.values(pardes.Mouse.Button)) |button| {
for (std.enums.values(pardes.Mouse.Kind)) |kind| {
- const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true };
+ const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true, .shift = true, .alt = true };
const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } })).event.mouse;
try testing.expectEqual(m.button, got.button);
try testing.expectEqual(m.kind, got.kind);
try testing.expectEqual(m.col, got.col);
try testing.expectEqual(m.row, got.row);
try testing.expectEqual(m.ctrl, got.ctrl);
+ try testing.expectEqual(m.shift, got.shift);
+ try testing.expectEqual(m.alt, got.alt);
}
}
}
@@ -1449,7 +1458,7 @@ test "detached wire: a session is never told to do a frontend's remembering" {
// Adding a name here is meant to be an ARGUMENT, not a formality, and the
// bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a
// window resized, a paste, a command line executed, a pointer leaving the
- // window — plus the two session words that say who is speaking. A pty's
+ // window, the window's focus — plus the two session words that say who is speaking. A pty's
// output, a worker's answer, a watched file's new bytes and an animation
// tick all fail that bar the same way: nobody did them, a machine reported
// them, and the machine that reports them is the one already holding the
@@ -1462,7 +1471,7 @@ test "detached wire: a session is never told to do a frontend's remembering" {
"resize", "paste",
"command", "pdf_scroll",
"pinch", "touch_scroll",
- "pointer_leave",
+ "pointer_leave", "focus",
};
// One: the tag set is exactly that, named rather than counted, so
diff --git a/src/diff.zig b/src/diff.zig
new file mode 100644
index 00000000..d129c222
--- /dev/null
+++ b/src/diff.zig
@@ -0,0 +1,498 @@
+//! 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) 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
+//! 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>`
+ old_path,
+ /// `+++ <new 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;
+}
+
+/// 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;
+
+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 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" ++
+ "@@ -3 +3,2 @@\n" ++
+ "-const a = 1;\n" ++
+ "+const a = 2;\n" ++
+ "+const b = 3;\n");
+ try testing.expect(looksLikeDiff(&lines));
+ 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" {
+ 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(&nothing));
+}
+
+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;" };
+ try testing.expectEqual(@as(usize, 2), targetAt(&lines, 5, true).?.line);
+ var w: Walk = .{ .continued_rows = true };
+ var last: Kind = .other;
+ for (lines[0..5]) |line| last = w.step(line).kind;
+ try testing.expectEqual(Kind.added, last);
+}
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/exec.zig b/src/exec.zig
index d114dbf8..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];
@@ -738,6 +782,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/gui/gui.zig b/src/gui/gui.zig
index 045f22d9..1e58d3ba 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -3097,6 +3097,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
const StdinFeed = struct {
parser: vaxis.Parser = .{},
+ /// Inside the driver's bracketed paste: its keys are the paste's text.
+ in_paste: bool = false,
+ paste: std.ArrayList(u8) = .empty,
cache: vaxis.GraphemeCache = .{},
buf: [1024]u8 = undefined,
fill: usize = 0,
@@ -3139,8 +3142,8 @@ const StdinFeed = struct {
var seq_start: usize = 0;
while (seq_start < len) {
- // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL
- // ESC]777;mouse;wheel;<amount>;<x>;<y>BEL
+ // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>[;<mods>]BEL
+ // ESC]777;mouse;wheel;<amount>;<x>;<y>[;<mods>]BEL
// A mouse event at window pixels, built as SDL would and run
// through dispatch, so a driver exercises the real pointer path
// (pixel to cell, tag and body hits). SGR mouse carries only cells.
@@ -3159,6 +3162,9 @@ const StdinFeed = struct {
const button = std.fmt.parseInt(u8, second, 10) catch 0;
const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
+ // An optional fifth field holds modifiers as xterm
+ // counts them: shift 4, meta (alt) 8, ctrl 16.
+ const held = std.fmt.parseInt(u8, parts.next() orelse "0", 10) catch 0;
var sev = std.mem.zeroes(c.SDL_Event);
if (std.mem.eql(u8, kind, "wheel")) {
// ESC]777;mouse;wheel;<y, SDL's sign: up positive>;<x>;<y>BEL:
@@ -3169,6 +3175,9 @@ const StdinFeed = struct {
sev.wheel.integer_y = @intFromFloat(@trunc(amount));
sev.wheel.mouse_x = x;
sev.wheel.mouse_y = y;
+ } else if (std.mem.eql(u8, kind, "focus")) {
+ // ESC]777;mouse;focus;<1 gained, 0 lost>;0;0BEL
+ sev.type = if (button != 0) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_EVENT_WINDOW_FOCUS_LOST;
} else if (std.mem.eql(u8, kind, "motion")) {
sev.type = c.SDL_EVENT_MOUSE_MOTION;
sev.motion.x = x;
@@ -3181,7 +3190,14 @@ const StdinFeed = struct {
sev.button.x = x;
sev.button.y = y;
}
+ const before = c.SDL_GetModState();
+ var mods: c.SDL_Keymod = 0;
+ if (held & 4 != 0) mods |= c.SDL_KMOD_LSHIFT;
+ if (held & 8 != 0) mods |= c.SDL_KMOD_LALT;
+ if (held & 16 != 0) mods |= c.SDL_KMOD_LCTRL;
+ c.SDL_SetModState(mods);
dispatch(gp, in, &sev);
+ c.SDL_SetModState(before);
out.n_events += 1;
}
seq_start += mouse_prefix.len + end + 1;
@@ -3228,10 +3244,24 @@ const StdinFeed = struct {
seq_start += result.n;
const event = result.event orelse continue;
switch (event) {
- .key_press => |key| {
+ .key_press => |key| if (f.in_paste) {
+ // A newline reads as Ctrl-J, as the tty's paste has it.
+ const text = key.text orelse if (key.codepoint == vaxis.Key.enter or (key.mods.ctrl and key.codepoint == 'j')) "\n" else if (key.codepoint == vaxis.Key.tab) "\t" else "";
+ f.paste.appendSlice(gpa, text) catch {};
+ } else {
postKey(in, key, if (key.text) |t| f.cache.put(t) else "");
out.n_events += 1;
},
+ .paste_start => {
+ f.in_paste = true;
+ f.paste.clearRetainingCapacity();
+ },
+ .paste_end => {
+ f.in_paste = false;
+ if (f.paste.items.len > 0) in.post(.{ .paste = f.paste.items });
+ f.paste.clearAndFree(gpa);
+ out.n_events += 1;
+ },
.mouse => |m| {
const button: ?pardes.Mouse.Button = switch (m.button) {
.left => .left,
@@ -3256,6 +3286,8 @@ const StdinFeed = struct {
.col = @intCast(@max(m.col, 0)),
.row = @intCast(@max(m.row, 0)),
.ctrl = m.mods.ctrl,
+ .shift = m.mods.shift,
+ .alt = m.mods.alt,
} });
out.n_events += 1;
}
@@ -3412,6 +3444,47 @@ test "the GUI's stdin keys reach a terminal pane as the tty's do: F5, Insert and
try std.testing.expectEqualStrings("\x1b[15~\x1b[2~\x1b[A", written.written());
}
+test "the window's function and editing keys reach a terminal as its cursor keys' mode and keyboard flags say" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ core.panes[0].?.body.mode = .tty;
+ while (core.nextEffect()) |_| {}
+ var in: Input = .{ .core = core };
+ var written: std.Io.Writer.Allocating = .init(gpa);
+ defer written.deinit();
+ const Case = struct { modes: []const u8, sym: c.SDL_Keycode, mod: c.SDL_Keymod = 0, sent: []const u8 };
+ const cases = [_]Case{
+ .{ .modes = "", .sym = c.SDLK_UP, .sent = "\x1b[A" },
+ .{ .modes = "", .sym = c.SDLK_HOME, .sent = "\x1b[H" },
+ .{ .modes = "", .sym = c.SDLK_F1, .sent = "\x1bOP" },
+ .{ .modes = "", .sym = c.SDLK_F5, .sent = "\x1b[15~" },
+ .{ .modes = "", .sym = c.SDLK_F12, .mod = c.SDL_KMOD_LSHIFT, .sent = "\x1b[24;2~" },
+ .{ .modes = "", .sym = c.SDLK_INSERT, .sent = "\x1b[2~" },
+ // DECCKM: less, vim, htop.
+ .{ .modes = "\x1b[?1h", .sym = c.SDLK_UP, .sent = "\x1bOA" },
+ .{ .modes = "", .sym = c.SDLK_END, .sent = "\x1bOF" },
+ .{ .modes = "", .sym = c.SDLK_LEFT, .mod = c.SDL_KMOD_LCTRL, .sent = "\x1b[1;5D" },
+ // codex's kitty flags (>7u): Ctrl-C and Alt-a are unambiguous.
+ // (A bare Escape at a shell prompt is pardes's way out of raw input.)
+ .{ .modes = "\x1b[?1l\x1b[>7u", .sym = 'c', .mod = c.SDL_KMOD_LCTRL, .sent = "\x1b[99;5u" },
+ .{ .modes = "", .sym = 'a', .mod = c.SDL_KMOD_LALT, .sent = "\x1b[97;3u" },
+ // With event types asked for (2), an arrow says it is a press, as
+ // ghostty says it.
+ .{ .modes = "", .sym = c.SDLK_UP, .sent = "\x1b[1;1:1A" },
+ };
+ for (cases) |case| {
+ core.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ while (core.nextEffect()) |_| {}
+ keyDown(&in, case.sym, case.mod);
+ written.clearRetainingCapacity();
+ while (core.nextEffect()) |effect| {
+ if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice());
+ }
+ try std.testing.expectEqualStrings(case.sent, written.written());
+ }
+}
+
const Input = struct {
core: ?*pardes.Pardes = null,
client: ?*detached_client.Client = null,
@@ -3457,6 +3530,8 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
}
switch (sev.type) {
c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
+ c.SDL_EVENT_WINDOW_FOCUS_GAINED => in.post(.{ .focus = true }),
+ c.SDL_EVENT_WINDOW_FOCUS_LOST => in.post(.{ .focus = false }),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
g.pointer_present = false;
g.pointer_mapped = false;
@@ -3526,18 +3601,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- in.post(.{
- .mouse = .{
- .button = button,
- .kind = if (b.down) .press else .release,
- .col = mc.col,
- .row = mc.row,
- .tag_hit = mc.tag_hit,
- .body_hit = mc.body_hit,
- .rule_above = mc.rule_above,
- .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
- },
- });
+ in.post(.{ .mouse = pointerMouse(button, if (b.down) .press else .release, mc, pointerMods()) });
},
c.SDL_EVENT_MOUSE_MOTION => {
const m = sev.motion;
@@ -3559,14 +3623,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- in.post(.{ .mouse = .{
- .button = held orelse .none,
- .kind = if (held != null) .drag else .motion,
- .col = mc.col,
- .row = mc.row,
- .tag_hit = mc.tag_hit,
- .body_hit = mc.body_hit,
- } });
+ in.post(.{ .mouse = pointerMouse(held orelse .none, if (held != null) .drag else .motion, mc, pointerMods()) });
},
c.SDL_EVENT_MOUSE_WHEEL => {
const w = sev.wheel;
@@ -3581,7 +3638,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y }, mc);
+ wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y, .mods = pointerMods() }, mc);
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
const f = sev.tfinger;
@@ -3767,6 +3824,13 @@ fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
c.SDLK_PAGEUP => pardes.Key.page_up,
c.SDLK_PAGEDOWN => pardes.Key.page_down,
c.SDLK_DELETE => pardes.Key.delete,
+ c.SDLK_INSERT => pardes.Key.insert,
+ c.SDLK_PRINTSCREEN => pardes.Key.print_screen,
+ c.SDLK_SCROLLLOCK => pardes.Key.scroll_lock,
+ c.SDLK_PAUSE => pardes.Key.pause,
+ c.SDLK_APPLICATION => pardes.Key.menu,
+ c.SDLK_F1...c.SDLK_F12 => pardes.Key.f1 + @as(u21, @intCast(sym - c.SDLK_F1)),
+ c.SDLK_F13...c.SDLK_F24 => pardes.Key.f1 + 12 + @as(u21, @intCast(sym - c.SDLK_F13)),
else => blk: {
if (!(ctrl or alt or gui_mod)) break :blk 0;
if (sym < 128 and sym >= ' ') break :blk @intCast(sym);
@@ -3913,16 +3977,16 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
.pane = @intCast(id),
.delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
} });
- } else if (pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) {
+ } else if (!w.mods.shift and pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) {
// The program's (codex, htop, less): every whole
// turn goes to it, no glide, for nothing of ours
- // moves.
+ // moves. Shift keeps the wheel pardes's.
resetScroll(g);
g.program_wheel = accumulateWheelDelta(g.program_wheel, w.y);
while (g.program_wheel >= 1) : (g.program_wheel -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
while (g.program_wheel <= -1) : (g.program_wheel += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
} else {
g.scroll_pane = id;
g.scroll_col = mc.col;
@@ -3938,9 +4002,9 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
} else {
g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, .ctrl = w.mods.ctrl, .shift = w.mods.shift, .alt = w.mods.alt } });
}
}
if (w.x != 0) in.post(.{ .mouse = .{
@@ -3950,10 +4014,37 @@ fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
.row = mc.row,
.tag_hit = mc.tag_hit,
.body_hit = mc.body_hit,
+ .ctrl = w.mods.ctrl,
+ .shift = w.mods.shift,
+ .alt = w.mods.alt,
} });
}
-const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0 };
+const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0, mods: PointerMods = .{} };
+
+/// The modifiers held with a pointer event, as the core's Mouse carries them.
+const PointerMods = struct { ctrl: bool = false, shift: bool = false, alt: bool = false };
+
+/// The core's Mouse for an SDL press, release or move over cell `mc`.
+fn pointerMouse(button: pardes.Mouse.Button, kind: pardes.Mouse.Kind, mc: MouseCell, mods: PointerMods) pardes.Mouse {
+ return .{
+ .button = button,
+ .kind = kind,
+ .col = mc.col,
+ .row = mc.row,
+ .tag_hit = mc.tag_hit,
+ .body_hit = mc.body_hit,
+ .rule_above = (kind == .press or kind == .release) and mc.rule_above,
+ .ctrl = mods.ctrl,
+ .shift = mods.shift,
+ .alt = mods.alt,
+ };
+}
+
+fn pointerMods() PointerMods {
+ const m = c.SDL_GetModState();
+ return .{ .ctrl = (m & c.SDL_KMOD_CTRL) != 0, .shift = (m & c.SDL_KMOD_SHIFT) != 0, .alt = (m & c.SDL_KMOD_ALT) != 0 };
+}
fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
@@ -4794,6 +4885,39 @@ test "the headless grid host round-trips a yank back as a paste" {
try std.testing.expectEqual(before + yanked.len, pane.file.?.content.len);
}
+test "the clipboard pasted into a terminal reaches its pty as xterm pastes: bracketed when asked, no ESC[201~ smuggled through" {
+ if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer core.deinit();
+ var shell: Shell = undefined;
+ shell.core = core;
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ const take = struct {
+ fn f(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (core_.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice());
+ }
+ return out.items;
+ }
+ }.f;
+ _ = try take(core, &sent);
+ // The clipboard is SDL's own with no display to hold one.
+ _ = c.SDL_SetHint(c.SDL_HINT_VIDEO_DRIVER, "dummy");
+ defer _ = c.SDL_ResetHint(c.SDL_HINT_VIDEO_DRIVER);
+ if (!c.SDL_InitSubSystem(c.SDL_INIT_VIDEO)) return error.SkipZigTest;
+ defer c.SDL_QuitSubSystem(c.SDL_INIT_VIDEO);
+ putClipboard(gpa, "ls\x1b[201~; rm -rf ~\nx");
+ readClipboard(&shell);
+ try std.testing.expectEqualStrings("ls [201~; rm -rf ~\rx", try take(core, &sent));
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } });
+ _ = try take(core, &sent);
+ readClipboard(&shell);
+ try std.testing.expectEqualStrings("\x1b[200~ls [201~; rm -rf ~\nx\x1b[201~", try take(core, &sent));
+}
+
fn openLink(ctx: ?*anyopaque, url: []const u8) void {
_ = ctx;
look.openLink(url); // desktop browser
@@ -4918,6 +5042,46 @@ test "a wheel's notch is whole lines, a touchpad's fraction is not" {
try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1));
}
+test "a click over codex in a terminal reaches its pty with its modifiers; with Shift it is pardes's" {
+ if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer core.deinit();
+ var frame: std.heap.ArenaAllocator = .init(gpa);
+ defer frame.deinit();
+ _ = try core.render(frame.allocator());
+ var in: Input = .{ .core = core };
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ const take = struct {
+ fn f(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (core_.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice());
+ }
+ return out.items;
+ }
+ }.f;
+ _ = try take(core, &sent);
+ const pane = core.panes[0].?;
+ const r = core.rects[0];
+ const mc: MouseCell = .{ .col = r.x + config.GUTTER + 5, .row = core.bodyTop(pane, r) + 3 };
+ const next: MouseCell = .{ .col = mc.col + 1, .row = mc.row };
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" } });
+ _ = try take(core, &sent);
+ in.post(.{ .mouse = pointerMouse(.none, .motion, mc, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<35;6;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .press, mc, .{ .ctrl = true }) });
+ try std.testing.expectEqualStrings("\x1b[<16;6;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .drag, next, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<32;7;4M", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.left, .release, next, .{}) });
+ try std.testing.expectEqualStrings("\x1b[<0;7;4m", try take(core, &sent));
+ in.post(.{ .mouse = pointerMouse(.middle, .press, mc, .{ .shift = true }) });
+ in.post(.{ .mouse = pointerMouse(.middle, .release, mc, .{ .shift = true }) });
+ try std.testing.expectEqualStrings("", try take(core, &sent));
+}
+
test "a wheel over codex in a terminal reaches its pty as the mouse reports it asked for" {
if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -4934,6 +5098,7 @@ test "a wheel over codex in a terminal reaches its pty as the mouse reports it a
g.scroll_notch = 0;
g.program_wheel = 0;
g.glide = .{};
+ defer g.glide.deinit(gpa);
g.cell_h = 20;
var in: Input = .{ .core = core };
var sent: std.ArrayList(u8) = .empty;
@@ -4967,6 +5132,15 @@ test "a wheel over codex in a terminal reaches its pty as the mouse reports it a
try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
wheelAt(&g, &in, .{ .y = 0.5 }, mc);
try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent));
+ // Alt is meta, 8; Shift keeps the turn pardes's own, a glide.
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1, .mods = .{ .alt = true } }, mc);
+ try std.testing.expectEqualStrings("\x1b[<72;6;4M", try Sent.take(core, &sent));
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1, .mods = .{ .shift = true } }, mc);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ try std.testing.expectEqual(@as(i32, -1), g.scroll_notch);
+ stepGlide(&g, core, gpa);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ resetScroll(&g);
// Its sign-in screen: mouse off, alternate scroll on, so arrow keys.
core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h" } });
_ = try Sent.take(core, &sent);
@@ -5171,7 +5345,8 @@ fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const down = left > 0;
left += if (down) -1 else 1;
const was = pane.scroll();
- core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } });
+ // Shift: pardes's own scroll, never a program's wheel.
+ core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row, .shift = true } });
if (pane.scroll() == was) break;
moved = true;
}
@@ -5344,6 +5519,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
.kind = .press,
.col = g.scroll_col,
.row = g.scroll_row,
+ .shift = true, // pardes's own scroll, never a program's wheel
} });
if (pane.scroll() == was) {
resetScroll(g);
diff --git a/src/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/mouse.zig b/src/mouse.zig
index a0ec6e47..ecb9bb57 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -313,20 +313,126 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
/// A wheel over a terminal whose program owns it (`terminal.ownsWheel`:
/// codex, htop, less) goes to the program as the program asked, one turn
/// per line, instead of scrolling a scrollback the alternate screen does
-/// not have. False when pardes scrolls.
-fn programWheel(p: *Pardes, id: usize, wheel: panes.terminal.Wheel, col: u16, row: u16, ctrl: bool) bool {
+/// not have. Shift keeps it pardes's, as xterm's Shift does. False when
+/// pardes scrolls.
+fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool {
+ if (m.shift) return false;
const pane = p.panes[id] orelse return false;
if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false;
const r = p.rects[id];
const body_y = p.bodyTop(pane, r);
const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1);
const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1);
- var buf: [32]u8 = undefined;
- const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, ctrl) orelse return true;
+ var buf: [64]u8 = undefined;
+ const wheel: panes.terminal.MouseButton = switch (m.button) {
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .wheel_left => .wheel_left,
+ else => .wheel_right,
+ };
+ const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, .{ .alt = m.alt, .ctrl = m.ctrl }) orelse return true;
for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes);
return true;
}
+pub const Grab = struct { id: usize, serial: u32, held: u3 = 0 };
+
+/// The 0-based cell of a terminal program's grid under screen cell
+/// `col`,`row`, or null over chrome (tag, gutter, scrollbar, anything past
+/// the grid).
+fn programCell(p: *const Pardes, id: usize, col: u16, row: u16) ?struct { col: i32, row: i32 } {
+ const pane = p.panes[id] orelse return null;
+ const r = p.rects[id];
+ const x0 = r.x + config.GUTTER;
+ const y0 = p.bodyTop(pane, r);
+ const h = @min(r.h -| pane.tag_rows, pane.rows);
+ if (col < x0 or col >= r.x + r.w or col - x0 >= pane.cols or row < y0 or row - y0 >= h) return null;
+ return .{ .col = col - x0, .row = row - y0 };
+}
+
+/// A pane whose program tracks the mouse (1000, 1002, 1003, or X10's 9)
+/// and has the keyboard's raw input: its clicks are the program's.
+fn tracksMouse(pane: *const Pane) bool {
+ return pane.isTerminal() and pane.body.mode == .tty and panes.terminal.reportsMouse(pane);
+}
+
+/// A program that tracks the mouse gets it over its grid, as xterm gives
+/// it: presses, releases, drags with a button down (1002) and, under 1003,
+/// every move, once per cell. A press it took keeps its drag and release
+/// wherever the pointer goes. Tags, gutters and the rest of pardes's
+/// chrome are never its, and Shift keeps pardes's own select, execute and
+/// look. True when the event was the program's.
+fn programMouse(p: *Pardes, m: Mouse) bool {
+ const button: ?panes.terminal.MouseButton = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .none => null,
+ else => return false,
+ };
+ const bit: u3 = if (button) |b| @as(u3, 1) << @intCast(@intFromEnum(b)) else 0;
+ const at = p.presentation.pointer(p.screen_w, p.screen_h, @min(m.col, p.screen_w -| 1), @min(m.row, p.screen_h -| 1));
+ const mods: panes.terminal.MouseMods = .{ .shift = m.shift, .alt = m.alt, .ctrl = m.ctrl };
+ var buf: [64]u8 = undefined;
+ if (p.mouse_grab) |*grab| {
+ const pane = p.panes[grab.id] orelse {
+ p.mouse_grab = null;
+ return false;
+ };
+ if (pane.serial != grab.serial or !pane.isTerminal()) {
+ p.mouse_grab = null;
+ return false;
+ }
+ // Off the grid the cell runs on past its edge, and the encoder
+ // clamps it there.
+ const r = p.rects[grab.id];
+ const col: i32 = if (at) |a| @as(i32, a.col) - @as(i32, r.x + config.GUTTER) else 0;
+ const row: i32 = if (at) |a| @as(i32, a.row) - @as(i32, p.bodyTop(pane, r)) else 0;
+ const action: panes.terminal.MouseAction = switch (m.kind) {
+ .press => .press,
+ .release => .release,
+ .drag, .motion => .motion,
+ };
+ if (m.kind == .press) grab.held |= bit;
+ if (m.kind == .release) grab.held &= ~bit;
+ const bytes = panes.terminal.mouseBytes(pane, &buf, action, button, mods, col, row, grab.held != 0);
+ if (bytes.len > 0) p.emitWrite(grab.id, bytes);
+ if (grab.held == 0) p.mouse_grab = null;
+ return true;
+ }
+ if (m.shift or p.drag != .none or p.header_drag or p.header_sweep != null) return false;
+ const mapped = at orelse return false;
+ const id = paneAt(p, mapped.col, mapped.row) orelse return false;
+ const pane = p.panes[id].?;
+ if (!tracksMouse(pane)) return false;
+ const cell = programCell(p, id, mapped.col, mapped.row) orelse return false;
+ switch (m.kind) {
+ .press => {
+ if (id != p.active) if (p.panes[p.active]) |previous| {
+ previous.focus = .body;
+ previous.closePrompt();
+ };
+ p.active = id;
+ pane.focus = .body;
+ pane.closePrompt();
+ p.mouse_grab = .{ .id = id, .serial = pane.serial, .held = bit };
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .press, button, mods, cell.col, cell.row, true);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ return true;
+ },
+ // A release or drag of a press pardes never passed on stays
+ // pardes's.
+ .release, .drag => return false,
+ .motion => {
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .motion, null, mods, cell.col, cell.row, false);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ // Under 1003 the move is the program's; below that pardes
+ // hovers as ever.
+ return panes.terminal.tracksAllMotion(pane);
+ },
+ }
+}
+
pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A click goes somewhere else: the question it leaves unanswered is
// taken back, and the pane stays. A wheel only looks around.
@@ -376,6 +482,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
}
}
p.pointer_inside = true;
+ if (programMouse(p, m)) return;
if (m.button == .none and m.kind == .motion) {
p.raw_hover_intent = true;
look.refreshLookHoverFromRaw(p);
@@ -525,7 +632,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id].?;
if (pane.hasPdf())
panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
- else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl))
+ else if (!programWheel(p, id, m, mcol, mrow))
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
@@ -533,7 +640,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
- if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) {
+ if (programWheel(p, id, m, mcol, mrow)) {
// the program's
} else if (pane.hasPdf()) {
panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
@@ -1250,16 +1357,13 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
if (panes.terminal.reportsMouse(pane)) {
- const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999));
- var mb: [32]u8 = undefined;
- if (panes.terminal.mouseFormatSgr(pane)) {
- p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
- } else {
- // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
- const cb: u8 = @intCast(32 + @min(col, 222));
- const rb: u8 = @intCast(32 + @min(row, 222));
- p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
+ // A click where the text goes, so the program's cursor is there.
+ const col = @max(0, pane.sel[sel_slot].c1);
+ const row = @max(0, pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows));
+ var mb: [64]u8 = undefined;
+ for ([_]panes.terminal.MouseAction{ .press, .release }) |action| {
+ const bytes = panes.terminal.mouseBytes(pane, &mb, action, .left, .{}, col, row, action == .press);
+ if (bytes.len > 0) p.emitWrite(s.id, bytes);
}
}
edit.typeToTty(p, s.id, pane, y);
@@ -1838,11 +1942,16 @@ test "a wheel over a program that owns it is the program's, in the form it asked
// Grid cell 6,4 (1-based), as a program counts.
const col = r.x + config.GUTTER + 5;
const row = p.bodyTop(pane, r) + 3;
- const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, sent: []const u8 };
+ const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, alt: bool = false, shift: bool = false, sent: []const u8 };
const cases = [_]Case{
// codex's chat (0.158+): the alternate screen, any-event tracking, SGR.
.{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" },
.{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" },
+ // Meta is 8, both 24; Shift keeps the wheel pardes's, which has
+ // nothing to scroll on the alternate screen.
+ .{ .modes = "", .button = .wheel_up, .alt = true, .sent = "\x1b[<72;6;4M" },
+ .{ .modes = "", .button = .wheel_up, .alt = true, .ctrl = true, .sent = "\x1b[<88;6;4M" },
+ .{ .modes = "", .button = .wheel_up, .shift = true, .sent = "" },
// htop's legacy bytes (1000 alone), and 1015's.
.{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" },
.{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" },
@@ -1857,7 +1966,7 @@ test "a wheel over a program that owns it is the program's, in the form it asked
for (cases) |case| {
p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
_ = try ptyWrites(p, &out);
- p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl } });
+ p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl, .alt = case.alt, .shift = case.shift } });
try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out));
}
// A shell on the main screen: the wheel scrolls its scrollback, and
@@ -1873,4 +1982,101 @@ test "a wheel over a program that owns it is the program's, in the form it asked
p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
try std.testing.expect(pane.scroll() != was);
+ // A program tracking the mouse on the main screen: Shift and the
+ // wheel scroll the scrollback, the program hears nothing.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1000h\x1b[?1006h" } });
+ _ = try ptyWrites(p, &out);
+ const tracked = pane.scroll();
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row, .shift = true } });
+ try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
+ try std.testing.expect(pane.scroll() != tracked);
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
+ try std.testing.expectEqualStrings("\x1b[<64;6;4M", try ptyWrites(p, &out));
+}
+
+test "a program tracking the mouse gets its clicks, drags and moves over its grid as xterm reports them; Shift and chrome stay pardes's" {
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWrites(p, &out);
+ const pane = p.panes[0].?;
+ try std.testing.expectEqual(.tty, pane.body.mode);
+ const r = p.rects[0];
+ const col = r.x + config.GUTTER + 5;
+ const row = p.bodyTop(pane, r) + 3;
+ const Ev = struct { button: Mouse.Button, kind: Mouse.Kind, dx: i32 = 0, dy: i32 = 0, shift: bool = false, alt: bool = false, ctrl: bool = false };
+ const send = struct {
+ fn f(pp: *Pardes, o: *std.ArrayList(u8), c: u16, rw: u16, e: Ev) ![]const u8 {
+ pp.update(.{ .mouse = .{
+ .button = e.button,
+ .kind = e.kind,
+ .col = @intCast(@as(i32, c) + e.dx),
+ .row = @intCast(@as(i32, rw) + e.dy),
+ .shift = e.shift,
+ .alt = e.alt,
+ .ctrl = e.ctrl,
+ } });
+ return ptyWrites(pp, o);
+ }
+ }.f;
+ // htop's modes: clicks in SGR, a release its own `m`.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1h\x1b[?1006;1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expect(p.drag == .none);
+ // 1000 has no drags; the release is reported where it lands.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;4m", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 1 }));
+ // Right with meta and ctrl: 2 + 8 + 16.
+ try std.testing.expectEqualStrings("\x1b[<26;6;4M", try send(p, &out, col, row, .{ .button = .right, .kind = .press, .alt = true, .ctrl = true }));
+ try std.testing.expectEqualStrings("\x1b[<2;6;4m", try send(p, &out, col, row, .{ .button = .right, .kind = .release }));
+ // Moves are no 1000 program's business: pardes hovers.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion }));
+ // Shift: pardes's own sweep, the program hears nothing of it.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press, .shift = true }));
+ try std.testing.expect(p.drag == .select);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 2 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 2 }));
+ try std.testing.expect(p.drag == .none);
+ // Chrome is never the program's: the tag, and the gutter.
+ const tag_row = p.tagTop(pane, r);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .release }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .release }));
+
+ // codex's any-event tracking: a drag once per cell, carried past the
+ // grid's edge and clamped there, and bare moves.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<35;9;4M", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[<32;7;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ // Up over the tag: row 1, the grid's first.
+ try std.testing.expectEqualStrings("\x1b[<32;7;1M", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .drag }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;1m", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .release }));
+ try std.testing.expect(p.mouse_grab == null);
+ // Over the tag a bare move is pardes's.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .none, .kind = .motion }));
+
+ // Legacy X10 bytes: a release is button 3. (Turning 1003 off turns
+ // tracking off, as xterm's one tracking mode goes.)
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003l\x1b[?1006l\x1b[?1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[M &$", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[M#&$", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
+
+ // A pane out of the program's raw input (Esc to normal mode) clicks as
+ // pardes clicks.
+ pane.body.mode = .normal;
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
}
diff --git a/src/pardes.zig b/src/pardes.zig
index b16d39b7..402b3cd8 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3482,6 +3482,9 @@ pub const Mouse = struct {
col: u16,
row: u16,
ctrl: bool = false,
+ shift: bool = false,
+ /// Alt, which xterm's mouse reports call meta.
+ alt: bool = false,
body_hit: ?BodyHit = null,
tag_hit: ?TagHit = null,
/// A pixel frontend's press on the rule it draws at the top of this row,
@@ -3575,6 +3578,8 @@ pub const Event = union(enum) {
/// The host pointer left the drawable area. Unlike an out-of-range motion,
/// this must not clamp onto and preview the final grid cell.
pointer_leave,
+ /// The window gained (true) or lost the host's keyboard focus.
+ focus: bool,
tick,
};
@@ -4346,6 +4351,13 @@ pub const Pardes = struct {
unattended: bool = false,
haptic: HapticSlot = if (platform == .macos) .none else {},
drag: mouse.Drag = .none,
+ /// A press a terminal's program took (mouse.programMouse): its drags
+ /// and release are the program's too, wherever the pointer goes.
+ mouse_grab: ?mouse.Grab = null,
+ /// Whether the host window has the keyboard (Event.focus), and the pane
+ /// whose program was last told it has it (reportFocus).
+ window_focused: bool = true,
+ focus_told: ?struct { id: usize, serial: u32 } = null,
hover_col: u16 = 0,
hover_row: u16 = 0,
pointer_raw_col: u16 = 0,
@@ -4361,6 +4373,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).
@@ -4502,6 +4518,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 +4771,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);
@@ -5039,6 +5059,25 @@ pub const Pardes = struct {
}
/// The column the keyboard is in: its tag's, or its pane's.
+ /// DECSET 1004: a program that asked is told `CSI I` when its pane
+ /// takes the keyboard (the active pane, its body, the window focused)
+ /// and `CSI O` when it gives it up, to another pane, a tag or header,
+ /// or another window.
+ fn reportFocus(p: *Pardes) void {
+ const want: @FieldType(Pardes, "focus_told") = blk: {
+ if (!p.window_focused or p.header_focus) break :blk null;
+ const pane = p.panes[p.active] orelse break :blk null;
+ if (pane.focus != .body) break :blk null;
+ break :blk .{ .id = p.active, .serial = pane.serial };
+ };
+ if (std.meta.eql(want, p.focus_told)) return;
+ if (p.focus_told) |old| if (p.panes[old.id]) |pane| {
+ if (pane.serial == old.serial and panes.terminal.reportsFocus(pane)) p.emitWrite(old.id, "\x1b[O");
+ };
+ if (want) |new| if (panes.terminal.reportsFocus(p.panes[new.id].?)) p.emitWrite(new.id, "\x1b[I");
+ p.focus_told = want;
+ }
+
pub fn noteTyped(p: *Pardes) void {
if (p.header_focus) {
if (p.header_column) |c| p.setActiveColumn(c);
@@ -5699,6 +5738,7 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ defer p.reportFocus();
// A tick with nothing animating is the one event that cannot change
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
@@ -5831,6 +5871,7 @@ pub const Pardes = struct {
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
+ .focus => |focused| p.window_focused = focused,
.pointer_leave => {
p.pointer_inside = false;
p.pointer_body_hit = null;
diff --git a/src/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);
+}
- 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 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));
+}
+
+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/src/terminal.zig b/src/terminal.zig
index 7874ca89..c0341d00 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -124,6 +124,9 @@ pub const State = struct {
replay: Replay,
reply: [256]u8 = undefined,
reply_len: u16 = 0,
+ /// The grid cell the program last heard the mouse at, so motion is
+ /// reported once per cell.
+ mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {},
};
const GColor = ghostty_vt.color;
@@ -374,8 +377,36 @@ pub fn bracketedPaste(pane: *const Pane) bool {
pub fn reportsMouse(pane: *const Pane) bool {
if (comptime !enabled) return false;
const state = pane.terminal orelse return false;
- const m = &state.vt.modes;
- return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
+ return state.vt.flags.mouse_event != .none;
+}
+
+/// A paste as xterm makes one (ghostty's encoder): the control bytes a
+/// paste could smuggle a command in by (ESC, so no ESC[201~ can end
+/// 2004's brackets early, NUL, ^C and the rest of xterm's list) become
+/// spaces, then the text goes inside the brackets when the program set
+/// 2004, else with its newlines as Returns. `text` is rewritten in place.
+pub fn encodePaste(pane: *const Pane, text: []u8) [3][]const u8 {
+ if (comptime !enabled) {
+ for (text) |*ch| if (ch.* == '\n') {
+ ch.* = '\r';
+ };
+ return .{ "", text, "" };
+ }
+ return ghostty_vt.input.encodePaste(text, .{ .bracketed = bracketedPaste(pane) });
+}
+
+/// DECSET 1004: the program wants to hear when it gains and loses focus.
+pub fn reportsFocus(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.focus_event);
+}
+
+/// ...every move of it, a button down or not (1003).
+pub fn tracksAllMotion(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.flags.mouse_event == .any;
}
/// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
@@ -395,43 +426,71 @@ pub fn ownsWheel(pane: *const Pane) bool {
(state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll));
}
-pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 };
+pub const MouseMods = struct { shift: bool = false, alt: bool = false, ctrl: bool = false };
+pub const MouseButton = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right };
+pub const MouseAction = enum { press, release, motion };
+
+/// One mouse event for the program, as xterm reports it (ctlseqs; ghostty's
+/// encoder): the tracking mode it set (1000 clicks, 1002 drags, 1003 all
+/// motion) decides whether it hears of it, the format it set (1006 SGR,
+/// 1015 urxvt, 1005 UTF-8, else X10) how, modifiers as shift+4, meta+8,
+/// ctrl+16. `col`,`row` are the 0-based grid cell; a drag that left the
+/// grid is reported clamped to its edge, motion only when the cell
+/// changed. Empty when the program does not hear of it.
+pub fn mouseBytes(pane: *Pane, buf: *[64]u8, action: MouseAction, button: ?MouseButton, mods: MouseMods, col: i32, row: i32, any_pressed: bool) []const u8 {
+ if (comptime !enabled) return &.{};
+ const state = pane.terminal orelse return &.{};
+ const input = ghostty_vt.input;
+ var writer = std.Io.Writer.fixed(buf);
+ var opts: input.MouseEncodeOptions = .{
+ .event = state.vt.flags.mouse_event,
+ .format = state.vt.flags.mouse_format,
+ .size = .{
+ .screen = .{ .width = state.vt.cols, .height = state.vt.rows },
+ .cell = .{ .width = 1, .height = 1 },
+ .padding = .{},
+ },
+ .any_button_pressed = any_pressed,
+ .last_cell = &state.mouse_cell,
+ };
+ // A release is always reported; the rest only over the grid, or held.
+ if (action == .release) opts.any_button_pressed = true;
+ input.encodeMouse(&writer, .{
+ .action = switch (action) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ },
+ .button = if (button) |b| switch (b) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .four,
+ .wheel_down => .five,
+ .wheel_left => .six,
+ .wheel_right => .seven,
+ } else null,
+ .mods = .{ .shift = mods.shift, .alt = mods.alt, .ctrl = mods.ctrl },
+ .pos = .{ .x = @as(f32, @floatFromInt(col)) + 0.5, .y = @as(f32, @floatFromInt(row)) + 0.5 },
+ }, opts) catch return &.{};
+ return writer.buffered();
+}
/// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the
-/// program, as xterm sends them: its mouse report in the format it asked
-/// for, else, on the alternate screen, an arrow key (none for a sideways
-/// turn). Null when the wheel is not the program's (`ownsWheel`) or the
-/// cell cannot be said in the legacy format.
-pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 {
+/// program: its mouse report while it tracks the mouse, else, on the
+/// alternate screen, an arrow key in the cursor keys' mode (none for a
+/// sideways turn). Null when the wheel is not the program's (`ownsWheel`).
+pub fn wheelBytes(pane: *Pane, buf: *[64]u8, wheel: MouseButton, col: u16, row: u16, mods: MouseMods) ?[]const u8 {
if (comptime !enabled) return null;
const state = pane.terminal orelse return null;
if (!ownsWheel(pane)) return null;
const m = &state.vt.modes;
if (!reportsMouse(pane)) return switch (wheel) {
- .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A",
- .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B",
- .left, .right => null,
+ .wheel_up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A",
+ .wheel_down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B",
+ else => null,
};
- const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0);
- const x = @as(u32, col) + 1;
- const y = @as(u32, row) + 1;
- if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null;
- if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null;
- buf[0..3].* = "\x1b[M".*;
- buf[3] = 32 + code;
- var n: usize = 4;
- for ([_]u32{ x, y }) |v| {
- const c = 32 + v;
- if (m.get(.mouse_format_utf8)) {
- if (c > 2047) return null;
- n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null;
- } else {
- if (c > 255) return null;
- buf[n] = @intCast(c);
- n += 1;
- }
- }
- return buf[0..n];
+ return mouseBytes(pane, buf, .press, wheel, mods, col, row, false);
}
/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body
@@ -2706,3 +2765,55 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
return .{};
}
+
+/// Every byte the core wrote to each pane's pty since the last call, `\n`
+/// between panes' shares: "0:<bytes>\n1:<bytes>\n" for panes that got any.
+fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ var per: [pardes.MAX_PANES]std.ArrayList(u8) = @splat(.empty);
+ defer for (&per) |*b| b.deinit(p.gpa);
+ while (p.nextEffect()) |effect| {
+ if (effect == .write) try per[effect.write.pane].appendSlice(p.gpa, effect.write.bytes.slice());
+ }
+ for (per, 0..) |b, id| if (b.items.len > 0) try out.print(p.gpa, "{d}:{s}\n", .{ id, b.items });
+ return out.items;
+}
+
+test "a program that asked for focus reports hears CSI I and CSI O as its pane takes and gives up the keyboard, and as the window does" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 90, .rows = 30, .shells = 3 });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWritesByPane(p, &out);
+ p.active = 0;
+ // Asking while its pane has the keyboard: told at once that it has.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1004h" } });
+ try std.testing.expectEqualStrings("0:\x1b[I\n", try ptyWritesByPane(p, &out));
+ p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?1004h" } });
+ try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out));
+ // A click into the other pane moves the keyboard: out of one, into the
+ // other.
+ const r = p.rects[1];
+ const at_col = r.x + config.GUTTER + 2;
+ const at_row = p.bodyTop(p.panes[1].?, r) + 1;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = at_col, .row = at_row } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = at_col, .row = at_row } });
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqualStrings("0:\x1b[O\n1:\x1b[I\n", try ptyWritesByPane(p, &out));
+ // The window: away and back.
+ p.update(.{ .focus = false });
+ try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out));
+ p.update(.{ .focus = false });
+ try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out));
+ p.update(.{ .focus = true });
+ try std.testing.expectEqualStrings("1:\x1b[I\n", try ptyWritesByPane(p, &out));
+ // A program that did not ask hears nothing: pane 2.
+ p.active = 2;
+ p.update(.tick);
+ try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out));
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 1d7ff415..eabbbe22 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1505,8 +1505,11 @@ const Shell = struct {
core.quit = true;
return true;
},
- .focus_in => {},
- .focus_out => core.update(.pointer_leave),
+ .focus_in => core.update(.{ .focus = true }),
+ .focus_out => {
+ core.update(.pointer_leave);
+ core.update(.{ .focus = false });
+ },
.winsize => |ws| {
s.vx.resize(s.gpa, s.tty.writer(), ws) catch {};
if (comptime pardes.pdf_enabled)
@@ -2244,6 +2247,44 @@ test "a terminal pane gets function keys as their sequences and lone modifiers n
try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written());
}
+test "the terminal's keys reach a program as its cursor keys' mode and keyboard flags say" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ core.panes[0].?.body.mode = .tty;
+ while (core.nextEffect()) |_| {}
+ var written: std.Io.Writer.Allocating = .init(gpa);
+ defer written.deinit();
+ // What the outer terminal sends, what the program asked for, and what
+ // it should read.
+ const Case = struct { modes: []const u8, in: []const u8, sent: []const u8 };
+ const cases = [_]Case{
+ .{ .modes = "", .in = "\x1b[A\x1b[H\x1bOP\x1b[15~", .sent = "\x1b[A\x1b[H\x1bOP\x1b[15~" },
+ .{ .modes = "\x1b[?1h", .in = "\x1b[A\x1b[F\x1b[1;5D", .sent = "\x1bOA\x1bOF\x1b[1;5D" },
+ .{ .modes = "\x1b[?1l\x1b[>7u", .in = "\x1b[99;5u\x1b[97;3u\x1b[A", .sent = "\x1b[99;5u\x1b[97;3u\x1b[1;1:1A" },
+ };
+ for (cases) |case| {
+ core.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ while (core.nextEffect()) |_| {}
+ var parser: vaxis.Parser = .{};
+ var bytes = case.in;
+ while (bytes.len > 0) {
+ const result = try parser.parse(bytes, gpa);
+ if (result.n == 0) break;
+ bytes = bytes[result.n..];
+ if (result.event) |event| switch (event) {
+ .key_press => |key| core.update(vaxis_input.keyEvent(key)),
+ else => {},
+ };
+ }
+ written.clearRetainingCapacity();
+ while (core.nextEffect()) |effect| {
+ if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice());
+ }
+ try std.testing.expectEqualStrings(case.sent, written.written());
+ }
+}
+
test "keypad Enter matches Return for editor input terminal writes and paste" {
for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| {
const key: vaxis.Key = .{ .codepoint = codepoint };
@@ -2297,6 +2338,8 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
.col = @intCast(m.col),
.row = @intCast(m.row),
.ctrl = m.mods.ctrl,
+ .shift = m.mods.shift,
+ .alt = m.mods.alt,
} };
}
@@ -2708,8 +2751,11 @@ const Attach = struct {
switch (event) {
.nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
- .focus_in => {},
- .focus_out => return a.send(.pointer_leave),
+ .focus_in => return a.send(.{ .focus = true }),
+ .focus_out => {
+ if (a.send(.pointer_leave)) |end| return end;
+ return a.send(.{ .focus = false });
+ },
.winsize => |ws| {
a.vx.resize(a.gpa, a.tty.writer(), ws) catch {};
a.dirty = true;