summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md30
-rw-r--r--src/ShaderBuild.zig8
-rw-r--r--src/builtins.zig3
-rw-r--r--src/dump.zig7
-rw-r--r--src/exec.zig3
-rw-r--r--src/fs.zig57
-rw-r--r--src/gui/gui.zig183
-rw-r--r--src/look.zig5
-rw-r--r--src/main.zig7
-rw-r--r--src/mouse.zig85
-rw-r--r--src/ninep/pane.zig3
-rw-r--r--src/tagline.zig3
-rw-r--r--src/terminal.zig49
13 files changed, 374 insertions, 69 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 3dd7c996..25740d16 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog
+## 0.0.8
+
+- `~` and `~/` mean the home directory wherever a path is taken: look
+ (`~/x:12`, `~/x:/re/`), `name`, `Save`, `ThemeFile`, DumpDir, Restore.
+- `pardes --wait` on a new file waits on the pane it made, however fast
+ that pane is saved and closed. A session's socket appears only once it is
+ listening, so an early client is never refused.
+- A wheel over a terminal whose program asked for the mouse goes to the
+ program, as a report in the format it chose, or as arrow keys on the
+ alternate screen with alternate scroll: codex, htop, less and vim scroll.
+- `pardes FILE` in a pane whose session answers never starts a nested
+ editor: a refusal is printed and it exits 1. A tty pardes killed by
+ SIGTERM or SIGHUP puts its terminal back first.
+- A 9P connection holds up to 128 waiting reads, and refuses the next in
+ words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held
+ reads.
+- A backward search along one long line takes time in its length, not its
+ square. A detached session no client watches draws no frames: 50 MB of
+ output through `pty/run` takes 11 s, not over 30.
+- Save makes its file's missing directories, for its own name and `Save
+ path` alike. `pane/new` takes rows from another column before refusing.
+- With no pane at all, the root `look` opens its file and the root `exec`
+ runs in the session's directory. A refused `Newcol` logs only its error.
+- `Lspwhy` explains the last query in its own file and place; every `+Lsp`
+ answer shares one pane per directory.
+- DumpConfig's information lines start with `# `, and a line starting with
+ `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0`
+ is refused. `Kill X` with nothing running is a message, not a failure.
+ An event write-back needs no trailing newline.
+
## 0.0.7
- `lapis` draws its cells unblended and each plaque pixel once: about a
diff --git a/src/ShaderBuild.zig b/src/ShaderBuild.zig
index c2610fd7..b50999a5 100644
--- a/src/ShaderBuild.zig
+++ b/src/ShaderBuild.zig
@@ -236,11 +236,9 @@ fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) v
}
}
-/// `~/` is the home directory.
-fn expand(buf: []u8, path: []const u8) []const u8 {
- if (!std.mem.startsWith(u8, path, "~/")) return path;
- const home = libc.getenv("HOME") orelse return path;
- return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path;
+/// `~` is the home directory (the one rule, filesystem.expandHome).
+fn expand(buf: *[4096]u8, path: []const u8) []const u8 {
+ return pardes.filesystem.expandHome(path, buf);
}
/// The compile thread: each file behind the prefix, through glslc with the
diff --git a/src/builtins.zig b/src/builtins.zig
index ba67766d..6aabba8d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -818,7 +818,8 @@ pub const ThemeFile = struct {
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
- const arg = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
+ var home_buf: [4096]u8 = undefined;
+ const arg = pardes.filesystem.expandHome(std.mem.trim(u8, c.arg orelse return, " \t\r\n"), &home_buf);
// Said with why, not "invalid theme path".
if (!std.mem.endsWith(u8, arg, ".zon")) {
var said: [320]u8 = undefined;
diff --git a/src/dump.zig b/src/dump.zig
index 1cc115be..8e767240 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -100,13 +100,10 @@ fn hostEnv(name: [*:0]const u8) ?[*:0]u8 {
/// setting when set (a leading `~/` is the home directory), else the default.
pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
if (dir_setting.len == 0) return defaultDirectory(buf);
- var joined: [2048]u8 = undefined;
+ var joined: [4096]u8 = undefined;
// `~/x`, else a relative one from the directory pardes started in: the
// same place however it was written, and shown so.
- const raw = if (std.mem.startsWith(u8, dir_setting, "~/")) (if (hostEnv("HOME")) |h|
- std.fmt.bufPrint(&joined, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch return null
- else
- dir_setting) else dir_setting;
+ const raw = pardes.filesystem.expandHome(dir_setting, &joined);
var fba_buf: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&fba_buf);
var cwd_buf: [1024]u8 = undefined;
diff --git a/src/exec.zig b/src/exec.zig
index 7d682d74..d114dbf8 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -295,9 +295,10 @@ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
if (path.len == 0 or path[path.len - 1] == '/' or
std.mem.indexOfScalar(u8, path, '\n') != null)
return p.reportError(id, "save", error.NoFilename);
+ var home_buf: [4096]u8 = undefined;
const full = std.fs.path.resolvePosix(
p.scratch.allocator(),
- &.{ Pardes.paneDir(pane), path },
+ &.{ Pardes.paneDir(pane), @import("fs.zig").expandHome(path, &home_buf) },
) catch return;
if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory);
if (full.len > Pardes.SavePath.cap) return p.reportError(id, "save", error.PathTooLong);
diff --git a/src/fs.zig b/src/fs.zig
index 30ad9c43..7695d497 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -732,6 +732,59 @@ pub fn dotRelative(word: []const u8) bool {
/// Where a path really is on the host, and whether it is a directory. The
/// path may lie in a mount this editor serves, so the turn is given up for
/// the syscalls: another thread answers them.
+/// A path's leading `~` -- alone, or before `/` or `:` (`~/x:12`) -- is the
+/// home directory: $HOME, else the passwd entry's. `~user/...` is that
+/// user's. As in a shell, `~` is home even beside a file named `~` in the
+/// current directory. Every entry point that takes a path the user typed
+/// runs it through here: look (B3 and 9P), name, Save, ThemeFile, DumpDir,
+/// Restore, a forwarded `pardes ~/x`. The path itself when there is no
+/// `~` to expand, or no home for it.
+pub fn expandHome(path: []const u8, out: *[4096]u8) []const u8 {
+ if (comptime !pardes.hosted) return path;
+ return expandHomeFrom(path, out, std.c.getenv("HOME"));
+}
+
+/// expandHome with HOME given (null: unset), so a test need not change the
+/// process's environment, which a child spawned later reads.
+fn expandHomeFrom(path: []const u8, out: *[4096]u8, env_home: ?[*:0]const u8) []const u8 {
+ if (path.len == 0 or path[0] != '~') return path;
+ const end = std.mem.indexOfAny(u8, path, "/:") orelse path.len;
+ const home = (if (end == 1) homeDir(env_home) else userHome(path[1..end])) orelse return path;
+ const trimmed = std.mem.trimEnd(u8, home, "/");
+ const rest = path[end..];
+ if (trimmed.len == 0 and (rest.len == 0 or rest[0] != '/')) return std.fmt.bufPrint(out, "/{s}", .{rest}) catch path;
+ return std.fmt.bufPrint(out, "{s}{s}", .{ trimmed, rest }) catch path;
+}
+
+fn homeDir(env_home: ?[*:0]const u8) ?[]const u8 {
+ if (env_home) |h| if (h[0] != 0) return std.mem.span(h);
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+fn userHome(name: []const u8) ?[]const u8 {
+ var z: [256]u8 = undefined;
+ const name_z = std.fmt.bufPrintSentinel(&z, "{s}", .{name}, 0) catch return null;
+ const pw = std.c.getpwnam(name_z) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+test "a leading ~ is home, from HOME or else the passwd entry" {
+ var out: [4096]u8 = undefined;
+ const home = homeDir(std.c.getenv("HOME")) orelse return error.SkipZigTest;
+ var want: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{s}/x:12", .{std.mem.trimEnd(u8, home, "/")}), expandHome("~/x:12", &out));
+ try std.testing.expectEqualStrings(std.mem.trimEnd(u8, home, "/"), expandHome("~", &out));
+ try std.testing.expectEqualStrings("a/~/b", expandHome("a/~/b", &out));
+ try std.testing.expectEqualStrings("~no-such-user-pardes/x", expandHome("~no-such-user-pardes/x", &out));
+ try std.testing.expectEqualStrings("/", expandHome("~root", &out)[0..1]);
+
+ // HOME unset: the passwd entry's home.
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return error.SkipZigTest;
+ var from_passwd: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&from_passwd, "{s}/y", .{std.mem.trimEnd(u8, std.mem.span(pw.dir.?), "/")}), expandHomeFrom("~/y", &out, null));
+}
+
pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
if (comptime !platform_has_fs) return null;
var z: [4096]u8 = undefined;
@@ -1171,7 +1224,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
/// Restore first tries a relative argument under the default dump directory,
/// then preserves the ordinary file lookup. Absolute paths remain verbatim.
-pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 {
+pub fn readRestore(gpa: std.mem.Allocator, typed: []const u8, dir_setting: []const u8) ![]u8 {
+ var home_buf: [4096]u8 = undefined;
+ const path = expandHome(typed, &home_buf);
var dir_buf: [4096]u8 = undefined;
const dir = @import("dump.zig").directory(&dir_buf, dir_setting);
return readRestoreIn(gpa, path, dir);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index be961477..045f22d9 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1882,6 +1882,9 @@ const Gui = struct {
/// G6: a wheel's notches, whole lines, scroll the core at once and the
/// picture follows on a spring (Glide), never the other way round.
scroll_notch: i32 = 0,
+ /// A touchpad's fraction toward the next turn sent to a program that
+ /// owns the wheel (terminal.ownsWheel).
+ program_wheel: f32 = 0,
glide: Glide = .{},
/// G9 and G11: the selection's and the hovered word's glow blocks as
/// last drawn, for their fade-in.
@@ -3578,55 +3581,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- if (w.y != 0 and std.math.isFinite(w.y)) {
- if (in.core) |core| {
- const hit: ?usize = for (core.panes, 0..) |slot, i| {
- if (slot == null) continue;
- const r = core.rects[i];
- if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i;
- } else null;
- if (g.scroll_pane) |old| if (hit == null or hit.? != old) {
- resetScroll(g);
- };
- if (hit) |id| {
- const pdf_target = if (comptime pardes.pdf_enabled)
- core.native_images and core.panes[id].?.pdfPage() != null
- else
- false;
- if (pdf_target) {
- resetScroll(g);
- in.post(.{ .pdf_scroll = .{
- .pane = @intCast(id),
- .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
- } });
- } else {
- g.scroll_pane = id;
- g.scroll_col = mc.col;
- g.scroll_row = mc.row;
- // A mouse wheel's notch is a whole line and
- // glides (G6); a touchpad's fraction moves the
- // picture by itself, under the finger.
- if (notchOf(w.y, w.integer_y)) |lines| {
- g.scroll_notch +|= lines;
- } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
- }
- }
- } else {
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
- while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
- in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
- while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
- in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
- }
- }
- if (w.x != 0) in.post(.{ .mouse = .{
- .button = if (w.x > 0) .wheel_right else .wheel_left,
- .kind = .press,
- .col = mc.col,
- .row = mc.row,
- .tag_hit = mc.tag_hit,
- .body_hit = mc.body_hit,
- } });
+ wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y }, mc);
},
c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
const f = sev.tfinger;
@@ -3933,6 +3888,73 @@ test "a click at the window's corner lands in the corner cell with a post chain
/// Where a window point lands: the cell drawn under it. Input is identity:
/// no post pass moves a pixel (the bundled Crt has no barrel), so a click
/// is exactly where it looks.
+/// A wheel turned over grid cell `mc` (SDL's sign: up positive): the
+/// part of dispatch after the pointer is placed, so a test can turn it.
+fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void {
+ if (w.y != 0 and std.math.isFinite(w.y)) {
+ if (in.core) |core| {
+ const hit: ?usize = for (core.panes, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = core.rects[i];
+ if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i;
+ } else null;
+ if (g.scroll_pane) |old| if (hit == null or hit.? != old) {
+ resetScroll(g);
+ };
+ if (hit) |id| {
+ const pdf_target = if (comptime pardes.pdf_enabled)
+ core.native_images and core.panes[id].?.pdfPage() != null
+ else
+ false;
+ const pane = core.panes[id].?;
+ if (pdf_target) {
+ resetScroll(g);
+ in.post(.{ .pdf_scroll = .{
+ .pane = @intCast(id),
+ .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
+ } });
+ } else if (pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) {
+ // The program's (codex, htop, less): every whole
+ // turn goes to it, no glide, for nothing of ours
+ // moves.
+ resetScroll(g);
+ g.program_wheel = accumulateWheelDelta(g.program_wheel, w.y);
+ while (g.program_wheel >= 1) : (g.program_wheel -= 1)
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ while (g.program_wheel <= -1) : (g.program_wheel += 1)
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ } else {
+ g.scroll_pane = id;
+ g.scroll_col = mc.col;
+ g.scroll_row = mc.row;
+ // A mouse wheel's notch is a whole line and
+ // glides (G6); a touchpad's fraction moves the
+ // picture by itself, under the finger.
+ if (notchOf(w.y, w.integer_y)) |lines| {
+ g.scroll_notch +|= lines;
+ } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ }
+ }
+ } else {
+ g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ while (g.scroll_delta >= 1) : (g.scroll_delta -= 1)
+ in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } });
+ }
+ }
+ if (w.x != 0) in.post(.{ .mouse = .{
+ .button = if (w.x > 0) .wheel_right else .wheel_left,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ .tag_hit = mc.tag_hit,
+ .body_hit = mc.body_hit,
+ } });
+}
+
+const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0 };
+
fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
return gridCellAtPixels(g, core, physical.x, physical.y);
@@ -4896,6 +4918,69 @@ test "a wheel's notch is whole lines, a touchpad's fraction is not" {
try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1));
}
+test "a wheel over codex in a terminal reaches its pty as the mouse reports it asked for" {
+ if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer core.deinit();
+ var frame: std.heap.ArenaAllocator = .init(gpa);
+ defer frame.deinit();
+ _ = try core.render(frame.allocator());
+ var g: Gui = undefined;
+ g.scroll_pane = null;
+ g.scroll_delta = 0;
+ g.scroll_lag = 0;
+ g.scroll_edge_len = 0;
+ g.scroll_notch = 0;
+ g.program_wheel = 0;
+ g.glide = .{};
+ g.cell_h = 20;
+ var in: Input = .{ .core = core };
+ var sent: std.ArrayList(u8) = .empty;
+ defer sent.deinit(gpa);
+ const Sent = struct {
+ fn take(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (core_.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice());
+ }
+ return out.items;
+ }
+ };
+ _ = try Sent.take(core, &sent);
+ const pane = core.panes[0].?;
+ const r = core.rects[0];
+ const mc: MouseCell = .{ .col = r.x + config.GUTTER + 5, .row = core.bodyTop(pane, r) + 3 };
+ // What codex 0.159's chat sets on start.
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" } });
+ _ = try Sent.take(core, &sent);
+ // A notch up, then a fast turn of three down: each its report, none
+ // left behind for a glide.
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc);
+ try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent));
+ wheelAt(&g, &in, .{ .y = -3, .integer_y = -3 }, mc);
+ try std.testing.expectEqualStrings("\x1b[<65;6;4M" ** 3, try Sent.take(core, &sent));
+ try std.testing.expectEqual(@as(i32, 0), g.scroll_notch);
+ try std.testing.expect(g.scroll_pane == null);
+ // A touchpad's fractions add up to a turn.
+ wheelAt(&g, &in, .{ .y = 0.5 }, mc);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ wheelAt(&g, &in, .{ .y = 0.5 }, mc);
+ try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent));
+ // Its sign-in screen: mouse off, alternate scroll on, so arrow keys.
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h" } });
+ _ = try Sent.take(core, &sent);
+ wheelAt(&g, &in, .{ .y = -1, .integer_y = -1 }, mc);
+ try std.testing.expectEqualStrings("\x1b[B", try Sent.take(core, &sent));
+ // Back on the main screen a shell's wheel is ours: a notch to glide,
+ // nothing to the pty.
+ core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l" } });
+ _ = try Sent.take(core, &sent);
+ wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc);
+ try std.testing.expectEqualStrings("", try Sent.take(core, &sent));
+ try std.testing.expectEqual(@as(i32, -1), g.scroll_notch);
+}
+
/// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at
/// once, so every key and click acts on where the text is, and the picture
/// glides there on the Motion flavour's spring (critically damped: text
diff --git a/src/look.zig b/src/look.zig
index de51804d..357a9c63 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1499,8 +1499,11 @@ 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 });
}
-pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
+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.
+ var home_buf: [4096]u8 = undefined;
+ const operand = filesystem.expandHome(std.mem.trim(u8, operand_raw, " \t\r\n"), &home_buf);
// A +Recent row is a remembered place: reopened and clamped to the file
// as it is now, never a miss for a line the file no longer has.
if (isRecentList(pane)) {
diff --git a/src/main.zig b/src/main.zig
index 859e6fb3..101a5be3 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -412,7 +412,7 @@ fn nativeMain(init: std.process.Init) !void {
// itself, which is what a right click in that pane would have been.
var look_buf: [64]u8 = undefined;
const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial});
- const word = positional orelse {
+ const typed_word = positional orelse {
var tag_buf: [64]u8 = undefined;
const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial});
const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch |err| Refuse.with(init.io, tag, err);
@@ -420,7 +420,10 @@ fn nativeMain(init: std.process.Init) !void {
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
};
- if (std.mem.indexOfAny(u8, word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
+ if (std.mem.indexOfAny(u8, typed_word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
+ // `pardes '~/x'`, quoted so the shell left it: home, as a look's.
+ var home_buf: [4096]u8 = undefined;
+ const word = pardes.filesystem.expandHome(typed_word, &home_buf);
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
var newbuf: [4096]u8 = undefined;
diff --git a/src/mouse.zig b/src/mouse.zig
index 2280020d..a0ec6e47 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -310,6 +310,23 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
return first.serial == second.serial and first.row == second.row and first.col == second.col;
}
+/// A wheel over a terminal whose program owns it (`terminal.ownsWheel`:
+/// codex, htop, less) goes to the program as the program asked, one turn
+/// per line, instead of scrolling a scrollback the alternate screen does
+/// not have. False when pardes scrolls.
+fn programWheel(p: *Pardes, id: usize, wheel: panes.terminal.Wheel, col: u16, row: u16, ctrl: bool) bool {
+ const pane = p.panes[id] orelse return false;
+ if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false;
+ const r = p.rects[id];
+ const body_y = p.bodyTop(pane, r);
+ const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1);
+ const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1);
+ var buf: [32]u8 = undefined;
+ const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, ctrl) orelse return true;
+ for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes);
+ return true;
+}
+
pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A click goes somewhere else: the question it leaves unanswered is
// taken back, and the pane stays. A wheel only looks around.
@@ -508,7 +525,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id].?;
if (pane.hasPdf())
panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
- else
+ else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl))
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
@@ -516,7 +533,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
- if (pane.hasPdf()) {
+ if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) {
+ // the program's
+ } else if (pane.hasPdf()) {
panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
// ponytail: no right clamp — overscroll shows blank and the
// next cursor move or left wheel pulls it back
@@ -1793,3 +1812,65 @@ test "a chorded exec an event reader holds is told with its argument, and writte
}
_ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
}
+
+/// Every byte the core wrote to pane 0's pty since the last call.
+fn ptyWrites(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (p.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(p.gpa, effect.write.bytes.slice());
+ }
+ return out.items;
+}
+
+test "a wheel over a program that owns it is the program's, in the form it asked for; a shell's scrolls" {
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWrites(p, &out);
+ const pane = p.panes[0].?;
+ const r = p.rects[0];
+ // Grid cell 6,4 (1-based), as a program counts.
+ const col = r.x + config.GUTTER + 5;
+ const row = p.bodyTop(pane, r) + 3;
+ const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, sent: []const u8 };
+ const cases = [_]Case{
+ // codex's chat (0.158+): the alternate screen, any-event tracking, SGR.
+ .{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" },
+ .{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" },
+ // htop's legacy bytes (1000 alone), and 1015's.
+ .{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" },
+ .{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" },
+ // codex's sign-in, less: the alternate screen asking for arrow keys
+ // (1007), in the cursor keys' mode.
+ .{ .modes = "\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h", .button = .wheel_down, .sent = "\x1b[B" },
+ .{ .modes = "\x1b[?1h", .button = .wheel_up, .sent = "\x1bOA" },
+ .{ .modes = "", .button = .wheel_right, .sent = "" },
+ // With alternate scroll off the alternate screen keeps the wheel.
+ .{ .modes = "\x1b[?1007l", .button = .wheel_up, .sent = "" },
+ };
+ for (cases) |case| {
+ p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ _ = try ptyWrites(p, &out);
+ p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl } });
+ try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out));
+ }
+ // A shell on the main screen: the wheel scrolls its scrollback, and
+ // the shell hears nothing.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1l\x1b[?1049l" } });
+ for (0..40) |i| {
+ var line: [16]u8 = undefined;
+ p.update(.{ .output = .{ .pane = 0, .bytes = try std.fmt.bufPrint(&line, "{d}\r\n", .{i}) } });
+ }
+ _ = try p.render(frame.allocator());
+ _ = try ptyWrites(p, &out);
+ const was = pane.scroll();
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
+ try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
+ try std.testing.expect(pane.scroll() != was);
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index dc8b4b10..7526075b 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -860,7 +860,8 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
"rename not allowed: an image or PDF is named by the file it shows");
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), fs.expandHome(name, &home_buf) }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be");
nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
diff --git a/src/tagline.zig b/src/tagline.zig
index 1923871a..dc7d10d4 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -344,7 +344,8 @@ pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
p.reportError(id, "name", error.InvalidName);
return false;
};
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), pardes.filesystem.expandHome(name, &home_buf) }) catch |err| {
p.reportError(id, "name", err);
return false;
};
diff --git a/src/terminal.zig b/src/terminal.zig
index 4cac6f1d..7874ca89 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -385,6 +385,55 @@ pub fn mouseFormatSgr(pane: *const Pane) bool {
return state.vt.modes.get(.mouse_format_sgr);
}
+/// A wheel over the program is the program's: it tracks the mouse, or it is
+/// on the alternate screen, which has no scrollback, with alternate scroll
+/// (DECSET 1007, on unless turned off) asking for arrow keys instead.
+pub fn ownsWheel(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return reportsMouse(pane) or
+ (state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll));
+}
+
+pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 };
+
+/// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the
+/// program, as xterm sends them: its mouse report in the format it asked
+/// for, else, on the alternate screen, an arrow key (none for a sideways
+/// turn). Null when the wheel is not the program's (`ownsWheel`) or the
+/// cell cannot be said in the legacy format.
+pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 {
+ if (comptime !enabled) return null;
+ const state = pane.terminal orelse return null;
+ if (!ownsWheel(pane)) return null;
+ const m = &state.vt.modes;
+ if (!reportsMouse(pane)) return switch (wheel) {
+ .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A",
+ .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B",
+ .left, .right => null,
+ };
+ const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0);
+ const x = @as(u32, col) + 1;
+ const y = @as(u32, row) + 1;
+ if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null;
+ if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null;
+ buf[0..3].* = "\x1b[M".*;
+ buf[3] = 32 + code;
+ var n: usize = 4;
+ for ([_]u32{ x, y }) |v| {
+ const c = 32 + v;
+ if (m.get(.mouse_format_utf8)) {
+ if (c > 2047) return null;
+ n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null;
+ } else {
+ if (c > 255) return null;
+ buf[n] = @intCast(c);
+ n += 1;
+ }
+ }
+ return buf[0..n];
+}
+
/// The whole scrollback as plain text, `gpa`-owned: what a terminal's body
/// reads and `Save` writes out. Logical lines: rows the terminal wrapped
/// are joined again (ghostty's wrap flags, as pty/run's output is), and