summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig40
-rw-r--r--src/CHANGELOG.md18
-rw-r--r--src/File.zig9
-rw-r--r--src/Messages.zig2
-rw-r--r--src/Mini.zig2
-rw-r--r--src/Output.zig10
-rw-r--r--src/Terminal.zig50
-rw-r--r--src/body_layer.zig13
-rw-r--r--src/builtins.zig117
-rw-r--r--src/colors.zig4
-rw-r--r--src/config.zig48
-rw-r--r--src/detached/server.zig16
-rw-r--r--src/draw.zig81
-rw-r--r--src/dump.zig51
-rw-r--r--src/edit.zig214
-rw-r--r--src/exec.zig185
-rw-r--r--src/fs-help.txt16
-rw-r--r--src/fs.zig11
-rw-r--r--src/gui/gui.zig86
-rw-r--r--src/host_io.zig247
-rw-r--r--src/layout.zig238
-rw-r--r--src/look.zig203
-rw-r--r--src/macos.zig22
-rw-r--r--src/mouse.zig126
-rw-r--r--src/ninep/addr.zig56
-rw-r--r--src/ninep/ctl.zig252
-rw-r--r--src/ninep/events.zig33
-rw-r--r--src/ninep/pane.zig73
-rw-r--r--src/ninep/pty.zig101
-rw-r--r--src/ninep/testing.zig9
-rw-r--r--src/ninep/tree.zig56
-rw-r--r--src/panes.zig38
-rw-r--r--src/pardes.zig747
-rw-r--r--src/pdf_view.zig2
-rw-r--r--src/regexp.zig52
-rw-r--r--src/sam_edit.zig717
-rw-r--r--src/surface.zig4
-rw-r--r--src/tagline.zig146
-rw-r--r--src/themes/acme.zig2
-rw-r--r--src/tty/tty.zig11
-rw-r--r--src/web/app.mjs3
41 files changed, 3381 insertions, 730 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 2ca11a87..651b3d0a 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -64,7 +64,9 @@ const prefix = "pardes-9p-";
pub const msize = ninep.msize;
-pub const max_conns = 4;
+/// Scripts, a mount and an agent or two at once; each slot holds its
+/// buffers (a few msize) whether used or not.
+pub const max_conns = 16;
extern "c" fn inet_pton(family: c_int, src: [*:0]const u8, dst: *anyopaque) c_int;
@@ -176,6 +178,8 @@ pub const Listener = struct {
stopping: std.atomic.Value(bool) = .init(false),
/// A connection wrote the Restore and has yet to answer it.
restore_writer: std.atomic.Value(bool) = .init(false),
+ /// Clients turned away with every slot taken, since the log last said so.
+ refused: std.atomic.Value(u32) = .init(0),
/// Connections accepted so far, and the count each slot's connection
/// was accepted at: a Restore cuts those accepted before it (`reset`),
/// and a new client in a freed slot has a later stamp. Written on the
@@ -217,6 +221,14 @@ pub const Listener = struct {
/// answers. A request that would change a pane while the editor is out
/// in a syscall mid-step is parked in the engine instead, and retried
/// when the turn is next given up between steps (`wakeParked`).
+ /// On the accepting task, without the turn: counted, and logged with
+ /// the turn (`answerHeld`).
+ fn onRefused(ctx: ?*anyopaque) void {
+ const l = of(ctx);
+ _ = l.refused.fetchAdd(1, .acq_rel);
+ l.kick();
+ }
+
fn onOpened(ctx: ?*anyopaque, conn: *Runner.Conn) void {
const l = of(ctx);
l.accepted[conn.index].store(l.accepts.fetchAdd(1, .acq_rel) + 1, .release);
@@ -296,7 +308,15 @@ pub const Listener = struct {
// Set with the turn still held, so `reset` sees it and waits.
if (restoring) l.restore_writer.store(true, .release);
defer if (restoring) l.restore_writer.store(false, .release);
+ core.fs.late_failure_len = 0;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
+ // ponytail: one slot, so a failure of another client's effects that
+ // settle in the same wait is told to this write too.
+ if (l.core == core and core.fs.late_failure_len != 0) {
+ const failed = pardes.ctlfs.failText(req.tag, pardes.ctlfs.E.IO, core.fs.late_failure[0..core.fs.late_failure_len]);
+ pardes.ctlfs.events.noteError(core, req, failed);
+ return conn.reply(&failed, "");
+ }
// The Restore's own write is answered once it is done, and before
// `reset` hangs every connection up, this one too, so the writer
// hears that it happened rather than a cut; a failed one leaves the
@@ -323,6 +343,11 @@ pub const Listener = struct {
const l = of(ctx);
if (l.stopping.load(.acquire)) return;
const core = l.core;
+ const refused = l.refused.swap(0, .acq_rel);
+ if (refused != 0) {
+ var why: [64]u8 = undefined;
+ pardes.ctlfs.events.notePath(core, "err -", std.fmt.bufPrint(&why, "9p: too many connections ({d} turned away)", .{refused}) catch "9p: too many connections");
+ }
if (!core.fs.news) return;
core.fs.news = false;
for (&core.fs.opens) |*o| {
@@ -816,7 +841,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
l.runner.init(.{
.io = io,
.root = pardes.ctlfs.root,
- .handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened },
+ .handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened, .refused = Listener.onRefused },
.greet_timeout_ms = Listener.greet_deadline_ms,
});
const entry_name = if (named.len != 0) named else fallback;
@@ -1278,7 +1303,8 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r
var directory: [64:0]u8 = undefined;
_ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-tcp-XXXXXX", .{}, 0);
if (mkdtemp(&directory) == null) return error.TempDirectoryFailed;
- defer _ = rmdir(&directory);
+ // What the listener posted and left inside goes with it.
+ defer std.Io.Dir.cwd().deleteTree(testing.io, std.mem.sliceTo(&directory, 0)) catch |err| std.debug.print("deleteTree: {s}\n", .{@errorName(err)});
const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null;
defer {
if (old_runtime) |v| {
@@ -1348,7 +1374,7 @@ test "Unix TCP and QUIC share one listener through reads writes reconnects and r
}
try testing.expect(l.path().len != 0);
try testing.expectEqual(tcp, l.tcp_address != null);
- try testing.expectEqual(@as(usize, 4), l.runner.conns.len);
+ try testing.expectEqual(@as(usize, max_conns), l.runner.conns.len);
try testing.expect(l.watcher == null);
const port = (if (tcp) l.tcp_address else l.quic_address).?.getPort();
try testing.expect(port != 0);
@@ -1389,7 +1415,8 @@ test "a change waits while the editor is out mid-step, a read does not, and the
var directory: [64:0]u8 = undefined;
_ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-park-XXXXXX", .{}, 0);
if (mkdtemp(&directory) == null) return error.TempDirectoryFailed;
- defer _ = rmdir(&directory);
+ // What the listener posted and left inside goes with it.
+ defer std.Io.Dir.cwd().deleteTree(testing.io, std.mem.sliceTo(&directory, 0)) catch |err| std.debug.print("deleteTree: {s}\n", .{@errorName(err)});
const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null;
defer {
if (old_runtime) |v| {
@@ -1458,7 +1485,8 @@ test "a held read is answered when the log has news, and a flushed one spends no
var directory: [64:0]u8 = undefined;
_ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-held-XXXXXX", .{}, 0);
if (mkdtemp(&directory) == null) return error.TempDirectoryFailed;
- defer _ = rmdir(&directory);
+ // What the listener posted and left inside goes with it.
+ defer std.Io.Dir.cwd().deleteTree(testing.io, std.mem.sliceTo(&directory, 0)) catch |err| std.debug.print("deleteTree: {s}\n", .{@errorName(err)});
const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null;
defer {
if (old_runtime) |v| {
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index f8451baf..142287b2 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,24 @@
## 0.0.3
+- A column can be empty, as in acme. `Newcol` makes an empty column and
+ gives its tag the keyboard; closing a column's last pane, or dragging it
+ away, leaves the column where it was with nothing in it, drawn as its tag
+ over blank space. The `+New` placeholder that used to stand in an emptied
+ column, and the machinery that swapped it out when something else arrived,
+ are gone. Closing the session's last pane now quits.
+- New panes go where acme's makenewwindow puts them: into the active column
+ (the one last typed or clicked in), filling it when it is empty, else under
+ the text of a pane with room to spare, else halving the biggest pane; never
+ into a new column. `Placement pardes` brings back the old rules, which open a
+ first document in a column of its own.
+- A document dragged into the left column no longer closes the untouched
+ boot shell there; `BootShell replace` brings that back.
+- Column tags are always shown: the `ColumnTags` setting is gone, and an
+ init file's `ColumnTags` line is ignored with a message.
+- Carrying a pane or a column by its grip shows acme's box cursor in the SDL
+ shell and a move cursor on the web.
+
- `Del` takes a side. `Del k` and `DelAbove` give the closed pane's rows to
the expanded pane above it, `Del j` and `DelBelow` to the one below. A bare
`Del` from the keyboard on a pane with panes both above and below asks on
diff --git a/src/File.zig b/src/File.zig
index b8368482..929e33bf 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -53,6 +53,10 @@ pub const State = struct {
/// Revision last known to match disk, after Save or an external reload.
/// Equal means the screen matches disk.
saved_revision: u32 = 0,
+ /// The text at `saved_revision`, hashed as the first edit after it
+ /// replaces it: an edit that brings the text back to it is clean again,
+ /// as an undo to the save point is in acme.
+ saved_hash: ?u64 = null,
watch_after_save: bool = false,
/// Seconds of the last content change, for the filesystem's stat; zero until edited.
mtime: u32 = 0,
@@ -859,9 +863,14 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
if (f.mini) |*mini| mini.deinit(p.gpa);
f.mini = null;
const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{};
+ // ponytail: a whole-text hash per edit; a rolling hash when files grow large.
+ if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content);
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
+ if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) {
+ f.saved_revision = f.revision;
+ };
f.mtime = pardes.ctlfs.events.now();
if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
f.line_starts = starts;
diff --git a/src/Messages.zig b/src/Messages.zig
index 800218b7..8b6c795d 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -410,7 +410,7 @@ pub fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 {
fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
if (text.len == 0) return;
- const serial: u32 = if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
+ const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
const kept = text[0..@min(text.len, LoggedMessage.cap)];
// /log hears every one: a client that retried and failed the same way
// is waiting on that second line. Only the +Messages view collapses.
diff --git a/src/Mini.zig b/src/Mini.zig
index e118e738..ac9a8e85 100644
--- a/src/Mini.zig
+++ b/src/Mini.zig
@@ -188,7 +188,7 @@ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void {
.syntax_dirty = false,
};
pane.body.cur_pinned = true;
- exec.placeDoc(p, id, free, pane);
+ exec.placeNew(p, id, id, free, .doc);
p.active = free;
}
diff --git a/src/Output.zig b/src/Output.zig
index 201b5491..2f462fd1 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -479,7 +479,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const free = p.freeSlot() orelse return error.NoPaneSlots;
const np = try open(p, free, dir, from, arg, result_content);
np.file.?.location_rows = result_rows;
- exec.placeDoc(p, id, free, np);
+ exec.placeNew(p, id, id, free, .doc);
p.active = id;
if (traits(from).steps) {
pane.search_pane = free;
@@ -558,8 +558,8 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
const pane = p.panes[id] orelse return error.MissingPane;
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
- const np = try open(p, free, dir, from, "", content);
- exec.placeDoc(p, id, free, np);
+ _ = try open(p, free, dir, from, "", content);
+ exec.placeNew(p, id, id, free, .doc);
p.active = id;
pane.search_pane = free;
pane.search_row = null;
@@ -777,7 +777,7 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
}
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
- const np = try open(p, free, dir, from, arg, content);
- exec.placeDoc(p, id, free, np);
+ _ = try open(p, free, dir, from, arg, content);
+ exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc);
p.active = free;
}
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 91c23305..76be29d0 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -659,6 +659,36 @@ test "replay restores terminal cells from the retained stream" {
try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
}
+test "a restored command pane comes back finished, its command not run again" {
+ if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const cmd = p.freeSlot().?;
+ _ = try p.newCommand(cmd, "/tmp", "make test");
+ feedOutput(p, p.panes[cmd].?, "ok\r\n");
+ p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 2 } });
+ try dump.dumpState(p);
+ const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }, p.dump_out.?);
+ defer restored.deinit();
+ var spawns: usize = 0;
+ while (restored.nextEffect()) |effect| switch (effect) {
+ .spawn => spawns += 1,
+ else => {},
+ };
+ try std.testing.expectEqual(@as(usize, 1), spawns); // the shell, not the command
+ const again = for (restored.panes) |slot| {
+ if (slot) |pane| if (pane.command != null) break pane;
+ } else return error.CommandPaneLost;
+ try std.testing.expectEqualStrings("make test", again.command.?);
+ try std.testing.expect(again.command_done);
+ try std.testing.expectEqual(@as(?u8, 2), again.command_status);
+ const text = try screenTextAlloc(again, gpa);
+ defer gpa.free(text);
+ try std.testing.expect(std.mem.indexOf(u8, text, "ok") != null);
+}
+
test "a restored terminal keeps its history above a marker and gets a shell where it was" {
if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -668,6 +698,7 @@ test "a restored terminal keeps its history above a marker and gets a shell wher
const pane = p.panes[0].?;
feedOutput(p, pane, "old output\r\n$ ls\r\n");
try pane.setOwnedCwd("/tmp");
+ pane.shell = try gpa.dupe(u8, "bash"); // `Tty bash`: it comes back as bash
try dump.dumpState(p);
const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
defer restored.deinit();
@@ -678,6 +709,8 @@ test "a restored terminal keeps its history above a marker and gets a shell wher
};
try std.testing.expect(spawned);
const again = restored.panes[0].?;
+ try std.testing.expectEqualStrings("bash", again.shell.?);
+ try std.testing.expect(again.command == null);
try std.testing.expectEqual(.spawn, again.pending_command.wait);
const text = try screenTextAlloc(again, gpa);
defer gpa.free(text);
@@ -961,6 +994,15 @@ pub const CommandOutput = struct {
/// What the newest finished command printed: the screen text between its
/// C and D marks, read from at most `max_rows` rows above its end, so a
/// command that printed the whole history costs no more than its tail.
+/// The shell exited in the middle of a command (its `exit 3`): no end mark
+/// will come, so the command's output ends where the cursor is.
+pub fn endOutputHere(pane: *Pane) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ const marks = &state.stream.handler;
+ if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true);
+}
+
pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
// Unless there is text to read, whether the nothing it printed is the
// whole of it.
@@ -1427,6 +1469,9 @@ pub fn dumpPane(
.collapsed = pane.collapsed,
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .shell = try arena.dupe(u8, pane.shell orelse ""),
+ .command = try arena.dupe(u8, pane.command orelse ""),
+ .status = pane.command_status,
.stream = try arena.dupe(u8, text),
.stream_b64 = &.{},
.cursor = .{ .col = 0, .row = 0 },
@@ -1476,6 +1521,9 @@ pub fn dumpPane(
.collapsed = pane.collapsed,
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .shell = try arena.dupe(u8, pane.shell orelse ""),
+ .command = try arena.dupe(u8, pane.command orelse ""),
+ .status = pane.command_status,
.stream = stream[0..len],
.stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
.stream_is_screen = true,
@@ -2111,6 +2159,8 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = p.panes[0].?;
+ pane.tag_expanded = false; // a one-row tag keeps this small screen's rows
+ p.sync();
try testing.expect(pane.tty_filter);
pane.tty_filter = false;
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 875f2e36..6e1fe8f1 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -72,7 +72,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
hi = @min(hi, line.len);
if (lo >= hi) continue;
var col = panes.File.rawDisplayCol(line, lo);
- const end = panes.File.rawDisplayCol(line, hi);
+ const end = if (row.to_edge and hi == line.len) width else panes.File.rawDisplayCol(line, hi);
while (col < end and col < width) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
@@ -103,7 +103,7 @@ fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []c
const hi = @min(end, row.hi);
if (lo >= hi) continue;
var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start)));
- const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start)));
+ const last = if (row.to_edge and hi == end) @as(i32, width) else prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start)));
while (col < last and col < width) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
@@ -130,13 +130,12 @@ pub fn paintPointerSelection(
fg: ?[3]u8,
) void {
if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, s, pane, r, rows, bg, fg);
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0 = @max(0, @min(sl.c0, sl.c1));
- const c1 = @max(0, @max(sl.c0, sl.c1));
var row: u16 = 0;
while (row < r.h) : (row += 1) {
- if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) continue;
+ if (row < from_row) continue;
+ const cols = edit.sweepCols(sl, row) orelse continue;
+ const c0: i32 = if (cols.first) cols.lo else 0;
+ const c1: i32 = cols.hi orelse std.math.maxInt(i16);
const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows;
// File line numbers occupy a gutter only in the body. The tag is
// row zero in Sel space and starts at its real first text cell.
diff --git a/src/builtins.zig b/src/builtins.zig
index c25311e3..657f49d0 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -171,7 +171,7 @@ pub const registry = struct {
inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
if (@intFromEnum(b) == i) return switch (setting.action) {
// a switch flips bare, and DumpDir bare is the default
- .toggle, .transition, .scene, .dump_dir, .lift => false,
+ .toggle, .transition, .scene, .dump_dir, .choice, .lift => false,
.shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true,
};
unreachable;
@@ -242,6 +242,28 @@ test "Exit asks once about unsaved text, and quits when asked again" {
try std.testing.expect(p.quit);
}
+test "Exit over a ctl write names every pane it asks about, and the next Exit discards them all" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const first = try p.setTestFile("saved\n");
+ first.file.?.saved_revision = first.file.?.revision -% 1;
+ p.newScratchBelow(p.active);
+ const second = p.panes[p.active].?;
+ panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120));
+ try std.testing.expect(second.file.?.revision != second.file.?.saved_revision);
+ p.fs.no_prompt = true;
+ defer p.fs.no_prompt = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ const said = p.fs.failure[0..p.fs.failure_len];
+ try std.testing.expect(std.mem.indexOf(u8, said, first.file.?.path) != null);
+ try std.testing.expect(std.mem.indexOf(u8, said, second.file.?.path) != null);
+ try std.testing.expect(std.mem.endsWith(u8, said, ": Modified (Exit again to discard them all)"));
+ p.fs.failure_len = 0;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(p.quit);
+}
+
/// A dump file that exists, for a Restore to get past reading it.
fn testDump(buf: []u8) ![]const u8 {
var tmp = std.testing.tmpDir(.{});
@@ -421,6 +443,10 @@ pub const Exec = struct {
/// worth the question, as winclean does not ask for one.
fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
var modified = false;
+ // Every pane asked about is named in the one refusal a ctl write gets,
+ // as acme's rowclean names them all (wind.c:511).
+ var all_buf: [256]u8 = undefined;
+ var all = std.Io.Writer.fixed(&all_buf);
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
const f = pane.file orelse continue;
@@ -430,8 +456,15 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
pane.discard_warned = .{ .revision = f.revision, .by = asking };
var buf: [limits.host_path_cap + 48]u8 = undefined;
c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, @tagName(asking) }) catch f.path);
+ all.print("{s}{s}", .{ if (modified) ", " else "", f.path }) catch {};
modified = true;
}
+ if (modified and c.p.fs.no_prompt) {
+ all.print(": Modified ({s} again to discard them all)", .{@tagName(asking)}) catch {};
+ const said = @import("Messages.zig").clip(all.buffered(), c.p.fs.failure.len);
+ @memcpy(c.p.fs.failure[0..said.len], said);
+ c.p.fs.failure_len = @intCast(said.len);
+ }
return modified;
}
@@ -787,11 +820,11 @@ pub const New = struct {
}
};
-/// The same empty scratch, opened in a fresh column beside the calling pane.
+/// An empty column right of the keyboard's, its tag taking the keyboard.
pub const Newcol = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
- c.p.newScratchColumn(c.id);
+ c.p.newColumn(c.id);
}
};
@@ -823,6 +856,8 @@ pub const Del = struct {
{
c.pane.prompt = .del_side;
c.p.active = c.id;
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(c.p, "ask", std.fmt.bufPrint(&rec, "{d} del k j", .{c.pane.serial}) catch "");
// The question says Del already.
if (c.pane.msg_announcement) c.pane.msg_len = 0;
return;
@@ -852,6 +887,51 @@ pub const Togglettymode = struct {
}
};
+/// acme's Edit (exec.c `edit`, edit.c): sam's command language over the
+/// pane's body, from a tag, a pane ctl or exec. One undo step; a failure
+/// changes nothing and says why. src/sam_edit.zig has what is left out.
+pub const Edit = struct {
+ pub const takes_arg = true;
+ pub const requires_arg = true;
+ pub fn run(c: Ctx) void {
+ const sam = @import("sam_edit.zig");
+ const ninep_pane = @import("ninep/pane.zig");
+ const f = ninep_pane.fileOf(c.pane) orelse return c.p.reportFailure(c.id, "Edit: only a text pane is edited");
+ const arena = c.p.scratch.allocator();
+ var why: sam.Why = .{};
+ var said: [260]u8 = undefined;
+ const res = sam.run(arena, f.content, ninep_pane.dotOf(c.pane), f.path, c.arg orelse "", &why) catch |err| switch (err) {
+ error.Edit => return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Edit: {s}", .{why.text()}) catch "Edit: failed"),
+ error.OutOfMemory => return c.p.reportError(c.id, "Edit", err),
+ };
+ if (res.undo != 0) {
+ for (0..@abs(res.undo)) |_| if (res.undo > 0) panes.File.undo(c.p, c.pane) else panes.File.redo(c.p, c.pane);
+ return;
+ }
+ if (res.ops.len > 0) {
+ const new = sam.apply(c.p.gpa, f.content, res.ops) catch |err| return c.p.reportError(c.id, "Edit", err);
+ panes.File.pushUndo(c.p, c.pane);
+ panes.File.setContent(c.p, f, new);
+ }
+ ninep_pane.setDot(c.pane, sam.moveDot(res.dot, res.ops));
+ if (res.printed.len > 0) _ = ninep_pane.appendErrors(c.p, c.id, res.printed);
+ }
+};
+
+/// acme's Undo and Redo (exec.c `undo`): step the pane's body back or
+/// forward through its edits, from a tag, a pane ctl or exec.
+pub const Undo = struct {
+ pub fn run(c: Ctx) void {
+ edit.doUndo(c.p, &c.pane.body);
+ }
+};
+
+pub const Redo = struct {
+ pub fn run(c: Ctx) void {
+ edit.doRedo(c.p, &c.pane.body);
+ }
+};
+
/// Fold a pane to its tag, or restore its share of the column.
pub const Collapse = struct {
pub fn run(c: Ctx) void {
@@ -861,7 +941,10 @@ pub const Collapse = struct {
pub const Delcol = struct {
pub fn run(c: Ctx) void {
- c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err);
+ // The column of the tag it came from, else the pane's: a pane ctl's
+ // Delcol closes that pane's column wherever the keyboard is.
+ const col = c.p.exec_column orelse (layout.findPane(c.p, c.id) orelse return).col;
+ c.p.removeColumn(col) catch |err| c.p.reportError(c.id, "close column", err);
}
};
@@ -973,9 +1056,9 @@ pub const Joincol = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
// The keyboard's column joins the one to its right; the last has none.
- const at = layout.findPane(c.p, c.p.active) orelse return;
- if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
- layout.joinCol(c.p);
+ const col = c.p.keyboardColumn(c.p.active) orelse return;
+ if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
+ layout.joinCol(c.p, col);
}
};
@@ -983,8 +1066,8 @@ pub const Tutor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
const free = c.p.freeSlot() orelse return;
- const nt = c.p.openTutorView(free) catch return;
- exec.placeDoc(c.p, c.id, free, nt); // a doc like any other
+ _ = c.p.openTutorView(free) catch return;
+ exec.placeNew(c.p, c.id, c.id, free, .doc); // a doc like any other
}
};
@@ -1097,8 +1180,10 @@ pub const Find = struct {
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .find, .top) catch |err|
- c.p.reportError(c.id, "find", err);
+ if (pat.len > 0) {
+ _ = look.runSearch(c.p, c.id, pat, .find, .top, true) catch |err| c.p.reportError(c.id, "find", err);
+ return;
+ }
c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.find_marker });
}
};
@@ -1111,8 +1196,10 @@ pub const Grep = struct {
pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true };
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
- if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .grep, .top) catch |err|
- c.p.reportError(c.id, "grep", err);
+ if (pat.len > 0) {
+ _ = look.runSearch(c.p, c.id, pat, .grep, .top, true) catch |err| c.p.reportError(c.id, "grep", err);
+ return;
+ }
c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.grep_marker });
}
};
@@ -1141,8 +1228,10 @@ pub const Down = struct {
return tagline.enterHeader(c.p, if (column) |at| at.col else 0);
// Onto the top pane of the column the header stands over.
const col = c.p.header_column orelse if (column) |at| at.col else return;
+ // An empty column's tag has nothing below it.
+ if (col < c.p.ncol and c.p.col_n[col] == 0) return;
tagline.exitHeader(c.p);
- if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0];
+ if (col < c.p.ncol) c.p.active = c.p.col_panes[col][0];
return;
}
_ = layout.focusDir(c.p, c.id, .down);
diff --git a/src/colors.zig b/src/colors.zig
index 25aa838f..224591d3 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -54,6 +54,8 @@ pub const Theme = struct {
column_box: ?[3]u8 = null,
column_box_dim: ?[3]u8 = null,
border: ?[3]u8 = null,
+ /// What an empty column shows under its tag; the border fill without it.
+ empty_col: ?[3]u8 = null,
search_bg: ?[3]u8 = null,
search_fg: ?[3]u8 = null,
diagnostic_error: ?[3]u8 = null,
@@ -169,6 +171,7 @@ pub const ChromeTheme = struct {
tag_name_fg: [3]u8,
tag_active_name_fg: [3]u8,
border: [3]u8,
+ empty_col: [3]u8,
search_bg: [3]u8,
search_fg: [3]u8,
diagnostic_error: [3]u8,
@@ -192,6 +195,7 @@ pub const ChromeTheme = struct {
.tag_name_fg = th.tag_name_fg orelse th.tag_fg,
.tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)),
.border = th.border orelse th.scroll_track,
+ .empty_col = th.empty_col orelse (th.border orelse th.scroll_track),
.search_bg = th.search_bg orelse th.sel_bg,
.search_fg = th.search_fg orelse th.sel_fg,
.diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg),
diff --git a/src/config.zig b/src/config.zig
index ce051be0..24c48770 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -49,6 +49,9 @@ pub const leader_path = paths: {
.Mode = null,
.Togglettymode = null,
.Collapse = null,
+ .Undo = null,
+ .Edit = null,
+ .Redo = null,
.Repl = null,
.Kill = null,
.Exit = null,
@@ -70,11 +73,12 @@ pub const leader_path = paths: {
.Newcol = "cn",
.Delcol = "cd",
.Joincol = "cj",
+ .Placement = "cp",
+ .BootShell = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
.FocusTint = null,
- .ColumnTags = null,
.SyntaxBold = null,
.Verbose = null,
.TreeContextTagStyle = null,
@@ -617,7 +621,6 @@ pub const Runtime = struct {
/// menu bar) turn it off and carry its commands there instead; the core
/// keeps the row as the acme tradition everywhere else.
workspace_tag: bool = true,
- column_tags: bool = true,
syntax_bold: bool = false,
/// A font's programming ligatures (`->`, `!=`) draw across their cells.
/// Only a shell that shapes text has the setting (`Capabilities.ligatures`).
@@ -683,8 +686,19 @@ pub const Runtime = struct {
/// never, while the window has the focus, or always (ghostty's
/// custom-shader-animation).
shader_animation: ShaderAnimation = .on,
+ /// Where a new pane goes (exec.placeNew): `acme`, acme's makenewwindow,
+ /// or `pardes`, the rules pardes had before it.
+ placement: Placement = .acme,
+ /// A document dragged into the left column where a lone shell nobody
+ /// has typed into stands: `keep` leaves the shell, `replace` closes it
+ /// (exec.evictLonePristineTty).
+ boot_shell: BootShell = .keep,
pub const ShaderAnimation = enum { off, on, always };
+ pub const Placement = enum { acme, pardes };
+ pub const BootShell = enum { keep, replace };
+ /// A setting that is one of a few words, each an enum field of Runtime.
+ pub const Choice = enum { placement, boot_shell };
/// shadow: a soft drop shadow. rim: a light hairline along the top of
/// the focused pane's tag rows. auto: a shadow on a light page; on a
@@ -849,7 +863,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures };
/// The bundled post pass, a Shadertoy file in shaders/post/.
pub const Scene = enum { crt };
@@ -876,6 +890,7 @@ pub const Runtime = struct {
/// it of files.
shader,
shader_animation,
+ choice: Choice,
};
pub const Setting = struct {
@@ -899,7 +914,6 @@ pub const Runtime = struct {
.{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
.{ .word = "Debug", .action = .{ .toggle = .debug } },
.{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } },
- .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } },
.{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } },
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
@@ -932,6 +946,8 @@ pub const Runtime = struct {
.{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
.{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
.{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
+ .{ .word = "Placement", .action = .{ .choice = .placement } },
+ .{ .word = "BootShell", .action = .{ .choice = .boot_shell } },
};
pub fn find(name: []const u8) ?Setting {
@@ -1092,6 +1108,18 @@ pub const Runtime = struct {
const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false;
},
+ // Bare, it steps to the next word, as a tag word should.
+ .choice => |which| switch (which) {
+ inline else => |field| {
+ const now = &@field(state, @tagName(field));
+ const T = @TypeOf(now.*);
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ now.* = if (text.len == 0)
+ @enumFromInt((@as(usize, @intFromEnum(now.*)) + 1) % std.enums.values(T).len)
+ else
+ std.meta.stringToEnum(T, text) orelse return false;
+ },
+ },
.lift => {
const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
state.lift = if (text.len == 0)
@@ -1286,6 +1314,9 @@ pub const Runtime = struct {
try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) })
else
try out.print("{s} unsupported\n", .{setting.word}),
+ .choice => |which| switch (which) {
+ inline else => |field| try out.print("{s} {s}\n", .{ setting.word, @tagName(@field(state, @tagName(field))) }),
+ },
.lift => if (setting.enabled(context.capabilities))
try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) })
else
@@ -1560,6 +1591,10 @@ pub const Runtime = struct {
.dump_dir => "~/dumps",
.shader => "~/crt.glsl",
.shader_animation => "always",
+ .choice => |which| switch (which) {
+ .placement => "pardes",
+ .boot_shell => "replace",
+ },
.lift => "rim",
.motion => "playful",
.inactive_dim => "35",
@@ -1579,7 +1614,6 @@ pub const Runtime = struct {
.tag_bottom => "Tagbottom on\n",
.debug => "Debug on\n",
.focus_tint => "FocusTint off\n",
- .column_tags => "ColumnTags off\n",
.syntax_bold => "SyntaxBold on\n",
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
@@ -1614,6 +1648,10 @@ pub const Runtime = struct {
},
.shader => "Shader ~/crt.glsl\n",
.shader_animation => "ShaderAnimation always\n",
+ .choice => |which| switch (which) {
+ .placement => "Placement pardes\n",
+ .boot_shell => "BootShell replace\n",
+ },
.lift => "Lift rim\n",
.motion => "Motion playful\n",
.inactive_dim => "InactiveDim 35\n",
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 5374bf73..857591d4 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -533,7 +533,7 @@ pub const Session = struct {
const pt = s.ptys[pane];
// A command whose exit is recorded is reaped: its group may be another's.
if (pt.fd < 0 or pt.cmd.exited) return;
- host_io.signalTty(pt.pid, pt.fd, sig);
+ host_io.signalTty(pt.pid, pt.fd, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -555,7 +555,7 @@ pub const Session = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
.text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
@@ -706,16 +706,14 @@ pub const Session = struct {
// it would hang up one that runs on without it.
// Its exit, if it came first, is told now its output is in.
if (pt.cmd.watched) return host_io.commandEof(s.core, &s.ptys, pane, s, closeWatched);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end, and a
+ // shell's, for a run waiting on it and the log.
const unwatched = if (s.core.panes[pane]) |pn| pn.command != null else false;
- var status: ?u8 = null;
- if (unwatched) {
- status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- }
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
s.closePty(pane);
s.harvest();
- if (unwatched) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = pane } });
}
diff --git a/src/draw.zig b/src/draw.zig
index 9df76cfd..963d61c1 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -366,6 +366,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
// the pointer per-text (the macOS I-beam) read this as "target" and
// afford it the arrow instead.
if (p.look_hover_preview != null) s.pointer_shape = .target;
+ if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box;
for (&s.body_layers) |*layer| layer.rows = 0;
for (&s.tag_layers) |*layer| layer.rows = 0;
s.nimages = 0;
@@ -401,6 +402,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
// Separators have their own role; a scrollbar need not frame the page.
const whole = s.regions[0].rect;
s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } });
+ for (0..p.ncol) |c| if (p.col_n[c] == 0) s.fill(p.col_x[c], p.topBarHeight() + p.columnBarHeight(), p.col_w[c], s.rows -| (p.topBarHeight() + p.columnBarHeight()), .{ .bg = .{ .rgb = chrome.empty_col } });
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -947,9 +949,9 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r
if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity);
const chrome = p.chromeTheme();
const region = s.region(.tag, id).?;
- const tag = try tagline.tagText(p, arena, pane);
+ const tag = (try tagline.shownTag(p, arena, pane)).text;
const rows = pane.tag_rows;
- // As wide as the widest line it shows, and never narrower than its
+ // As wide as the widest row it shows, and never narrower than its
// viewport.
var widest: usize = 0;
var split = std.mem.splitScalar(u8, tag, '\n');
@@ -1093,8 +1095,10 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
// A row per line shown, from `tag_top`: the tag's first line carries
// the prefix, the rest are the tag's own text alone.
const top = pane.tag_top;
+ // Its lines wrapped at its width (tagline.shownTag): a row per row shown.
+ const shown = try tagline.shownTag(p, arena, pane);
var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
- var split = std.mem.splitScalar(u8, whole, '\n');
+ var split = std.mem.splitScalar(u8, shown.text, '\n');
for (0..top) |_| _ = split.next();
for (lines[0..rows]) |*line| line.* = split.next() orelse "";
// The keyboard's caret, as a row and a byte offset in that row's
@@ -1103,42 +1107,45 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
const Caret = struct { row: usize, at: usize };
const caret: ?Caret = if (!active)
null
- else if (pane.prompt == .name)
- .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
- else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
- const row: usize = @intCast(pane.tag.cur_row - top);
- break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
+ else if (pane.prompt == .name) caret: {
+ // A drafted name is the prefix: its caret is on row 0 of the tag,
+ // the rows it wrapped onto included.
+ const at = modal.positionAt(shown.text, shown.fromReal(@intCast(@max(0, pane.input.cur_col))));
+ if (at.row < top or at.row >= top + rows) break :caret null;
+ break :caret .{ .row = at.row - top, .at = @min(lines[at.row - top].len, at.col) };
+ }
+ else if (pane.focus == .tag and pane.prompt == .none) caret: {
+ const real = modal.offsetAt(whole, .{ .row = @intCast(@max(0, pane.tag.cur_row)), .col = @intCast(@max(0, pane.tag.cur_col)) });
+ const at = modal.positionAt(shown.text, shown.fromReal(real));
+ if (at.row < top or at.row >= top + rows) break :caret null;
+ const row: usize = at.row - top;
+ break :caret .{ .row = row, .at = @min(lines[row].len, at.col) };
} else null;
- if (caret) |at| {
- const line = lines[at.row];
- const col = panes.File.rawDisplayCol(line, at.at);
- var scroll: usize = pane.tag_scroll;
- if (col < scroll) scroll = col;
- if (col >= scroll + capacity) scroll = col - capacity + 1;
- var raw = panes.File.rawAtDisplay(line, scroll);
- scroll = panes.File.rawDisplayCol(line, raw);
- if (col >= scroll + capacity) {
- raw = modal.nextGrapheme(line, raw);
- scroll = panes.File.rawDisplayCol(line, raw);
- }
- // A line past a u16 of columns keeps its caret off the band.
- pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16)));
- } else if (pane.focus != .tag) pane.tag_scroll = 0;
+ // Its rows are wrapped to fit: a pane tag never scrolls sideways.
+ _ = capacity;
+ pane.tag_scroll = 0;
for (lines[0..rows], 0..) |line, row| {
// The scroll is the caret's line's: a shorter row shows nothing.
const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll));
_ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
}
- const tag = lines[0];
- if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
- const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
- const hi = @min(name.hi, tag.len);
- if (lo < hi) {
- var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
- const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ // The name's tint, in the tag's first line, over whichever rows it
+ // wrapped onto.
+ const first_line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
+ if (tagTintRange(pane, first_line, prefix_len)) |name| if (name.lo < name.hi) {
+ const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ const a = modal.positionAt(shown.text, shown.fromReal(name.lo));
+ const b = modal.positionAt(shown.text, shown.fromReal(@min(name.hi, first_line.len)));
+ var row = @max(a.row, top);
+ while (row <= b.row and row < top + rows) : (row += 1) {
+ const line = lines[row - top];
+ const from = if (row == a.row) a.col else 0;
+ const to = if (row == b.row) b.col else line.len;
+ var col = panes.File.rawDisplayCol(line, @min(from, line.len));
+ const end = panes.File.rawDisplayCol(line, @min(to, line.len));
+ const y = tag_y + @as(u16, @intCast(row - top));
while (col < end and col < tw) : (col += 1)
- s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
+ s.at(tx + @as(u16, @intCast(col)), y).style.fg = .{ .rgb = name_fg };
}
};
if (p.look_hover_preview) |preview| {
@@ -1156,8 +1163,8 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
const lo = @min(range.anchor, range.head);
const hi = @max(range.anchor, range.head);
if (modal.nextGrapheme(whole, lo) >= hi) continue;
- const first = modal.positionAt(whole, lo);
- const last = modal.positionAt(whole, hi);
+ const first = modal.positionAt(shown.text, shown.fromReal(lo));
+ const last = modal.positionAt(shown.text, shown.fromReal(hi));
var row = @max(first.row, top);
while (row <= last.row and row < top + rows) : (row += 1) {
const line = lines[row - top];
@@ -1299,14 +1306,14 @@ test "place puts every part where the painters and layers put it, in every layou
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
_ = try p.setTestFile("placed\n" ** 80);
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.newScratchBelow(0);
p.sync();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
for (0..16) |step| {
p.settings.tag_bottom = step & 1 != 0;
- p.settings.column_tags = step & 2 != 0;
if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice"));
if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
@@ -1661,7 +1668,7 @@ test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics nev
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " };
- const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
+ const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
var text: std.ArrayListUnmanaged(u8) = .empty;
defer text.deinit(std.testing.allocator);
for (0..steps) |_| {
diff --git a/src/dump.zig b/src/dump.zig
index 954a3270..ef442ddb 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -99,6 +99,13 @@ pub const PaneKind = enum {
pub const Terminal = struct {
cwd: []const u8 = "",
+ /// The shell it runs when not the configured one (`Tty bash`); empty,
+ /// the configured one, as in dumps from before.
+ shell: []const u8 = "",
+ /// A command pane's line and how it ended (null: unknown, or still
+ /// running when dumped); empty for an interactive terminal.
+ command: []const u8 = "",
+ status: ?u8 = null,
/// The rendered scrollback and screen as plain text, one row per line.
stream: []const u8 = "",
stream_b64: []const u8 = "",
@@ -161,6 +168,9 @@ pub const Pane = struct {
rows: u16 = 0,
vweight: f32 = 1,
collapsed: bool = false,
+ /// Its serial when dumped (0 in older dumps): a Restore logs
+ /// `restored <old> <new>` for it.
+ serial: u32 = 0,
/// Exact editable tail when the user touched it. Null retains the live
/// default; a present empty slice deliberately restores an empty tail.
tag_tail: ?[]const u8 = null,
@@ -217,7 +227,8 @@ pub fn validate(state: State) !void {
}
for (state.columns) |col| {
if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns;
- if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns;
+ // An empty column is a column, as in acme's dump (rows.c:639).
+ if (col.panes.len > max_panes) return error.BadDumpColumns;
for (col.panes) |pane| {
if (pane >= state.panes.len) return error.BadDumpPaneRef;
}
@@ -625,6 +636,7 @@ pub fn dumpState(p: *Pardes) !void {
else
try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll);
dp.tag_tail = pane.tag.own;
+ dp.serial = pane.serial;
dump_panes[panes_len] = dp;
panes_len += 1;
}
@@ -638,14 +650,12 @@ pub fn dumpState(p: *Pardes) !void {
column_ids[c][ids_len] = compact;
ids_len += 1;
};
- if (ids_len > 0) {
- columns[columns_len] = .{
- .tag = p.col_tags[c].own,
- .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
- .panes = column_ids[c][0..ids_len],
- };
- columns_len += 1;
- }
+ columns[columns_len] = .{
+ .tag = p.col_tags[c].own,
+ .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
+ .panes = column_ids[c][0..ids_len],
+ };
+ columns_len += 1;
}
var mounts: [filesystem.max_mounts]Mount = undefined;
@@ -677,16 +687,22 @@ pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes {
var opts = p.opts;
opts.cols = p.screen_w;
opts.rows = p.screen_h;
- const replacement = try initDump(p.gpa, opts, zon_bytes, p);
+ var old: [MAX_PANES]u32 = @splat(0);
+ const replacement = try initDump(p.gpa, opts, zon_bytes, p, &old);
pardes.ctlfs.events.notePath(replacement, "restore", from);
+ // Which serial each pane had, for a script that held the old ones.
+ for (replacement.panes, old) |slot, was| if (slot) |pane| if (was != 0) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(replacement, "restored", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, pane.serial }) catch continue);
+ };
return replacement;
}
pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
- return initDump(gpa, opts, zon_bytes, null);
+ return initDump(gpa, opts, zon_bytes, null, null);
}
-fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes {
+fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes, old_serials: ?*[MAX_PANES]u32) !*Pardes {
const image_gpa = opts.image_allocator orelse gpa;
const pdf_gpa = opts.pdf_allocator orelse gpa;
const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa;
@@ -736,17 +752,26 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
}
for (st.panes, 0..) |src, i| {
if (i >= MAX_PANES) break;
+ if (old_serials) |o| o[i] = src.serial;
const pane: *pardes.panes.Pane = switch (src.kind) {
.terminal => terminal: {
const t = src.terminal.?;
// A host that runs shells gets them back: the old history
// above a new shell in the directory it was in. The web
// shell and an isolated build only ever replay it.
+ // A command pane comes back finished, showing what it said
+ // and how it ended: its command is not run again.
const live = comptime (terminal_panes and platform != .web and !isolated);
- const revive = live and t.cwd.len <= effect_path_cap;
+ const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0;
const restored = try pardes.panes.Terminal.restore(p, src, revive);
p.installPane(i, restored);
try restored.setOwnedCwd(t.cwd);
+ if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell);
+ if (t.command.len > 0) {
+ restored.command = try gpa.dupe(u8, t.command);
+ restored.command_done = true;
+ restored.command_status = t.status;
+ }
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } });
break :terminal restored;
diff --git a/src/edit.zig b/src/edit.zig
index 84bbb292..a194ef48 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -202,7 +202,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
/// (the tag's lines first, the ones it shows)
pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- const tag = try tagline.tagText(p, arena, pane);
+ const tag = (try tagline.shownTag(p, arena, pane)).text;
const from = modal.lineStartOffset(tag, pane.tag_top);
const cut = modal.lineStartOffset(tag, pane.tag_top + pane.tag_rows);
const shown = if (cut < tag.len) tag[from .. cut - 1] else tag[from..];
@@ -234,10 +234,13 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
errdefer p.gpa.free(rows);
const raw_text = if (raw) try p.gpa.dupe(u8, try selectionText(p, pane, gesture)) else null;
const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
- const left = @max(0, @min(gesture.c0, gesture.c1));
- const right = @max(0, @max(gesture.c0, gesture.c1));
for (rows, 0..) |*row, index| {
const visible = first + @as(i32, @intCast(index));
+ // A stream: only the first row starts where the press was; the
+ // others start after the line numbers, and all but the last run on.
+ const cols = sweepCols(gesture, visible + @as(i32, pane.tag_rows)).?;
+ const left: i32 = if (cols.first) cols.lo else prefix;
+ const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2;
var source = pane.wrapAt(visible);
if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 };
const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line);
@@ -250,7 +253,8 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
row.* = .{
.row = source.line,
.lo = lo,
- .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)),
+ .hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)),
+ .to_edge = cols.hi == null,
.raw_terminal = raw,
.prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0,
};
@@ -294,40 +298,55 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 {
}
return result;
}
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
- const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
const text = try paneText(p, pane);
- var total: usize = 0;
- var selected: usize = 0;
- var count_it = std.mem.splitAny(u8, text, "\n");
- var count_row: i32 = 0;
- while (count_it.next()) |line| : (count_row += 1) {
- if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len));
- total += b1 - b0 + @intFromBool(selected > 0);
- selected += 1;
- }
- const out = try arena.alloc(u8, total);
- var at: usize = 0;
+ const shown = try tagline.shownTag(p, arena, pane);
+ var out: std.ArrayList(u8) = .empty;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
var first = true;
while (it.next()) |line| : (v += 1) {
- if (v < r0 or v > r1) continue;
- if (!first) {
- out[at] = '\n';
- at += 1;
- }
+ const cols = sweepCols(sl, v) orelse continue;
+ // A row the tag's wrapping began carries on its text: no newline.
+ const soft = v > 0 and v < pane.tag_rows and std.mem.indexOfScalar(
+ usize,
+ shown.soft,
+ modal.lineStartOffset(shown.text, @as(usize, @intCast(v)) + pane.tag_top) -| 1,
+ ) != null;
+ if (!first and !soft) try out.append(arena, '\n');
first = false;
- const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len));
- @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
- at += b1 - b0;
+ // Past the first row a body row starts after its line numbers.
+ const gutter: i32 = if (pane.file != null and v >= pane.tag_rows) panes.File.gutterWidth(pane) else 0;
+ const lo: usize = @intCast(if (cols.first) cols.lo else gutter);
+ const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, lo), line.len);
+ const b1 = if (cols.hi) |hi|
+ @max(b0, modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, @intCast(hi)), line.len)))
+ else
+ line.len;
+ try out.appendSlice(arena, line[b0..b1]);
}
- return out;
+ return out.items;
+}
+
+/// A sweep's columns on `row`, in its Sel space: acme's stream, not a
+/// rectangle (libframe/frselect.c:115-140, frselectpaint): the first row
+/// from the press to its end, the rows between whole, the last from its
+/// start to the release. `hi` null runs to the end of the row.
+pub const SweepCols = struct { lo: i32, hi: ?i32, first: bool };
+
+pub fn sweepCols(sl: Pane.Sel, row: i32) ?SweepCols {
+ const start, const end = sweepEnds(sl);
+ if (row < start[0] or row > end[0]) return null;
+ return .{
+ .lo = if (row == start[0]) @max(0, start[1]) else 0,
+ .hi = if (row == end[0]) @max(0, end[1]) else null,
+ .first = row == start[0],
+ };
+}
+
+/// The sweep's two ends in reading order, each (row, col).
+pub fn sweepEnds(sl: Pane.Sel) struct { [2]i32, [2]i32 } {
+ const fwd = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1);
+ return if (fwd) .{ .{ sl.r0, sl.c0 }, .{ sl.r1, sl.c1 } } else .{ .{ sl.r1, sl.c1 }, .{ sl.r0, sl.c0 } };
}
pub fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool {
@@ -738,6 +757,19 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
Key.left, Key.right, Key.up, Key.down => {
const lines = paneCursorLines(p, t) catch return;
const cur0 = t.toModalCursor();
+ // In a pane's tag, Up on its first line collapses it and Down
+ // on its last expands it, where they would move nothing; acme's
+ // arrows in a tag always do (plan9port text.c:776, Tagdown/Tagup).
+ if (t.what == .tag) if (t.pane()) |pane| {
+ if (key.cp == Key.up and cur0.row == 0) {
+ pane.tag_expanded = false;
+ return;
+ }
+ if (key.cp == Key.down and cur0.row + 1 >= lines.len) {
+ pane.tag_expanded = true;
+ return;
+ }
+ };
const nc = switch (key.cp) {
Key.left => modal.charLeft(lines, cur0),
Key.right => modal.charRight(lines, cur0),
@@ -2000,7 +2032,8 @@ pub fn pushUndo(p: *Pardes, t: *Text) void {
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
switch (t.what) {
- .body => {},
+ // A B1 chord took its one undo point when it began (mouse.zig).
+ .body => if (p.chord_hold) return,
.tag, .column, .workspace => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null),
// a prompt's answer is typed once and gone; it keeps no history
.input => return,
@@ -2215,3 +2248,120 @@ test "only the SPC clipboard commands cross to the system clipboard" {
p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expect(p.clip_pending == null);
}
+
+/// acme's double-click (plan9port acme text.c:1407, textdoubleclick): from
+/// the point `q` (before the character clicked), the text up to the match
+/// of a bracket just before it or just after it (text.c:1386, `{[(<«` and
+/// their closers), the whole line at a line's start or end, the quoted text
+/// next to `' " \``, else the word, acme's isalnum (util.c:328) run.
+pub fn doubleClickRange(text: []const u8, q_in: usize) [2]usize {
+ const sets = [_][2][]const u21{
+ .{ &.{ '{', '[', '(', '<', 0xab }, &.{ '}', ']', ')', '>', 0xbb } },
+ .{ &.{'\n'}, &.{'\n'} },
+ .{ &.{ '\'', '"', '`' }, &.{ '\'', '"', '`' } },
+ };
+ const q = @min(q_in, text.len);
+ for (sets) |set| {
+ const left, const right = set;
+ // the character to the left, looking right
+ const before = if (q == 0) .{ @as(u21, '\n'), @as(usize, 0) } else runeBefore(text, q);
+ if (std.mem.indexOfScalar(u21, left, before[0])) |k| {
+ var at = q;
+ if (clickMatch(text, before[0], right[k], true, &at)) {
+ const closer = if (before[0] == '\n') 0 else runeLen(right[k]);
+ return .{ q, at - closer };
+ }
+ return .{ q, q };
+ }
+ // the character to the right, looking left
+ const after = if (q == text.len) @as(u21, '\n') else runeAt(text, q);
+ if (std.mem.indexOfScalar(u21, right, after)) |k| {
+ var at = q;
+ if (clickMatch(text, after, left[k], false, &at)) {
+ const q1 = q + @as(usize, @intFromBool(q < text.len and after == '\n'));
+ var q0 = at;
+ if (after != '\n' or at != 0 or (text.len > 0 and text[0] == '\n')) q0 += runeLen(left[k]);
+ return .{ @min(q0, q1), q1 };
+ }
+ return .{ q, q };
+ }
+ }
+ var lo = q;
+ var hi = q;
+ while (hi < text.len and isAlnum(runeAt(text, hi))) hi += std.unicode.utf8ByteSequenceLength(text[hi]) catch 1;
+ while (lo > 0 and isAlnum(runeBefore(text, lo)[0])) lo = runeBefore(text, lo)[1];
+ return .{ lo, hi };
+}
+
+/// acme's textclickmatch (text.c:1457): the nesting match of `cl` by `cr`
+/// from `q`, leaving `q` just past it (forward) or on it (back).
+fn clickMatch(text: []const u8, cl: u21, cr: u21, forward: bool, q: *usize) bool {
+ var nest: usize = 1;
+ while (true) {
+ var c: u21 = undefined;
+ if (forward) {
+ if (q.* >= text.len) break;
+ c = runeAt(text, q.*);
+ q.* += std.unicode.utf8ByteSequenceLength(text[q.*]) catch 1;
+ } else {
+ if (q.* == 0) break;
+ const b = runeBefore(text, q.*);
+ q.* = b[1];
+ c = b[0];
+ }
+ if (c == cr) {
+ nest -= 1;
+ if (nest == 0) return true;
+ } else if (c == cl) nest += 1;
+ }
+ return cl == '\n' and nest == 1;
+}
+
+fn runeLen(c: u21) usize {
+ return std.unicode.utf8CodepointSequenceLength(c) catch 1;
+}
+
+fn runeAt(text: []const u8, at: usize) u21 {
+ const n = std.unicode.utf8ByteSequenceLength(text[at]) catch return text[at];
+ if (at + n > text.len) return text[at];
+ return std.unicode.utf8Decode(text[at..][0..n]) catch text[at];
+}
+
+/// The rune ending at `at`, and where it starts.
+fn runeBefore(text: []const u8, at: usize) struct { u21, usize } {
+ var s = at - 1;
+ while (s > 0 and text[s] & 0xC0 == 0x80 and at - s < 4) s -= 1;
+ return .{ runeAt(text, s), s };
+}
+
+fn isAlnum(c: u21) bool {
+ if (c <= ' ') return false;
+ if (c >= 0x7F and c <= 0xA0) return false;
+ if (c < 0x80 and std.mem.indexOfScalar(u8, "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~", @intCast(c)) != null) return false;
+ return true;
+}
+
+test "double-click selects as acme's does" {
+ const t = "fn foo_bar(a, b) {\n x\n}\n";
+ const Case = struct { q: usize, want: []const u8 };
+ for ([_]Case{
+ .{ .q = 4, .want = "foo_bar" }, // in a word
+ .{ .q = 11, .want = "a, b" }, // just after (
+ .{ .q = 15, .want = "a, b" }, // just before )
+ .{ .q = 18, .want = "\n x\n" }, // just after {
+ .{ .q = 0, .want = "fn foo_bar(a, b) {\n" }, // a line's start
+ }) |c| {
+ const r = doubleClickRange(t, c.q);
+ try std.testing.expectEqualStrings(c.want, t[r[0]..r[1]]);
+ }
+ const quoted = "say \"hello there\" now";
+ const r = doubleClickRange(quoted, 5);
+ try std.testing.expectEqualStrings("hello there", quoted[r[0]..r[1]]);
+ const french = "«oui»";
+ const f = doubleClickRange(french, 2);
+ try std.testing.expectEqualStrings("oui", french[f[0]..f[1]]);
+ // a line's end: the whole line, its newline included
+ const lines = "one\ntwo\n";
+ const e = doubleClickRange(lines, 7);
+ try std.testing.expectEqualStrings("two\n", lines[e[0]..e[1]]);
+}
diff --git a/src/exec.zig b/src/exec.zig
index ee7c31e9..56e6a2a9 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -322,18 +322,15 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
nt.greet = !v9fs;
nt.v9fs_on_spawn = v9fs;
nt.body.mode = .tty;
- const parent = layout.splitParent(p, from);
- const f = layout.findPane(p, parent).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, parent, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ placeNew(p, from, from, free, .shell);
return nt;
}
-/// when a doc lands in `col`, a lone pristine shell there is clutter — drop
-/// it; absorbVWeight hands its space to the doc
+/// With `BootShell replace`, a doc landing in `col` closes a lone pristine
+/// shell there, the boot's placeholder, and absorbVWeight hands its space to
+/// the doc. With `keep`, the default, the shell stays like any other pane.
pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
+ if (p.settings.boot_shell == .keep) return;
var n_tty: usize = 0;
var tty_id: usize = 0;
for (0..p.col_n[col]) |k| {
@@ -357,29 +354,6 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
if (p.active == tty_id) p.active = keep_id;
}
-/// A pane that just joined a column whose only other pane is stillborn —
-/// an empty scratch nobody typed into, body or tag (what Newcol makes and
-/// what a closed column's last pane leaves behind) — takes the column
-/// whole: the placeholder was only holding the space for it. Run at the
-/// end of a step: the pane that asked for the joiner is often the
-/// placeholder itself, and its caller is still holding it.
-pub fn replaceStillborn(p: *Pardes) void {
- const serial = p.stillborn_joiner orelse return;
- p.stillborn_joiner = null;
- const new_id = p.paneBySerial(serial) orelse return;
- const f = layout.findPane(p, new_id) orelse return;
- if (p.col_n[f.col] != 2) return;
- const old_id = p.col_panes[f.col][1 - f.idx];
- const old = p.panes[old_id] orelse return;
- const file = old.file orelse return;
- const out = file.output orelse return;
- if (out.from != .cmd or out.from.cmd != .New) return;
- if (file.revision != 0 or file.content.len != 0) return;
- if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return;
- layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows
- p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err);
-}
-
// ---- REPLs ----
//
// A terminal is bound as a language's REPL by `Repl python` (builtins.zig).
@@ -461,9 +435,21 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed {
src.question_len = @intCast(w.buffered().len);
src.prompt = .repl_choice;
p.active = from;
+ var rec: [96]u8 = undefined;
+ var rw = std.Io.Writer.fixed(&rec);
+ rw.print("{d} repl", .{src.serial}) catch {};
+ for (bound[0..n]) |i| rw.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {};
+ pardes.ctlfs.events.notePath(p, "ask", rw.buffered());
return .asked;
}
+fn pythonBlockOpen(text: []const u8) bool {
+ const trimmed = std.mem.trimEnd(u8, text, " \t\n");
+ const start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |nl| nl + 1 else 0;
+ const last = trimmed[start..];
+ return last.len > 0 and (last[0] == ' ' or last[0] == '\t' or last[last.len - 1] == ':');
+}
+
/// Types `text` into the REPL in `to`, bracketed if its program asked for
/// that, then Enter; says so on `from`'s message row and in the log.
pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
@@ -491,6 +477,11 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
}
edit.typeToTty(p, to, dst, clean[0..n]);
p.emitWrite(to, "\r");
+ // A Python block whose last line is indented, or a bare header, is not
+ // over at one Enter, pasted into 3.13's REPL or typed into the old one:
+ // the blank line a second Enter gives ends it.
+ if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n]))
+ p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
std.mem.indexOfScalar(u8, text, '\n') != null)
@@ -552,7 +543,7 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi
/// Runs `line` in a command pane for `from`'s directory: `from` itself if
/// it is one whose command is done, else the directory's command pane that
-/// is done, else a new one below the last pane of the rightmost column.
+/// is done, else a new one, placed as placeNew places a command pane.
/// A pane reused keeps what it showed: the new command runs below a line
/// naming it, as acme appends to +Errors and never clears it (util.c:213).
fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
@@ -607,13 +598,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
return null;
};
echoCommand(p, pane, line);
- const rc = if (p.ncol > 0) p.ncol - 1 else 0;
- if (p.col_n[rc] > 0) {
- const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]);
- const f = layout.findPane(p, parent).?;
- layout.splitBelow(p, parent, pane);
- layout.insert(p, f.col, f.idx + 1, free);
- } else layout.insert(p, rc, p.col_n[rc], free);
+ placeNew(p, from, from, free, .command);
noteRun(p, pane, "run", line);
return free;
}
@@ -634,7 +619,7 @@ fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void {
/// The whole line, one record as `msg` carries its text: its control
/// characters, a newline too, become spaces (events.pushLog). A new pane is
/// announced first, so `new` comes before the `run` in it.
-fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
+pub fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
pardes.ctlfs.events.announce(p);
var buf: [command_max + 16]u8 = undefined;
pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, std.mem.trim(u8, said, " \t\r\n") }) catch return);
@@ -691,7 +676,24 @@ pub fn applyStartupConfig(p: *Pardes) void {
p.announce = false;
defer p.announce = true;
var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
- while (lines.next()) |line| _ = executeBuiltinLine(p, p.active, line);
+ while (lines.next()) |line| {
+ if (executeBuiltinLine(p, p.active, line)) continue;
+ // A setting pardes no longer has is said, once, and changes nothing.
+ const word = commandText(line);
+ if (std.mem.eql(u8, word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len], "ColumnTags"))
+ p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored");
+ }
+}
+
+test "an init file's retired ColumnTags line is said and ignored" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.opts.startup_config = "ColumnTags\nWrap off\n";
+ applyStartupConfig(p);
+ try std.testing.expect(!p.settings.wrap);
+ try std.testing.expect(p.columnBarHeight() > 0);
+ const pane = p.panes[p.active].?;
+ try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]);
}
pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
@@ -718,7 +720,6 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
const previous_context_style = p.settings.tree_context_tag_style;
const previous_transition = p.settings.panel_transition;
const previous_tagline_percent = p.settings.font.tagline_percent;
- const previous_column_tags = p.settings.column_tags;
switch (setting.action) {
.theme => {
const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
@@ -768,20 +769,98 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
p.settings.panel_transition != previous_transition;
const tagline_metrics_changed = setting.action == .tagline_size and
p.settings.font.tagline_percent != previous_tagline_percent;
- if (p.settings.column_tags != previous_column_tags) {
- if (p.header_column != null) tagline.exitHeader(p);
- }
// Idle frames keep no frozen grid while transitions are off; the one
// just drawn is the baseline the first transition starts from.
if (transition_changed and previous_transition == .off)
p.presentation.capturePrevious(p.gpa, &p.surface);
- if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) {
+ if (transition_changed or tagline_metrics_changed) {
p.abandonPanelAnimations();
}
}
-pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
- p.stillborn_joiner = nt.serial;
+/// What a new pane is, and who asked: each rule places them differently.
+/// `scratch` is New's, `fs` 9P's pane/new, `errors` a +Errors pane.
+pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
+
+/// Put new pane `id` in the layout: every new pane comes through here, by
+/// the `Placement` setting, and every one but a command pane takes the
+/// keyboard. `from` is the pane that asked, `origin` the text it asked
+/// from: null when a column tag, the workspace tag or 9P asked (acme's
+/// t->w == nil).
+pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void {
+ const asked = if (p.exec_column != null) null else origin;
+ switch (p.settings.placement) {
+ .acme => switch (kind) {
+ // New: the bottom half of the last pane in its own column
+ // (look.c:921-923, et->col).
+ .scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id),
+ // +Errors, a command's output: the last column (util.c:94-98).
+ .command, .errors => layout.acmePlace(p, p.ncol - 1, null, id),
+ // makenewwindow(nil) for 9P's new (acme.c:877), else (t).
+ .fs => layout.acmePlace(p, acmeColumn(p, from), null, id),
+ .shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id),
+ },
+ .pardes => pardesPlace(p, from, id, kind),
+ }
+ if (kind == .command) return;
+ tagline.exitHeader(p);
+ p.active = id;
+ if (layout.findPane(p, id)) |f| p.setActiveColumn(f.col);
+}
+
+/// acme's makenewwindow column (util.c:456-467): the column a command came
+/// from (its tag's), else the active column, else the keyboard's pane's,
+/// else the asking pane's.
+fn acmeColumn(p: *Pardes, from: usize) usize {
+ if (p.exec_column) |c| return c;
+ if (p.activeColumn()) |c| return c;
+ if (layout.findPane(p, p.active)) |f| return f.col;
+ return columnOf(p, from);
+}
+
+fn columnOf(p: *Pardes, id: usize) usize {
+ return if (layout.findPane(p, id)) |f| f.col else p.ncol - 1;
+}
+
+/// The placements pardes had before it took acme's: an empty column whose
+/// tag asked, or has the keyboard, is filled; a scratch goes under the
+/// asking pane, a shell under it or the nearest pane with room, a document
+/// beside the last one read or in a column of its own, and a command pane
+/// at the foot of the last column.
+fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
+ const empty = if (p.exec_column orelse if (p.header_focus) p.header_column else null) |c|
+ (if (c < p.ncol and p.col_n[c] == 0) c else null)
+ else
+ null;
+ if (empty) |c| {
+ layout.insert(p, c, 0, id);
+ p.panes[id].?.vweight = 1;
+ return;
+ }
+ const nt = p.panes[id].?;
+ switch (kind) {
+ .scratch, .fs => if (layout.findPane(p, from)) |sf| {
+ layout.insert(p, sf.col, sf.idx + 1, id);
+ layout.splitBelow(p, from, nt);
+ },
+ .shell => belowParent(p, from, id),
+ .doc, .errors => pardesDoc(p, from, id),
+ .command => {
+ const rc = p.ncol - 1;
+ if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id);
+ },
+ }
+}
+
+fn belowParent(p: *Pardes, from: usize, id: usize) void {
+ const parent = layout.splitParent(p, from);
+ const f = layout.findPane(p, parent).?;
+ layout.insert(p, f.col, f.idx + 1, id);
+ layout.splitBelow(p, parent, p.panes[id].?);
+}
+
+fn pardesDoc(p: *Pardes, from_id: usize, free: usize) void {
+ const nt = p.panes[free].?;
const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc
var src_id: ?usize = null;
if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) {
@@ -808,22 +887,14 @@ pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
if (!doc) if (layout.findPane(p, from_id)) |sf| {
layout.insert(p, sf.col, sf.idx + 1, free);
layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays
- p.active = free;
return;
};
if (src_id == null and doc and p.ncol < MAX_COLS and
layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true))
- {
- p.active = free;
return;
- }
// Without room for a first-document column, split the Look origin
// just like Tty. Never displace (or evict) a top-left bystander.
- const src = layout.splitParent(p, src_id orelse from_id);
- const sf = layout.findPane(p, src).?;
- layout.insert(p, sf.col, sf.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
+ belowParent(p, src_id orelse from_id, free);
}
test "the acme chords act once per selection, not once on the primary" {
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 57ef7b62..0121a831 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -8,15 +8,15 @@ exec write a line: a middle click, an editor command word or a shell line
log events: new/del/rename/save <serial> <name>, run/exit/send <serial> ..., dump/restore <path>, msg, err
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
-focus the serial of the pane with the keyboard; write a serial to give it the keyboard
-ctl settings, one a line; or Newcol, Dump, Exit (quits; asks once), Kill [word] (stops what pardes ran, or what starts with word)
+focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard
+ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
-pane/new open it to make a pane (named +New); the read answers that pane's serial
+pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes
os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only)
-Below, $m is the mount: $NINE_MOUNT/pardes/<pid> when 9ns --mntgen set $NINE_MOUNT; PARDES_MOUNT in a Tty9p shell.
+Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARDES_MOUNT in a Tty9p shell.
cat $m/index which panes exist
n=$(cat $m/pane/new) make a pane, take its serial
@@ -24,7 +24,7 @@ Below, $m is the mount: $NINE_MOUNT/pardes/<pid> when 9ns --mntgen set $NINE_MOU
printf 'text\n' >> $m/pane/$n/body append to a pane (> replaces it all)
echo notes.txt > $m/pane/$n/name; echo Save > $m/pane/$n/ctl rename, then save it
echo 'Msg hello' > $m/exec show text in the editor
- echo '/^fn/' > $m/pane/$n/addr; printf NEW > $m/pane/$n/data replace what addr matched
+ echo 'Edit ,x/foo/c/bar/' > $m/pane/$n/ctl rename every foo, one undo step (sam's commands)
cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
cat $m/pane/$n/event hold it open to own that pane's clicks
@@ -35,11 +35,11 @@ Below, $m is the mount: $NINE_MOUNT/pardes/<pid> when 9ns --mntgen set $NINE_MOU
Pitfalls, one each:
Each open of pane/new makes another pane (ls, stat, find never do); rmdir closes even a dirty one.
- addr takes #n,#m or /regexp/ from the current address (^ $ per line); a failed one refuses data.
+ addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line); > never resets it, 0 does; a miss refuses data.
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
- tag reads the path, then its own text; writes append; truncating clears it, default words too.
+ tag reads the path, then its own text; > replaces that text (default words too), >> appends.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
- lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New.
+ lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits.
Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands.
diff --git a/src/fs.zig b/src/fs.zig
index b2e2751d..0a4473d6 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -598,7 +598,8 @@ test "virtual body writes can read their input from the same pane" {
const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial});
try write(p, path, pane.file.?.content);
try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 2), pane.file.?.history.undo_len);
+ // The truncation and the write are one undo step.
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
const events = &p.panes[p.active].?.fs.events;
try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED"));
events.pop();
@@ -1260,9 +1261,17 @@ pub const Namespace = struct {
/// the first error one reports (`failure`) fails the write, as acme's
/// ctl answers a command's error (editors/acme/xfid.c:700).
no_prompt: bool = false,
+ /// The root ctl is running a session builtin: its messages are the
+ /// session's, logged `msg -`, though shown on the active pane.
+ session_write: bool = false,
refused: bool = false,
failure: [256]u8 = undefined,
failure_len: u16 = 0,
+ /// What went wrong performing a write's effects after it was answered
+ /// by the core (a save the host could not do): the 9P write, which
+ /// waits for them, fails with it (src/9p_io.zig).
+ late_failure: [256]u8 = undefined,
+ late_failure_len: u16 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [320]u8 = undefined,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 9748d4c0..5dfacee9 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{
0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8,
0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00,
};
+/// acme's boxcursor (acme.c:956), while a grip carries a pane or column.
+const p9_box_clr = [32]u8{
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F,
+ 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+const p9_box_set = [32]u8{
+ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00,
+};
+const p9_box_mask = blk: {
+ var m: [32]u8 = undefined;
+ for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl;
+ break :blk m;
+};
const p9_arrow_mask = blk: {
var m: [32]u8 = undefined;
for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl;
@@ -1849,6 +1867,7 @@ const Gui = struct {
pointer_present: bool = false,
arrow_cursor: ?*c.SDL_Cursor = null,
link_cursor: ?*c.SDL_Cursor = null,
+ box_cursor: ?*c.SDL_Cursor = null,
pointer_shape: pardes.Surface.PointerShape = .arrow,
pointer_mapped: bool = false,
pointer_cell: ?MouseCell = null,
@@ -2307,6 +2326,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor);
const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER);
defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor);
+ // Its hot spot the middle, as acme's {-7, -7} offset puts it.
+ const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7);
+ defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor);
if (arrow_cursor) |cur| {
_ = c.SDL_SetCursor(cur);
} else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()});
@@ -2442,6 +2464,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.window = window,
.arrow_cursor = arrow_cursor,
.link_cursor = link_cursor,
+ .box_cursor = box_cursor,
.device = device,
.swapchain_format = swapchain_format,
.pipeline = pipeline,
@@ -3758,9 +3781,10 @@ test "tagline pointer mapping retains physical grips and carries logical text co
try std.testing.expect(grip.tag_hit == null);
try std.testing.expectEqual(r.x + config.GUTTER - 1, grip.col);
- const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y + 1)) + 0.5) * body_h, body_w, body_h, tag_w);
+ const body_y = tag_y + core.panes[core.active].?.tag_rows; // a long tag wraps
+ const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(body_y)) + 0.5) * body_h, body_w, body_h, tag_w);
try std.testing.expectEqual(pixelCell(x, 10), body.col);
- try std.testing.expectEqual(tag_y + 1, body.row);
+ try std.testing.expectEqual(body_y, body.row);
try std.testing.expect(body.tag_hit == null);
const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w);
@@ -4075,15 +4099,13 @@ const Shell = struct {
host_io.commandEof(s.core, s.ptys, e.pane, s, closeWatched);
break :eof;
}
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
const unwatched = if (s.core.panes[e.pane]) |pane| pane.command != null else false;
- var status: ?u8 = null;
- if (unwatched) {
- status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- }
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
s.closePty(e.pane);
- if (unwatched) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = e.pane } });
}
s.saw_event = true;
@@ -4428,7 +4450,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void {
const s = shellOf(ctx);
// A command whose exit is recorded is reaped: its group may be another's.
- if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig);
+ if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -4448,7 +4470,7 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| {
if (std.mem.eql(u8, f.path, path))
if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) {
@@ -5296,7 +5318,11 @@ fn renderFrame(
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
g.pointer_shape = surface.pointer_shape;
- const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor();
+ const cursor = switch (g.pointer_shape) {
+ .link => g.link_cursor,
+ .box => g.box_cursor orelse g.arrow_cursor,
+ else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(),
+ };
if (cursor) |value| _ = c.SDL_SetCursor(value);
}
g.scene_target_failed = false;
@@ -5842,7 +5868,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
var notices = false;
for (0..32) |step| {
core.settings.tag_bottom = step & 1 != 0;
- core.settings.column_tags = step & 2 != 0;
if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse"));
core.leader_on = step & 4 != 0;
if (step & 8 != 0) {
@@ -8300,6 +8325,41 @@ test "the rule between stacked panes stays in the tag band's slack, at any tagli
}
}
+test "an empty column is ruled on both sides, as between two filled ones" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ // Newcol from the left pane: an empty column between the two filled ones.
+ core.newColumn(0);
+ pardes.test_api.sync(core);
+ try std.testing.expectEqual(@as(usize, 3), core.ncol);
+ try std.testing.expectEqual(@as(usize, 0), core.col_n[1]);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const layout = fixedCellLayout(&g);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true);
+ const border = [3]f32{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 };
+ // Its left edge and its right edge (the next column's left): each a
+ // rule `rule_px` wide in the border colour, down to the window's foot.
+ for ([_]usize{ 1, 2 }) |column| {
+ const px: f32 = @floatFromInt(@as(u32, core.col_x[column]) * 10);
+ const rule = for (g.decor.items) |item| {
+ if (@abs(item.x0 - (px / 800 * 2 - 1)) < 1e-4 and @abs(item.x1 - ((px + @as(f32, @floatFromInt(s.chrome.rule_px))) / 800 * 2 - 1)) < 1e-4 and
+ std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &border)) break item;
+ } else return error.TestExpectedColumnRule;
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+}
+
test "decor is read from the regions, each pane's in its own group" {
const gpa = std.testing.allocator;
const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
diff --git a/src/host_io.zig b/src/host_io.zig
index a019e15d..df78f73c 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -637,35 +637,55 @@ pub const Shell = struct {
\\
;
- const rc_path_capacity = 64;
+ const rc_path_capacity = 256;
- // Private files live until host teardown. Lengths keep this value movable.
+ /// The prompt files a shell sources at start. Under $XDG_RUNTIME_DIR
+ /// (private to the user) there is one file per content, named by its
+ /// hash and shared by every pardes, so none is left behind by a pardes
+ /// that was killed: 72K of them piled up in /tmp when each host wrote
+ /// its own. Without that directory each host writes private mkstemp
+ /// files in /tmp and removes them at teardown, as before.
pub const PromptFiles = struct {
bash_path: [rc_path_capacity:0]u8 = @splat(0),
- bash_len: u8 = 0,
+ bash_len: u16 = 0,
+ bash_owned: bool = false,
fish_path: [rc_path_capacity:0]u8 = @splat(0),
- fish_len: u8 = 0,
- fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0),
- fish_command_len: u8 = 0,
+ fish_len: u16 = 0,
+ fish_owned: bool = false,
+ fish_command: [rc_path_capacity + "source ''".len:0]u8 = @splat(0),
+ fish_command_len: u16 = 0,
pub fn init() PromptFiles {
var rcs: PromptFiles = .{};
- rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc);
- rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc);
+ const bash = place(&rcs.bash_path, "bash", bash_rc);
+ rcs.bash_len = bash.len;
+ rcs.bash_owned = bash.owned;
+ const fish = place(&rcs.fish_path, "fish", fish_rc);
+ rcs.fish_len = fish.len;
+ rcs.fish_owned = fish.owned;
if (rcs.fishPath()) |path| {
- const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch {
- _ = libc.unlink(path.ptr);
+ // Quoted for fish; a path a quote would break is not used.
+ const command = if (std.mem.indexOfAny(u8, path, "'\\") != null)
+ error.NoSpaceLeft
+ else
+ std.fmt.bufPrintSentinel(&rcs.fish_command, "source '{s}'", .{path}, 0);
+ const ok = command catch {
+ if (rcs.fish_owned) _ = libc.unlink(path.ptr);
rcs.fish_len = 0;
return rcs;
};
- rcs.fish_command_len = @intCast(command.len);
+ rcs.fish_command_len = @intCast(ok.len);
}
return rcs;
}
pub fn deinit(rcs: *PromptFiles) void {
- if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr);
- if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr);
+ if (rcs.bashPath()) |path| if (rcs.bash_owned) {
+ _ = libc.unlink(path.ptr);
+ };
+ if (rcs.fishPath()) |path| if (rcs.fish_owned) {
+ _ = libc.unlink(path.ptr);
+ };
rcs.bash_len = 0;
rcs.fish_len = 0;
rcs.fish_command_len = 0;
@@ -687,8 +707,34 @@ pub const Shell = struct {
}
};
+ /// The shared file for `contents` under $XDG_RUNTIME_DIR, written
+ /// beside it and renamed into place when missing or different, so a
+ /// shell never reads half of one; failing that, a private /tmp file.
+ fn place(path_buf: *[rc_path_capacity:0]u8, name: []const u8, contents: []const u8) struct { len: u16, owned: bool } {
+ shared: {
+ const dir = std.mem.span(std.c.getenv("XDG_RUNTIME_DIR") orelse break :shared);
+ if (dir.len == 0 or dir[0] != '/') break :shared;
+ const final = std.fmt.bufPrintSentinel(path_buf, "{s}/pardes-osc133-{s}-{x:0>16}", .{ dir, name, std.hash.Wyhash.hash(0, contents) }, 0) catch break :shared;
+ var have: [2048]u8 = undefined;
+ if (readSmall(final, &have)) |got| if (std.mem.eql(u8, got, contents)) return .{ .len = @intCast(final.len), .owned = false };
+ var tmp_buf: [rc_path_capacity:0]u8 = @splat(0);
+ var template_buf: [rc_path_capacity]u8 = undefined;
+ const tmp_template = std.fmt.bufPrint(&template_buf, "{s}.XXXXXX", .{final}) catch break :shared;
+ const n = stage(&tmp_buf, tmp_template, contents);
+ if (n == 0) break :shared;
+ if (libc.rename(tmp_buf[0..n :0].ptr, final.ptr) != 0) {
+ _ = libc.unlink(tmp_buf[0..n :0].ptr);
+ break :shared;
+ }
+ return .{ .len = @intCast(final.len), .owned = false };
+ }
+ var tmp_template: [64]u8 = undefined;
+ const template = std.fmt.bufPrint(&tmp_template, "/tmp/pardes-osc133-{s}-XXXXXX", .{name}) catch return .{ .len = 0, .owned = false };
+ return .{ .len = stage(path_buf, template, contents), .owned = true };
+ }
+
// Publish a path only after its private 0600 file is fully written and closed.
- fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 {
+ fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u16 {
const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0;
const fd = mkstemp(path.ptr);
if (fd < 0) return 0;
@@ -746,7 +792,7 @@ pub const Shell = struct {
pub const Spawn = struct {
path: [*:0]const u8,
- argv: [4:null]?[*:0]const u8,
+ argv: [6:null]?[*:0]const u8,
};
// Resolve in the parent. The path buffer and prompt files must survive through exec.
@@ -757,7 +803,24 @@ pub const Shell = struct {
.fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
.none => .{ null, null },
};
- return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
+ return .{ .path = path, .argv = .{ path, marks[0], marks[1], null, null, null } };
+ }
+
+ /// A command pane's shell runs its one line with job control on, so a
+ /// job the line puts in the background has a process group of its own.
+ /// The shell leads the terminal's session, and when it exits the kernel
+ /// hangs up the terminal's foreground group: with job control off that
+ /// is the shell's own, the job's too, and the job died with the command
+ /// (nohup'd or not; only setsid got out, and not always in time). A
+ /// shell pardes does not know runs the line as it is.
+ pub fn commandArgv(path: [*:0]const u8, line: [*:0]const u8) [6:null]?[*:0]const u8 {
+ const bin = std.mem.span(path);
+ const base = if (std.mem.lastIndexOfScalar(u8, bin, '/')) |s| bin[s + 1 ..] else bin;
+ const posix_shells = [_][]const u8{ "bash", "sh", "dash", "zsh", "ksh", "mksh", "yash" };
+ for (posix_shells) |name| if (std.mem.eql(u8, base, name))
+ return .{ path, "-m", "-c", line, null, null };
+ if (family(bin) == .fish) return .{ path, "-C", "status job-control full", "-c", line, null };
+ return .{ path, "-c", line, null, null, null };
}
/// A shell by its path or its name in the usual directories, or null.
@@ -794,6 +857,20 @@ pub const Shell = struct {
return @ptrCast(buf);
}
+ test "a command pane's line runs with job control on in the shells that have it" {
+ const want = struct {
+ fn check(path: [*:0]const u8, words: []const []const u8) !void {
+ const argv = commandArgv(path, "sleep 9 &");
+ for (words, 0..) |w, i| try std.testing.expectEqualStrings(w, std.mem.span(argv[i].?));
+ try std.testing.expect(argv[words.len] == null);
+ }
+ };
+ try want.check("/usr/bin/bash", &.{ "/usr/bin/bash", "-m", "-c", "sleep 9 &" });
+ try want.check("/bin/sh", &.{ "/bin/sh", "-m", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/fish", &.{ "/usr/bin/fish", "-C", "status job-control full", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/nu", &.{ "/usr/bin/nu", "-c", "sleep 9 &" });
+ }
+
test "a path is taken at its word, a name is looked up, and both pick their own marks" {
if (builtin.os.tag == .windows) return;
var buf: [std.fs.max_path_bytes]u8 = undefined;
@@ -817,52 +894,55 @@ pub const Shell = struct {
try std.testing.expect(libc.access(gone.path, X_OK) == 0);
}
- test "prompt rc owners have private complete files and clean them up" {
+ test "prompt files are one per content under the runtime dir, or private and cleaned up without it" {
if (builtin.os.tag == .windows) return;
- var original = PromptFiles.init();
- var a = original;
- original = .{};
- original.deinit();
- defer a.deinit();
+ var saved_buf: [std.fs.max_path_bytes:0]u8 = @splat(0);
+ const saved: ?[:0]const u8 = if (std.c.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&saved_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null;
+ defer _ = if (saved) |v| setenv("XDG_RUNTIME_DIR", v.ptr, 1) else unsetenv("XDG_RUNTIME_DIR");
+
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [std.fs.max_path_bytes:0]u8 = @splat(0);
+ const dir_len = try tmp.dir.realPath(std.testing.io, &dir_buf);
+ _ = setenv("XDG_RUNTIME_DIR", dir_buf[0..dir_len :0].ptr, 1);
+
+ // Shared: the same file for every owner, left in place, rewritten
+ // whole when what is there is not its content.
+ var a = PromptFiles.init();
var b = PromptFiles.init();
- defer b.deinit();
const a_bash = a.bashPath() orelse return error.TempCreateFailed;
- const b_bash = b.bashPath() orelse return error.TempCreateFailed;
- const a_fish = a.fishPath() orelse return error.TempCreateFailed;
- try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash));
+ try std.testing.expectEqualStrings(a_bash, b.bashPath().?);
+ try std.testing.expect(std.mem.startsWith(u8, a_bash, dir_buf[0..dir_len]));
const fish_command = a.fishCommand() orelse return error.MissingFishCommand;
- try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]);
- try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]);
- for ([_][]const u8{ a_bash, b_bash, a_fish }) |path| {
- const stat = try std.Io.Dir.cwd().statFile(std.testing.io, path, .{});
- try std.testing.expectEqual(std.Io.File.Kind.file, stat.kind);
- try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077);
- }
- var fish_buf: [fish_rc.len]u8 = undefined;
- try std.testing.expectEqualStrings(fish_rc, readSmall(a_fish, &fish_buf) orelse return error.ReadFailed);
-
- var buf: [bash_rc.len]u8 = undefined;
- const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY });
- if (fd < 0) return error.OpenFailed;
- defer _ = libc.close(fd);
- var len: usize = 0;
- while (len < buf.len) {
- const n = libc.read(fd, buf[len..].ptr, buf.len - len);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- return error.ReadFailed;
- }
- if (n == 0) break;
- len += @intCast(n);
- }
- try std.testing.expectEqualStrings(bash_rc, buf[0..len]);
-
- var removed: [rc_path_capacity:0]u8 = @splat(0);
- @memcpy(removed[0..a_bash.len], a_bash);
- removed[a_bash.len] = 0;
+ var want: [rc_path_capacity + 16]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&want, "source '{s}'", .{a.fishPath().?}), fish_command);
+ var buf: [2048]u8 = undefined;
+ try std.testing.expectEqualStrings(bash_rc, readSmall(a_bash, &buf) orelse return error.ReadFailed);
+ try std.testing.expectEqualStrings(fish_rc, readSmall(a.fishPath().?, &buf) orelse return error.ReadFailed);
+ const stat = try std.Io.Dir.cwd().statFile(std.testing.io, a_bash, .{});
+ try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077);
+ var kept: [rc_path_capacity:0]u8 = @splat(0);
+ @memcpy(kept[0..a_bash.len], a_bash);
a.deinit();
- try std.testing.expect(libc.access(&removed, 0) < 0);
- try std.testing.expectEqualStrings(bash_rc, readSmall(b_bash, &buf) orelse return error.ReadFailed);
+ b.deinit();
+ try std.testing.expect(libc.access(&kept, 0) == 0);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = std.fs.path.basename(kept[0..a_bash.len]), .data = "half" });
+ var c = PromptFiles.init();
+ defer c.deinit();
+ try std.testing.expectEqualStrings(bash_rc, readSmall(c.bashPath().?, &buf) orelse return error.ReadFailed);
+
+ // No runtime dir: private files, one per owner, gone at teardown.
+ _ = unsetenv("XDG_RUNTIME_DIR");
+ var d = PromptFiles.init();
+ var e = PromptFiles.init();
+ defer e.deinit();
+ const d_bash = d.bashPath() orelse return error.TempCreateFailed;
+ try std.testing.expect(!std.mem.eql(u8, d_bash, e.bashPath().?));
+ try std.testing.expect(std.mem.startsWith(u8, d_bash, "/tmp/pardes-osc133-bash-"));
+ @memcpy(kept[0..d_bash.len], d_bash);
+ kept[d_bash.len] = 0;
+ d.deinit();
+ try std.testing.expect(libc.access(&kept, 0) < 0);
}
};
@@ -1021,7 +1101,7 @@ pub fn forkShell(
if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand;
@memcpy(command_buf[0..line.len], line);
command_buf[line.len] = 0;
- spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null };
+ spawn.argv = Shell.commandArgv(spawn.path, command_buf[0..line.len :0].ptr);
}
var helper_buf: [4096]u8 = undefined;
const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
@@ -1590,7 +1670,11 @@ fn reapShell(pid: libc.pid_t, grace_ms: u32) void {
while (libc.waitpid(pid, null, 0) < 0 and libc.errno(-1) == .INTR) {}
}
-pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void {
+/// The tty's foreground job is signalled. A command pane's shell (`command`)
+/// is its command too: its line runs with job control on, so the job
+/// running is in a group of its own, and the shell's group goes as well, or
+/// the rest of the line would run on after the job it was on.
+pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal, command: bool) void {
const sig = switch (which) {
.int => libc.SIG.INT,
.term => libc.SIG.TERM,
@@ -1599,11 +1683,9 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.kill => libc.SIG.KILL,
};
const fg = tcgetpgrp(master_fd);
- if (fg > 0) {
- _ = libc.kill(-fg, sig);
- return;
- }
- if (shell_pid > 0) _ = libc.kill(shell_pid, sig);
+ if (fg > 0) _ = libc.kill(-fg, sig);
+ if (command and shell_pid > 0 and fg != shell_pid) _ = libc.kill(-shell_pid, sig);
+ if (fg <= 0 and !command and shell_pid > 0) _ = libc.kill(shell_pid, sig);
}
/// Kill: SIGTERM to the job in the tty's foreground, never to the shell.
@@ -1994,6 +2076,43 @@ test "a command's exit is its process's, told by its watcher, whether or not its
}
}
+test "a command's background job outlives it and its pty, and Kill stops its whole line" {
+ if (comptime !tty_probe_platform) return error.SkipZigTest;
+ if (!haveFile("/bin/sh")) return error.SkipZigTest;
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const rcs: Shell.PromptFiles = .{};
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ const Case = struct { line: []const u8, kill: bool, made: bool };
+ for ([_]Case{
+ // The shell leads the pty's session; its exit hangs up only the
+ // foreground group, which the job, in a group of its own, is not.
+ .{ .line = "(sleep 0.4; touch left) & exit 0", .kill = false, .made = true },
+ // The job running is in its own group; the shell goes too.
+ .{ .line = "sleep 0.4; touch killed", .kill = true, .made = false },
+ }) |c| {
+ const id = core.freeSlot().?;
+ _ = try core.newCommand(id, dir, c.line);
+ while (core.nextEffect()) |_| {}
+ const child = try forkShell(core, id, &rcs, "/bin/sh", dir, 24, 80, null);
+ if (c.kill) {
+ sleepMs(150);
+ signalTty(child.pid, child.file.handle, .term, true);
+ }
+ _ = exitStatus(child.pid, 2000);
+ _ = libc.close(child.file.handle); // the pane closes: the pty hangs up
+ sleepMs(800);
+ const name = if (c.kill) "killed" else "left";
+ const made = if (tmp.dir.statFile(std.testing.io, name, .{})) |_| true else |_| false;
+ try std.testing.expectEqual(c.made, made);
+ try core.removePane(id, null);
+ }
+}
+
test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" {
const pid = libc.fork();
if (pid == 0) {
diff --git a/src/layout.zig b/src/layout.zig
index f0e6fd16..c58f3bcf 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -19,7 +19,9 @@ pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
pub const MovePlacement = struct {
preview_col: usize,
- above_id: usize,
+ /// The pane the moved one lands below; null when the column is empty
+ /// and the moved pane takes it whole.
+ above_id: ?usize,
row: u16,
above_y: u16,
above_h: u16,
@@ -60,6 +62,16 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bo
return true;
}
+/// Column `c`'s serial, handed out the first time it is asked for.
+pub fn columnSerial(p: *Pardes, c: usize) u32 {
+ if (p.col_serial[c] == 0) {
+ p.col_serial[c] = p.next_column_serial;
+ p.next_column_serial +%= 1;
+ if (p.next_column_serial == 0) p.next_column_serial = 1;
+ }
+ return p.col_serial[c];
+}
+
pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize {
if (serial == 0) return null;
for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column;
@@ -178,6 +190,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme
heights[sib] +|= p.rects[id].h;
}
var y: u16 = p.topBarHeight() + p.columnBarHeight();
+ if (p.col_n[tc] == 0) return .{ .preview_col = tc, .above_id = null, .row = y, .above_y = y, .above_h = 0 };
var last: ?MovePlacement = null;
for (0..p.col_n[tc]) |k| {
const pid = p.col_panes[tc][k];
@@ -213,18 +226,23 @@ pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
}
}
removePane(p, id);
- if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ if (source_multi) {
const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx];
heights[sib] +|= p.rects[id].h;
}
- const af = findPane(p, placement.above_id) orelse return;
+ const above = placement.above_id orelse {
+ insert(p, placement.preview_col, 0, id);
+ if (source_multi) setColumnWeights(p, src.col, &heights);
+ return;
+ };
+ const af = findPane(p, above) orelse return;
const upper_h = @max(1, placement.row -| placement.above_y);
const lower_h = @max(1, placement.above_h -| upper_h);
- heights[placement.above_id] = upper_h;
+ heights[above] = upper_h;
heights[id] = lower_h;
insert(p, af.col, af.idx + 1, id);
setColumnWeights(p, af.col, &heights);
- if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights);
+ if (source_multi and src.col != af.col) setColumnWeights(p, src.col, &heights);
}
pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
@@ -278,39 +296,23 @@ pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void {
p.col_n[c] += 1;
}
+/// Take a pane out of its column. The column stays, empty if this was its
+/// last pane, as acme's colclose leaves it (cols.c:186): only Delcol and
+/// Joincol take a column away.
pub fn removePane(p: *Pardes, id: usize) void {
const f = findPane(p, id) orelse return;
const c = f.col;
var k = f.idx;
while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1];
p.col_n[c] -= 1;
- if (p.col_n[c] == 0) {
- tagline.exitHeader(p);
- if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
- p.col_tags[c].deinit(p.gpa);
- var j = c;
- while (j + 1 < p.ncol) : (j += 1) {
- p.col_panes[j] = p.col_panes[j + 1];
- p.col_n[j] = p.col_n[j + 1];
- p.col_weight[j] = p.col_weight[j + 1];
- p.col_tags[j] = p.col_tags[j + 1];
- p.col_serial[j] = p.col_serial[j + 1];
- }
- p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
- p.col_serial[p.ncol] = 0;
- }
}
-pub fn joinCol(p: *Pardes) void {
- const f = findPane(p, p.active) orelse return;
- const c = f.col;
- if (c + 1 >= p.ncol) return;
+/// Close column `c` once its panes are gone, its width to the one left of
+/// it (the first column's to its right), as acme's rowclose gives it.
+pub fn dropColumn(p: *Pardes, c: usize) void {
+ std.debug.assert(p.col_n[c] == 0);
tagline.exitHeader(p);
- const dst = c + 1;
- p.col_weight[dst] +|= p.col_weight[c];
- for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
- p.col_n[dst] += p.col_n[c];
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
p.col_tags[c].deinit(p.gpa);
var j = c;
while (j + 1 < p.ncol) : (j += 1) {
@@ -325,42 +327,145 @@ pub fn joinCol(p: *Pardes) void {
p.col_serial[p.ncol] = 0;
}
+/// Fold column `c` into the one on its right, its panes below theirs.
+pub fn joinCol(p: *Pardes, c: usize) void {
+ if (c + 1 >= p.ncol) return;
+ const dst = c + 1;
+ for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
+ p.col_n[dst] += p.col_n[c];
+ p.col_n[c] = 0;
+ // The width goes right, where the panes went.
+ p.col_weight[dst] +|= p.col_weight[c];
+ p.col_weight[c] = 0;
+ dropColumn(p, c);
+}
+
+/// Put new pane `id` in column `c` as acme's makenewwindow does
+/// (util.c:449-495): an empty column it takes whole; asked from a tag or
+/// from 9P (`origin` null, acme's t->w == nil) it takes the bottom half of
+/// the column's last pane (coladd with y < 0, cols.c:62-65). Asked from a
+/// pane's text, it goes under the text of the pane with the most blank rows
+/// when there are many, else it halves the biggest pane, or `origin` when
+/// that is in the column and not much smaller.
+pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
+ compute(p);
+ const n = p.col_n[c];
+ if (n == 0) {
+ insert(p, c, 0, id);
+ p.panes[id].?.vweight = 1;
+ return;
+ }
+ var big: ?usize = null;
+ var empty: ?usize = null;
+ for (p.col_panes[c][0..n]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ // `>=`: at a tie the one nearer the bottom, as acme picks.
+ if (big == null or bodyRows(p, k) >= bodyRows(p, big.?)) big = k;
+ if (empty == null or blankRows(p, k) >= blankRows(p, empty.?)) empty = k;
+ }
+ const v0 = big orelse {
+ // Every pane folded: the new one goes last.
+ insert(p, c, n, id);
+ return;
+ };
+ const y: u16 = y: {
+ const o = origin orelse {
+ var last = v0;
+ for (p.col_panes[c][0..n]) |k| if (!p.panes[k].?.collapsed) {
+ last = k;
+ };
+ break :y bodyTop(p, last) + bodyRows(p, last) / 2;
+ };
+ const e = empty.?;
+ const el = blankRows(p, e);
+ if (el > 15 or (el > 3 and el > (bodyRows(p, v0) -| 1) / 2))
+ break :y bodyTop(p, e) + bodyRows(p, e) - el;
+ const mine = if (findPane(p, o)) |f| f.col == c and !p.panes[o].?.collapsed else false;
+ const v = if (mine and @as(u32, p.rects[o].h) * 3 > @as(u32, p.rects[v0].h) * 2) o else v0;
+ break :y p.rects[v].y + p.rects[v].h / 2;
+ };
+ splitAt(p, c, id, y);
+}
+
+/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the
+/// pane that `y` falls in, which keeps its rows above `y`.
+fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
+ const n = p.col_n[c];
+ var k: usize = 0;
+ while (k + 1 < n and y >= p.rects[p.col_panes[c][k]].y + p.rects[p.col_panes[c][k]].h) k += 1;
+ const v = p.col_panes[c][k];
+ const r = p.rects[v];
+ snapColWeights(p, c);
+ insert(p, c, k + 1, id);
+ const pane = p.panes[id].?;
+ // Each keeps a tag and a body row, as acme's minht does.
+ const lo = r.y +| BOX_H + 1;
+ const hi = (r.y + r.h) -| (BOX_H + 1);
+ const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse {
+ pane.vweight = BOX_H + 1;
+ return;
+ };
+ // `y` fell in an unfolded pane: acmePlace picks from those.
+ p.panes[v].?.vweight = @floatFromInt(@max(1, row - r.y));
+ pane.vweight = @floatFromInt(@max(1, (r.y + r.h) - row));
+}
+
+fn bodyTop(p: *const Pardes, id: usize) u16 {
+ return p.rects[id].y + p.panes[id].?.tag_rows;
+}
+
+fn bodyRows(p: *const Pardes, id: usize) u16 {
+ return p.rects[id].h -| p.panes[id].?.tag_rows;
+}
+
+/// The rows at the bottom of a pane's body that show nothing: acme's
+/// maxlines - nlines. A picture or a PDF page fills its body.
+fn blankRows(p: *const Pardes, id: usize) u16 {
+ const pane = p.panes[id].?;
+ const rows = bodyRows(p, id);
+ const used: usize = if (pane.file) |*f|
+ // The empty line after a final newline shows nothing.
+ (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll
+ else if (pane.isTerminal())
+ @as(usize, panes.Terminal.gridCursor(pane).y) + 1
+ else
+ rows;
+ return rows -| @as(u16, @intCast(@min(used, rows)));
+}
+
pub fn canSplitColumn(p: *Pardes, source_id: usize) bool {
- if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false;
+ if (source_id >= MAX_PANES or p.panes[source_id] == null) return false;
const source = findPane(p, source_id) orelse return false;
+ return canSplitCol(p, source.col);
+}
+
+fn canSplitCol(p: *Pardes, col: usize) bool {
+ if (p.ncol >= MAX_COLS or col >= p.ncol) return false;
// Refresh derived widths: public layout surgery may be chained between
// syncs, and a cached width must never admit a now-too-narrow split.
compute(p);
- if (p.col_w[source.col] < config.MINW * 2) return false;
+ if (p.col_w[col] < config.MINW * 2) return false;
- const weight = p.col_weight[source.col];
+ const weight = p.col_weight[col];
if (weight >= 2 and weight % 2 == 0) return true;
for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2)
return false;
return weight > 0;
}
-pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
- if (id >= MAX_PANES or p.panes[id] == null) return false;
- if (!canSplitColumn(p, source_id)) return false;
- const source = findPane(p, source_id) orelse return false;
- const source_col = source.col;
- var old_weight = p.col_weight[source_col];
- const needs_rebase = old_weight < 2 or old_weight % 2 != 0;
- if (needs_rebase) old_weight *= 2;
- if (id == source_id) {
- if (p.col_n[source_col] <= 1) return false;
- _ = absorbVWeight(p, id, null);
- removePane(p, id);
- } else if (findPane(p, id) != null) return false;
-
- if (needs_rebase) {
+/// An empty column beside column `col`, with half its width: acme's Newcol
+/// (rowadd, rows.c:51). The new column's index, or null with no room.
+pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
+ if (!canSplitCol(p, col)) return null;
+ var old_weight = p.col_weight[col];
+ if (old_weight < 2 or old_weight % 2 != 0) {
+ old_weight *= 2;
for (0..p.ncol) |column| p.col_weight[column] *= 2;
}
const source_weight = old_weight / 2;
- const new_weight = old_weight - source_weight;
- p.col_weight[source_col] = source_weight;
- const c = source_col + @intFromBool(!before);
+ p.col_weight[col] = source_weight;
+ const c = col + @intFromBool(!before);
tagline.exitHeader(p);
var j = p.ncol;
while (j > c) : (j -= 1) {
@@ -370,12 +475,27 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
p.col_tags[j] = p.col_tags[j - 1];
p.col_serial[j] = p.col_serial[j - 1];
}
- p.col_weight[c] = new_weight;
+ p.col_weight[c] = old_weight - source_weight;
p.col_tags[c] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[c] = 0;
- p.col_panes[c][0] = id;
- p.col_n[c] = 1;
+ p.col_n[c] = 0;
p.ncol += 1;
+ return c;
+}
+
+/// Pane `id` alone in a new column beside `source_id`'s; `id` may be the
+/// source itself when its column keeps another pane.
+pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
+ if (id >= MAX_PANES or p.panes[id] == null) return false;
+ if (!canSplitColumn(p, source_id)) return false;
+ const source = findPane(p, source_id) orelse return false;
+ if (id == source_id) {
+ if (p.col_n[source.col] <= 1) return false;
+ _ = absorbVWeight(p, id, null);
+ removePane(p, id);
+ } else if (findPane(p, id) != null) return false;
+ const c = insertColumn(p, source.col, before).?;
+ insert(p, c, 0, id);
return true;
}
@@ -551,11 +671,7 @@ fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usiz
pub fn compute(p: *Pardes) void {
p.rects = @splat(.{});
if (p.ncol == 0) return;
- for (0..p.ncol) |column| if (p.col_serial[column] == 0) {
- p.col_serial[column] = p.next_column_serial;
- p.next_column_serial +%= 1;
- if (p.next_column_serial == 0) p.next_column_serial = 1;
- };
+ for (0..p.ncol) |column| _ = columnSerial(p, column);
var wsum: u128 = 0;
for (0..p.ncol) |c| wsum += p.col_weight[c];
if (wsum == 0) wsum = 1;
@@ -688,7 +804,6 @@ test "collapse local transfers remain bounded through repeated tiny window resiz
}
for (0..180) |step| {
p.screen_h = @intCast((step * 17) % 81);
- p.settings.column_tags = step % 2 == 0;
p.settings.tag_bottom = step % 3 == 0;
compute(p);
toggleCollapse(p, step % 5);
@@ -743,7 +858,7 @@ test "collapse single and all panes leave unused column space" {
try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y);
}
-test "collapse survives tiny resizes column tags and TagBottom" {
+test "collapse survives tiny resizes and TagBottom" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 });
defer p.deinit();
_ = try p.newShell(1, "");
@@ -753,8 +868,7 @@ test "collapse survives tiny resizes column tags and TagBottom" {
p.panes[1].?.collapsed = true;
for ([_]bool{ false, true }) |bottom| {
p.settings.tag_bottom = bottom;
- for ([_]bool{ false, true }) |column_tags| {
- p.settings.column_tags = column_tags;
+ {
for (0..50) |height| {
p.screen_h = @intCast(height);
compute(p);
diff --git a/src/look.zig b/src/look.zig
index 2e27a001..d90d27b8 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -10,6 +10,7 @@ const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
const animation = @import("animation.zig");
const modal = @import("modal.zig");
+const tagline = @import("tagline.zig");
const filesystem = @import("fs.zig");
const panes = @import("panes.zig");
const mouse = @import("mouse.zig");
@@ -466,6 +467,7 @@ pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
var sl = at;
if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
+ if (sl.r0 >= 0 and sl.r0 < pane.tag_rows) return tagWordSel(p, pane, sl);
const text = edit.paneText(p, pane) catch return null;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
@@ -481,6 +483,34 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
return null;
}
+/// A tag word is expanded in the tag's own text, so a word the wrapping
+/// broke across rows is still one word, as acme's frame wraps a tag
+/// without breaking its text.
+fn tagWordSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
+ const arena = p.scratch.allocator();
+ const shown = tagline.shownTag(p, arena, pane) catch return null;
+ const whole = tagline.tagText(p, arena, pane) catch return null;
+ const row: usize = @as(usize, @intCast(at.r0)) + pane.tag_top;
+ const shown_line = modal.lineSlice(shown.text, row);
+ const real = shown.toReal(modal.lineStartOffset(shown.text, row) + panes.File.rawAtDisplay(shown_line, @intCast(@max(0, at.c0))));
+ const start = if (std.mem.lastIndexOfScalar(u8, whole[0..@min(real, whole.len)], '\n')) |nl| nl + 1 else 0;
+ const end = std.mem.indexOfScalarPos(u8, whole, start, '\n') orelse whole.len;
+ const b = expandedWord(whole[start..end], real - start) orelse return null;
+ const lo = shownCell(shown.text, shown.fromReal(start + b.lo), pane.tag_top);
+ const hi = shownCell(shown.text, shown.fromReal(start + b.hi - 1), pane.tag_top);
+ return .{ .state = at.state, .r0 = lo.row, .c0 = lo.col, .r1 = hi.row, .c1 = hi.col };
+}
+
+fn shownCell(text: []const u8, off: usize, top: usize) struct { row: i32, col: i32 } {
+ const before = text[0..@min(off, text.len)];
+ const row = std.mem.count(u8, before, "\n");
+ const line_start = if (std.mem.lastIndexOfScalar(u8, before, '\n')) |nl| nl + 1 else 0;
+ return .{
+ .row = @as(i32, @intCast(row)) - @as(i32, @intCast(top)),
+ .col = @intCast(panes.File.rawDisplayCol(text[line_start..], off - line_start)),
+ };
+}
+
/// the word under the modal cursor as a pane-local selection (paneText
/// coords: row 0 is the tag; file panes carry the line-number prefix)
pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel {
@@ -518,7 +548,7 @@ pub fn submitSearch(p: *Pardes, id: usize) void {
.grep
else
.text;
- runSearch(p, id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err);
+ _ = runSearch(p, id, armed[slash + 1 ..], kind, .top, true) catch |err| return p.reportError(id, "search", err);
if (kind == .text) lookFirstHit(p, id);
}
@@ -538,10 +568,13 @@ fn lookFirstHit(p: *Pardes, id: usize) void {
exec.runBuiltin(p, config.look_cmd, rid, "", txt);
}
-pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void {
- const pane = p.panes[id] orelse return;
+/// Fills the results buffer, and answers how many rows a text or PDF
+/// search found (0 for Find and Grep, which do not count). With
+/// `open_empty` false a text search that found nothing opens no buffer.
+pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart, open_empty: bool) !usize {
+ const pane = p.panes[id] orelse return 0;
const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
- if (pat.len == 0) return;
+ if (pat.len == 0) return 0;
const arena = p.scratch.allocator();
// where the pane lives: a file's directory, a shell's cwd — the walk
// root, and the directory the results buffer is named in.
@@ -617,6 +650,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
nrows += 1;
}
}
+ if (!open_empty and kind == .text and nrows == 0) return 0;
const content = try p.gpa.dupe(u8, out[0..out_len]);
// the buffer records WHICH search filled it, pattern and all: Find and
// Grep are builtins (words you can execute), the bare `/` is a key
@@ -626,6 +660,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
.grep => .{ .cmd = .Grep },
};
try panes.Output.fillResults(p, id, dir, from, pat, content, anchor);
+ return nrows;
}
pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
@@ -963,7 +998,8 @@ pub fn focusPaneLine(p: *Pardes, id: usize, at: Spot, landing: enum { center, ke
if (pane.file) |*f| {
if (at.line > panes.File.nlines(p.gpa, f)) return;
if (landing == .center) {
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ // center, clamp at top; a text that fits is shown whole
+ const next = if (pane.textFits()) 0 else (at.line - 1) -| pane.rows / 2;
if (next != f.scroll) {
f.scroll = next;
f.syntax_dirty = true;
@@ -1137,6 +1173,86 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v
}
}
+/// A look that found nothing: said on the message row, logged as an `err`
+/// record, and marked so that /look reads back nothing (ninep/ctl.zig run).
+fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
+ p.look_missed = true;
+ var said: [160]u8 = undefined;
+ const why = std.fmt.bufPrint(&said, fmt, args) catch "no match";
+ var msg: [176]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match");
+ const serial = if (p.panes[id]) |pane| pane.serial else 0;
+ var rec: [200]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return);
+}
+
+/// After a look at `path:N` focused its pane: a line past the end focuses
+/// nothing, leaves no pane it opened, and is a miss; a bare line number
+/// selects the line, its newline included, as acme's does.
+fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize, opened: ?usize) void {
+ const shown = p.panes[p.active] orelse return;
+ const f = if (shown.file) |*file| file else return;
+ if (at.line > panes.File.nlines(p.gpa, f)) {
+ if (opened) |free| p.removePane(free, null) catch {};
+ if (p.panes[before] != null) p.active = before;
+ return missed(p, id, "{s} has no line {d}", .{ path, at.line });
+ }
+ if (at.line > 0 and at.col == 0 and at.end_line == 0) {
+ const start = modal.lineStartOffset(f.content, at.line - 1);
+ const next = std.mem.indexOfScalarPos(u8, f.content, start, '\n');
+ const end = if (next) |i| i + 1 else f.content.len;
+ @import("ninep/pane.zig").setDot(shown, .{ .q0 = @intCast(start), .q1 = @intCast(end) });
+ }
+}
+
+/// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an
+/// address character (addr.c:28, isaddrc) that is not a line spot this
+/// parser already reads (`file:12`, `file:12:5`). Not a URL.
+fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } {
+ if (std.mem.indexOf(u8, tok, "://") != null) return null;
+ const colon = std.mem.indexOfScalar(u8, tok, ':') orelse return null;
+ const expr = tok[colon + 1 ..];
+ // A written look is a whole line: the path has no blanks, but a
+ // pattern in the address may (`calc.py:/return a/`).
+ if (std.mem.indexOfAny(u8, tok[0..colon], " \t") != null) return null;
+ if (std.mem.indexOfAny(u8, expr, " \t") != null and std.mem.indexOfAny(u8, expr, "/?") == null) return null;
+ if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/$.#,;", expr[0]) == null) return null;
+ if (std.ascii.isDigit(expr[0])) {
+ const pl = parsePathLine(tok);
+ if (pl.at.line != 0 and pl.end == tok.len) return null;
+ }
+ return .{ .path = tok[0..colon], .expr = expr };
+}
+
+/// Opens (or finds) `path` as Look would, `id`'s own pane when it is empty,
+/// and selects what `expr` addresses there from its dot. An address that
+/// does not evaluate moves nothing and says why (acme: no jump).
+fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
+ const ninep_pane = @import("ninep/pane.zig");
+ const Addr = @import("ninep/addr.zig").Addr;
+ var target = id;
+ if (path.len > 0) {
+ const before = p.active;
+ lookAt(p, id, path);
+ target = p.active;
+ // The path opened nothing (a search, or no such file): Look said so.
+ if (target == before and target != id) return;
+ }
+ const pane = p.panes[target] orelse return;
+ const f = pane.file orelse return;
+ if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
+ var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
+ const r = a.address(ninep_pane.dotOf(pane)) orelse {
+ return missed(p, id, "{s}", .{a.err});
+ };
+ if (a.i < expr.len or r.q0 > r.q1) {
+ return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"});
+ }
+ clearNavigationSelection(p, pane);
+ ninep_pane.setDot(pane, r);
+ p.active = target;
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
p.noteHaptic(.look);
@@ -1149,37 +1265,45 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// The operand may borrow selected terminal/PDF text released below.
const txt = p.scratch.allocator().dupe(u8, operand) catch return;
const trimmed = std.mem.trim(u8, txt, " \t\r\n");
- clearNavigationSelection(p, pane);
+ // 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).
+ if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr);
const pl = parsePathLine(trimmed);
+ if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane);
if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
var realbuf: [4096]u8 = undefined;
const found = resolveLookTarget(p, id, txt, &realbuf);
+ // A line past the end of an open pane is a miss (the target's line is
+ // already clamped, so the one written is asked).
+ if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| if (shown.file) |*f| if (pl.at.line > panes.File.nlines(p.gpa, f))
+ return missed(p, id, "{s} has no line {d}", .{ trimmed, pl.at.line });
+ // A miss changes nothing, the selection included; anything else starts
+ // from a clean one.
+ if (found != .none) clearNavigationSelection(p, pane);
switch (found) {
.none => {
if (pane.image != null or pane.body.mode == .tty) return;
- runSearch(p, id, trimmed, .text, .cursor) catch |err| {
+ const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| {
p.reportError(id, "search", err);
return;
};
+ if (rows == 0) return missed(p, id, "no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)});
+ clearNavigationSelection(p, pane);
const at = pane.search_row;
_ = searchStep(p, id, 1);
if (at != null and pane.search_row == at) {
pane.search_row = null;
_ = searchStep(p, id, 1);
}
- // A path that is not there searches for its text, and a search
- // that found nothing says so rather than leave an empty buffer.
- const results = if (pane.search_pane) |rid| p.panes[rid] else null;
- if (results) |r| if (r.file) |f| if (f.content.len == 0) {
- var said: [96]u8 = undefined;
- p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match");
- };
+
},
.pane => |t| {
if (t.id >= MAX_PANES) return;
const target = p.panes[t.id] orelse return;
+ const before = p.active;
clearNavigationSelection(p, target);
focusPaneLine(p, t.id, t.at, .center);
+ settleLine(p, id, trimmed, t.at, before, null);
},
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
@@ -1194,43 +1318,48 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err);
nt.greet = true;
- const src = layout.splitParent(p, id);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ exec.placeNew(p, id, id, free, .shell);
},
.file => |target| {
if (comptime pdf_enabled) if (target.kind == .pdf) {
if (focusPaneByPath(p, target.path, target.at)) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
+ _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ exec.placeNew(p, id, id, free, .doc);
return;
};
+ // An open pane on this path too short for the line: a miss that
+ // touches nothing.
+ if (openPaneTarget(p, target.path, target.at)) |open| if (p.panes[open.pane.id].?.file) |*f|
+ if (target.at.line > panes.File.nlines(p.gpa, f))
+ return missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line });
// focus an existing pane on this path (rescrolled), else open
- if (focusPaneByPath(p, target.path, target.at)) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
- if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
- exec.placeDoc(p, id, free, nt);
- layout.compute(p);
- nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
- if (target.at.end_line != 0) {
- const centered = nt.file.?.scroll;
- selectSpan(p, nt, target.at);
- nt.file.?.scroll = centered;
+ const before = p.active;
+ var opened: ?usize = null;
+ if (!focusPaneByPath(p, target.path, target.at)) {
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
+ return p.reportError(id, "look", err);
+ if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
+ exec.placeNew(p, id, id, free, .doc);
+ layout.compute(p);
+ nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
+ if (target.at.end_line != 0) {
+ const centered = nt.file.?.scroll;
+ selectSpan(p, nt, target.at);
+ nt.file.?.scroll = centered;
+ }
+ opened = free;
}
+ settleLine(p, id, target.path, target.at, before, opened);
},
.image => |target| {
if (focusPaneByPath(p, target.path, .{})) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Image.create(p, free, target.path, &.{}) catch |err|
+ _ = panes.Image.create(p, free, target.path, &.{}) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ exec.placeNew(p, id, id, free, .doc);
},
}
}
@@ -1244,6 +1373,8 @@ test "Look hover waits without mutating the pane and input cancels it" {
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = try p.setTestFile("alpha beta gamma\n");
+ pane.tag_expanded = false; // a one-row tag keeps this small screen's rows
+ p.sync();
while (p.nextEffect()) |_| {}
const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
_ = try p.newShell(other_id, "");
diff --git a/src/macos.zig b/src/macos.zig
index acc344d2..678b155d 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1313,15 +1313,15 @@ fn drainInbox(st: *State) bool {
host_io.commandEof(st.core, &st.ptys, e.pane, st, closeWatched);
continue;
}
- // Unwatched, a command's exit is read here, as its end.
- if (st.core.panes[e.pane]) |pane| if (pane.command != null) {
- const status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- reap(st, e.pane);
- st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
- st.core.update(.{ .eof = .{ .pane = e.pane } });
- continue;
- };
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
+ const command = if (st.core.panes[e.pane]) |pane| pane.command != null else false;
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ reap(st, e.pane);
+ if (command or status != null) st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
+ continue;
}
reap(st, e.pane);
st.core.update(.{ .eof = .{ .pane = e.pane } });
@@ -2469,7 +2469,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void {
const st = hostState(ctx);
// A command whose exit is recorded is reaped: its group may be another's.
- if (st.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig);
+ if (st.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -2487,7 +2487,7 @@ fn killJob(ctx: ?*anyopaque, pane: u8) bool {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const st = hostState(ctx);
filesystem.write(st.core, path, bytes) catch |err|
- return st.core.saveFailed(pane, "save", err);
+ return st.core.saveFailed(pane, path, err);
st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes));
var mbuf: [256]u8 = undefined;
st.core.setMessage(pane, message.stamp(&mbuf, "saved", path));
diff --git a/src/mouse.zig b/src/mouse.zig
index 22eb3270..36786612 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -48,6 +48,14 @@ pub const Drag = union(enum) {
button: Mouse.Button,
chorded: bool = false,
ctrl: bool = false,
+ /// A B1 body sweep held past the pane's top (-1) or bottom (1): the
+ /// body scrolls a line a tick, as acme's frselect scrolls
+ /// (libframe frselect.c, the `scroll` callback while the button is
+ /// held outside the frame).
+ edge: i2 = 0,
+ /// Where the sweep began in the text, once it has scrolled: its
+ /// press row may be off the screen.
+ anchor: ?modal.Cursor = null,
/// This gesture began over a usable native PDF raster. The payload is
/// zero-bit when PDF support is absent.
pdf: panes.Pdf.PointerDrag = .{},
@@ -337,6 +345,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
wordAtCol(bar, at);
const word = p.scratch.allocator().dupe(u8, selected) catch return;
tagline.exitHeader(p);
+ // A column tag's command acts on that column, empty or not.
+ p.exec_column = column;
+ defer p.exec_column = null;
if (word.len > 0) {
if (m.button == config.exec_button) {
const named = std.meta.stringToEnum(Builtin, word);
@@ -450,6 +461,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows;
if (on_tag and mcol < r.x + TAG_TEXT_INSET) return;
p.active = id;
+ p.noteTyped(); // acme's button 1 sets activecol (acme.c:659)
pane.closePrompt();
const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
pane.clearPointerSelection(sel_slot);
@@ -621,6 +633,17 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
// from the pointer.
const body_y = p.bodyTop(pane, r);
const body_h = r.h -| pane.tag_rows;
+ s.edge = if (s.button != config.select_button or pane.sel[b].r0 < tag_rows or pane.file == null or pane.hasPdf() or body_h == 0)
+ 0
+ else if (mrow < body_y)
+ -1
+ else if (mrow >= body_y + body_h or
+ // The pointer cannot leave the screen: at a bottom pane,
+ // its last row is the edge.
+ (body_y + body_h >= p.screen_h and mrow + 1 >= body_y + body_h))
+ 1
+ else
+ 0;
pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows)
tag_line
else if (body_h == 0)
@@ -666,11 +689,12 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
const pane = p.panes[id] orelse return .{};
if (on_tag) {
const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{};
+ const shown = tagline.shownTag(p, p.scratch.allocator(), pane) catch return .{};
const sel = operand.expanded orelse operand.preview orelse return .{};
const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 };
const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top;
- const start = modal.lineStartOffset(tag, row);
- const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, row), @intCast(@max(0, lead.c0)));
+ const start = modal.lineStartOffset(shown.text, row);
+ const at = shown.toReal(start + panes.File.rawAtDisplay(modal.lineSlice(shown.text, row), @intCast(@max(0, lead.c0))));
const q0: u32 = @intCast(@min(at, tag.len));
return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) };
}
@@ -691,12 +715,13 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
const start: ?modal.Cursor = if (operand.file_word) |w|
.{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) }
else if (operand.expanded orelse operand.preview) |sel| lead: {
- // A selection's leading corner in reading order, converted the way
+ // A selection's start in reading order, converted the way
// `pointerOperand` converts the click itself.
- const top = @min(sel.r0, sel.r1) - @as(i32, pane.tag_rows);
+ const lead = edit.sweepEnds(sel)[0];
+ const top = lead[0] - @as(i32, pane.tag_rows);
if (top < 0) break :lead null;
const w = pane.wrapAt(top);
- const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
+ const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, lead[1] - panes.File.gutterWidth(pane));
break :lead .{
.row = @intCast(@max(0, w.line)),
.col = @intCast(@max(0, col)),
@@ -730,10 +755,11 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
const rows = pane.pointerSelection(gesture) orelse return;
const text = pane.rawPointerText(gesture) orelse return;
if (rows.len == 0) return;
- const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0];
+ const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0);
+ const endpoint = rows[if (forward) rows.len - 1 else 0];
const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return;
const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane))));
- const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
+ const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
pane.body.cur_row = pane.surfRow(endpoint.row);
var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t");
if (pane.ovl) |overlay| {
@@ -753,11 +779,70 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
edit.setYank(p, text);
}
+/// acme's double-click (text.c:881 textselect, plan9port text.c:1407
+/// textdoubleclick): a second plain B1 click at the same cell within half a
+/// second selects what edit.doubleClickRange says, from the caret the click
+/// left. A plain click still puts the caret on its cell.
+fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, col: i32) void {
+ const now = clockMs();
+ const prev = p.last_click;
+ p.last_click = .{ .id = id, .on_tag = on_tag, .row = row, .col = col, .ms = now };
+ const c = prev orelse return;
+ if (c.id != id or c.on_tag != on_tag or c.row != row or c.col != col or now - c.ms > 500) return;
+ p.last_click = null;
+ const text = edit.flatSurface(p, t) catch return;
+ const q = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const r = edit.doubleClickRange(text, q);
+ if (r[1] <= r[0]) return;
+ const a = modal.positionAt(text, r[0]);
+ const h = modal.positionAt(text, modal.prevGrapheme(text, r[1]));
+ t.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ t.cur_row = @intCast(h.row);
+ t.cur_col = @intCast(h.col);
+ t.cur_pinned = true;
+}
+
+/// On each tick while a B1 body sweep is held past an edge: the body
+/// scrolls a line toward it, the sweep's end stays under the pointer and
+/// its start stays on its text (Drag.select.anchor).
+pub fn edgeScroll(p: *Pardes) void {
+ if (p.drag != .select) return;
+ const s = &p.drag.select;
+ if (s.edge == 0) return;
+ const pane = p.panes[s.id] orelse return;
+ const sl = &pane.sel[sel_slot];
+ if (s.anchor == null) s.anchor = sweepPoint(p, pane, sl.r0, sl.c0) orelse return;
+ const before = pane.scroll();
+ pane.scrollBy(s.edge);
+ // The start's row follows its text while it is on the screen, and
+ // stays on the body's edge row once it is off (the anchor keeps it).
+ const top: i32 = pane.tag_rows;
+ sl.r0 = std.math.clamp(sl.r0 - (pane.scroll() - before), top, top + @as(i32, pane.rows) - 1);
+}
+
+/// The text position under a body cell of the sweep's space.
+fn sweepPoint(p: *Pardes, pane: *panes.Pane, r: i32, c: i32) ?modal.Cursor {
+ const visible = r - @as(i32, pane.tag_rows);
+ if (visible < 0) return null;
+ const w = pane.wrapAt(visible);
+ if (w.line < 0) return null;
+ const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, c - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0));
+ return .{ .row = @intCast(w.line), .col = @intCast(@max(0, col)) };
+}
+
+fn clockMs() i64 {
+ if (comptime !pardes.hosted) return 0;
+ var ts: std.c.timespec = undefined;
+ if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
+}
+
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
// A different button's release belongs to a chord or another host
// stream; only the button which began this gesture may end it.
if (p.drag.button() != button) return;
defer p.drag = .none;
+ defer p.chord_hold = false;
switch (p.drag) {
.border_v => |d| {
const c = d.left_col;
@@ -775,13 +860,17 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y),
.column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) {
.stay => {},
- .reorder => |target| layout.reorderColumn(p, column, target),
+ .reorder => |target| {
+ layout.reorderColumn(p, column, target);
+ p.setActiveColumn(target); // a grip drag sets activecol (acme.c:640)
+ },
.edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]),
},
.move => |d| {
layout.movePane(p, d.id, d.cur_x, d.cur_y);
- if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial;
- // a file moved into the left column evicts a lone unused shell
+ if (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col);
+ // BootShell replace: a file moved into the left column closes a
+ // lone unused shell there
if (p.panes[d.id]) |mt| if (mt.file != null) {
if (layout.findPane(p, d.id)) |f| if (f.col == 0)
exec.evictLonePristineTty(p, 0, d.id);
@@ -836,10 +925,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
} else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| {
pane.tag.cur_row = @intCast(cursor.row);
pane.tag.cur_col = @intCast(cursor.col);
+ if (!dragged) doubleClick(p, s.id, &pane.tag, true, sl.r1, sl.c1);
}
return;
}
- if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
+ if (dragged and s.anchor == null) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane);
const body_vis = sl.r1 - @as(i32, pane.tag_rows);
if (body_vis >= 0 and pane.body.mode != .tty) {
@@ -861,6 +951,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.body.select = false;
pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
+ // A ctrl-click is a definition jump, not half a double-click.
+ if (!dragged and !s.ctrl) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1);
+ if (s.ctrl) p.last_click = null;
+ // A sweep that scrolled has its start off the screen: it
+ // is kept as the text's own selection, start to end.
+ if (dragged) if (s.anchor) |a| {
+ pane.sel[sel_slot].state = .none;
+ pane.body.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ };
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
} else {
@@ -993,6 +1092,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
}
if (!s.chorded) {
s.chorded = true;
+ // Everything done while B1 stays down is one undo step, as acme
+ // marks the file once per B1 hold (text.c:881 textselect, `state`
+ // and its one filemark): B1-B2 then B1-B3 is a copy.
+ edit.pushUndo(p, &pane.body);
+ p.chord_hold = true;
const sl = pane.sel[sel_slot];
pane.sel[sel_slot].state = .none;
const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 0537c17a..1e47183b 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -91,6 +91,26 @@ pub const Addr = struct {
if (d < '0' or d > '9') break;
n = @min(n * 10 + (d - '0'), std.math.maxInt(u32));
}
+ // pardes's `line:col`, Look's `file:12:5` spelling (look.zig
+ // parsePathLine): the point at that column of that line, the
+ // column in bytes from 1, clamped to the line's end and snapped
+ // to a character's start as Look's cursor is. Not sam's.
+ if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and
+ a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9')
+ {
+ a.i += 1;
+ var col: u64 = 0;
+ while (a.i < a.expr.len and a.expr[a.i] >= '0' and a.expr[a.i] <= '9') : (a.i += 1)
+ col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32));
+ const lines = modal.cursorLineCount(a.text);
+ if (n == 0 or n > lines or col == 0) {
+ a.err = e_range;
+ return null;
+ }
+ const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
+ r = .{ .q0 = q, .q1 = q };
+ continue;
+ }
r = a.number(r, @intCast(n), dir, size) orelse return null;
dir = 0;
size = .line;
@@ -202,8 +222,8 @@ pub const Addr = struct {
a.err = "no previous regular expression";
return null;
}
- var rx = regexp_.Regex.compile(pat) catch {
- a.err = e_regexp;
+ var rx = regexp_.Regex.compile(pat) catch |err| {
+ a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
const found = if (back) found: {
@@ -340,13 +360,34 @@ test "a failed address leaves none, so data refuses rather than act at the last
try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename);
try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno());
try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content);
- // A good address, or a truncated addr, gives it one again.
+ // A good address gives it one again; truncating addr does not.
_ = th.wr(p, addr, "#0,#3");
try testing.expectEqual(Status.ok, th.wr(p, data, "ONE").reply.status);
try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);
_ = th.wr(p, addr, "99");
_ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true });
- try testing.expectEqual(Status.ok, th.wr(p, data, "0").reply.status);
+ try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "0").reply.ename);
+}
+
+test "a shell's > on addr searches on from the last address, and 0 starts over" {
+ const p = try th.withFile(testing.allocator, "foo x foo y foo\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const addr = Node.of(serial, .addr);
+ const data = Node.of(serial, .data);
+ // echo /foo/ > addr; printf foobar > data -- twice over, as a loop does.
+ for (0..3) |_| {
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true });
+ try testing.expectEqual(Status.ok, th.wr(p, addr, "/foo/").reply.status);
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = data, .truncate = true });
+ try testing.expectEqual(Status.ok, th.wr(p, data, "foobar").reply.status);
+ }
+ try testing.expectEqualStrings("foobar x foobar y foobar\n", p.panes[0].?.file.?.content);
+ // .+#1 moves on from where the last write left the address.
+ _ = th.wr(p, addr, "0");
+ _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true });
+ try testing.expectEqual(Status.ok, th.wr(p, addr, ".+#1").reply.status);
+ try testing.expectEqualStrings(" 1 1 ", th.rd(p, addr, 0, 64).bytes);
}
test "the address language, form by form" {
@@ -372,6 +413,11 @@ test "the address language, form by form" {
.{ .expr = "/two/", .q0 = 4, .q1 = 7 },
.{ .expr = "/t.o/", .q0 = 4, .q1 = 7 },
.{ .expr = "1\n", .q0 = 0, .q1 = 4 },
+ // pardes's line:col, composing like any simple address.
+ .{ .expr = "2:2", .q0 = 5, .q1 = 5 },
+ .{ .expr = "2:99", .q0 = 7, .q1 = 7 },
+ .{ .expr = "2:2,3:1", .q0 = 5, .q1 = 8 },
+ .{ .expr = "2:2+#3", .q0 = 8, .q1 = 8 },
}) |c| {
_ = th.wr(p, addr, "#0");
const w = th.wr(p, addr, c.expr);
@@ -420,7 +466,7 @@ test "the address language, form by form" {
for ([_][2][]const u8{
.{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range },
.{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp },
- .{ "/*a/", e_regexp },
+ .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range },
}) |c| {
_ = th.wr(p, addr, "#0");
try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename);
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index b28e02fa..b4144fd2 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -69,13 +69,19 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const before = Serials.snapshot(p);
// An exec answers the pane it went to: a command pane it reused, or
// the terminal it typed into.
+ p.look_missed = false;
const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
look.lookAt(p, id, text);
break :blk null;
};
+ // A look that found nothing answers nothing, not the pane before it.
+ if (!exec and p.look_missed) return;
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
+ // A look's hits buffer is how it found the text, not where: it
+ // answers the pane the hit is selected in.
+ if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue;
noteResult(p, serial);
created = true;
};
@@ -84,6 +90,37 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
+/// A write's messages, a line each, except that an `Edit` line takes the
+/// lines after it while its `{` group or `a`/`c`/`i` text block is open
+/// (sam_edit.needsMore): a block written whole reaches Edit whole, never
+/// as lines of their own.
+const Messages = struct {
+ p: *Pardes,
+ data: []const u8,
+ at: usize = 0,
+
+ fn init(p: *Pardes, data: []const u8) Messages {
+ return .{ .p = p, .data = data };
+ }
+
+ fn next(m: *Messages) ?[]const u8 {
+ if (m.at >= m.data.len) return null;
+ const start = m.at;
+ var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len;
+ m.at = end + 1;
+ const first = std.mem.trim(u8, m.data[start..end], " \t\r");
+ const edit = "Edit";
+ if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
+ return first;
+ const sam = @import("../sam_edit.zig");
+ while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
+ end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
+ m.at = end + 1;
+ }
+ return std.mem.trim(u8, m.data[start..end], " \t\r");
+ }
+};
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
@@ -93,11 +130,11 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const text = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |text| {
if (text.len == 0) continue;
- for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
+ // Only an Edit block holds newlines (Messages).
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL);
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
@@ -205,6 +242,13 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
return tree.failText(req.tag, E.INVAL, text);
}
+/// `refuse`, saying which ctl takes the message instead.
+fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply {
+ const room = p.fs.ename.len -| (why.len + ctl.len + 18);
+ const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why;
+ return tree.failText(req.tag, E.INVAL, text);
+}
+
/// Checks one line written to a ctl as a builtin of `scope` before any line
/// of the write runs: a word the registry knows, of this ctl's scope, given
/// an argument if and only if it takes or requires one, and for a setting a
@@ -216,8 +260,9 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const word = line[0 .. sp orelse line.len];
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
- if (builtins.registry.scope(b) != scope)
- return refuse(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line);
+ // Edit is the one pane word the root takes too: at the active pane.
+ if (builtins.registry.scope(b) != scope and b != .Edit)
+ return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl");
if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
const setting = config.Runtime.find(word) orelse return null;
@@ -298,6 +343,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }),
.window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }),
.window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }),
+ .choice => |which| switch (which) {
+ inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }),
+ },
.message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }),
// Bare, it names the default directory.
.dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
@@ -336,15 +384,16 @@ pub fn readRoot(p: *Pardes, req: Req) Reply {
pub fn writeRoot(p: *Pardes, req: Req) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
continue;
}
if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
if (runBuiltin(p, req, p.active, line)) |refusal| return refusal;
}
}
@@ -445,25 +494,33 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
+ if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said);
const errno = get(p, pane);
if (errno != 0) return Reply.fail(req.tag, errno);
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
- if (other) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
+ if (other) return tree.failText(req.tag, E.BUSY, e_locked);
held = true;
if (apply) pane.fs.lock = mine;
} else if (std.mem.eql(u8, line, "unlock")) {
if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
held = false;
if (apply) pane.fs.lock = null;
+ } else if (std.mem.startsWith(u8, line, "answer ")) {
+ // The pane's question (its `ask` in the log), answered.
+ const choice = std.mem.trim(u8, line["answer ".len..], " ");
+ if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line);
+ const id = p.paneBySerial(serial).?;
+ if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice)
+ return refuse(p, req, "no question asked", line);
+ if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]);
} else if (!apply) {
if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
} else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| {
@@ -475,6 +532,23 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
}
const e_not_locked = "window not locked by this open";
+/// Who holds it is another open of this ctl, whose client pardes cannot name.
+const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
+
+/// acme's get asks winclean first (exec.c:513): unsaved text is warned
+/// about once, and the same get again, nothing edited since, discards it.
+fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
+ const f = pane_files.fileOf(pane) orelse return null;
+ if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null;
+ // Dirty by a rename alone is no text to lose: only edits are asked about.
+ const saved = f.saved_hash orelse return null;
+ if (std.hash.Wyhash.hash(0, f.content) == saved) return null;
+ if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null;
+ pane.discard_warned = .{ .revision = f.revision, .by = .get };
+ const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
+ p.setMessage(p.paneBySerial(pane.serial).?, said);
+ return said;
+}
fn get(p: *Pardes, pane: *Pane) u16 {
const f = pane_files.fileOf(pane) orelse return 0;
@@ -486,6 +560,7 @@ fn get(p: *Pardes, pane: *Pane) u16 {
panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
+ f.saved_hash = std.hash.Wyhash.hash(0, f.content);
return 0;
}
@@ -546,10 +621,23 @@ test "commands lists every builtin with its argument and the ctl that takes it"
try testing.expect(words.next() == null);
}
try testing.expectEqual(std.enums.values(Builtin).len, lines);
- for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
+test "Undo and Redo on a pane's ctl step its body through its edits" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, body, "two\n").reply.status);
+ try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes);
+}
+
test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -604,8 +692,8 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r
var want: [64]u8 = undefined;
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename);
}
- try testing.expectEqualStrings("not a window control message \"Newcol\"", wr(p, ctl_node, "Newcol").reply.ename);
- try testing.expectEqualStrings("not a window control message \"Verbose off\"", wr(p, ctl_node, "Verbose off").reply.ename);
+ try testing.expectEqualStrings("not a window control message \"Newcol\": write it to /ctl", wr(p, ctl_node, "Newcol").reply.ename);
+ try testing.expectEqualStrings("not a window control message \"Verbose off\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename);
try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename);
// Checked whole first: a bad line anywhere and nothing ran.
try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno());
@@ -657,7 +745,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
try testing.expectEqual(columns + 1, p.ncol);
- try testing.expectEqualStrings("not a session control message \"Del\"", wr(p, root_ctl, "Del").reply.ename);
+ try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename);
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
@@ -700,7 +788,7 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob
try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\n").reply.status);
// Another open's lock is refused at once, and so is its unlock; writes
// to the pane's other files are not.
- try testing.expectEqualStrings(tree.e_in_use, w.ctl(p, ctl_node, b, "lock\n").reply.ename);
+ try testing.expectEqualStrings(e_locked, w.ctl(p, ctl_node, b, "lock\n").reply.ename);
try testing.expectEqual(E.BUSY, w.ctl(p, ctl_node, b, "lock\n").errno());
try testing.expectEqualStrings(e_not_locked, w.ctl(p, ctl_node, b, "unlock\n").reply.ename);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .addr), "1").reply.status);
@@ -730,6 +818,112 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob
for (p.fs.opens) |o| try testing.expect(o.node == 0);
}
+test "Edit through a pane's ctl renames every foo in one undo step, and a failed one changes nothing" {
+ const p = try withFile(testing.allocator, "foo x foo y foo\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,x/foo/c/foobar/\n").reply.status);
+ try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes);
+ const failed = wr(p, ctl_node, "Edit ,s/nothing/x/\n");
+ try testing.expectEqual(E.IO, failed.errno());
+ try testing.expectStringStartsWith(failed.reply.ename, "Edit: no substitution");
+ try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes);
+ // The whole rename is one undo step.
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Undo").reply.status);
+ try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes);
+}
+
+test "a write of an Edit block carries it whole, to a pane's ctl, the root's ctl and exec" {
+ const p = try withFile(testing.allocator, "foo x\nfoo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\ni/[/\na/]/\n}\n").reply.status);
+ try testing.expectEqualStrings("[foo] x\n[foo] y\n", rd(p, body, 0, 64).bytes);
+ p.active = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Edit ,x/\\[/{\nc/</\n}\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Edit $a\nend\n.\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes);
+ // An unclosed group is refused, and nothing after it runs as a line.
+ try testing.expectEqual(E.IO, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\nd\n").errno());
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes);
+}
+
+test "dirty goes back to 0 when the text is the saved text again" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const dirty = Node.of(serial, .dirty);
+ try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "two\n").reply.status);
+ try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes);
+}
+
+test "a message from a root ctl write is the session's in the log" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged before the log is opened
+ while (p.nextEffect()) |_| {}
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Kill\n").reply.status);
+ // The builtin's name, then what it said: both the session's.
+ try testing.expectEqualStrings("msg - Kill\n", call(p, .{ .tag = 4, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
+ try testing.expectEqualStrings("msg - Kill: nothing running\n", call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
+}
+
+test "a look takes acme's :addr and file:addr, and a miss changes nothing" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "foo x foo y\nbar\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const look_node = Node.of(serial, .look);
+ const dot = Node.of(serial, .dot);
+ // :addr is an address in this pane, evaluated from its dot.
+ try testing.expectEqual(Status.ok, wr(p, look_node, ":/y/\n").reply.status);
+ try testing.expectEqualStrings(" 10 11 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, ":#4\n").reply.status);
+ try testing.expectEqualStrings(" 4 4 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo/\n").reply.status); // from #4: the second foo
+ try testing.expectEqualStrings(" 6 9 ", rd(p, dot, 0, 64).bytes);
+ // file:addr names the pane's own file here.
+ var line: [256]u8 = undefined;
+ const path = p.panes[0].?.file.?.path;
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:/bar/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes);
+ // A miss: no buffer, the selection kept, and it says so.
+ const panes_before = p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null});
+ try testing.expectEqual(Status.ok, wr(p, look_node, "zzqq\n").reply.status);
+ try testing.expectEqual(panes_before, p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null}));
+ try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: no match");
+ // A hit answers the pane the text is selected in, not its hits buffer.
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ var own: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&own, "{d}\n", .{serial}), rd(p, look_node, 0, 64).bytes);
+ // A pattern with a blank in it; 0/re/ searches from the top, whatever dot is.
+ try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo y/\n").reply.status);
+ try testing.expectEqualStrings(" 6 11 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:0/foo/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
+ // file:N selects the line; a line past the end moves nothing and reads back empty.
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:2\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:99\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqualStrings("", rd(p, look_node, 0, 64).bytes);
+ try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: ");
+}
+
test "exec runs a builtin at the pane and records the pane it acted on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "Msg fs-ran\n");
@@ -886,6 +1080,10 @@ test "get reloads the pane from disk and reports a missing file without losing d
_ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
_ = wr(p, Node.of(serial, .body), "unsaved\n");
+ // Unsaved text is asked about once, as acme's get asks winclean.
+ const asked = wr(p, ctl_node, "get\n");
+ try testing.expectEqual(E.IO, asked.errno());
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno());
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
@@ -999,7 +1197,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" {
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
}
-test "a look that fills a column's stillborn scratch is answered with the scratch already gone" {
+test "a look after Newcol fills the empty column the keyboard is on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -1009,14 +1207,12 @@ test "a look that fills a column's stillborn scratch is answered with the scratc
var dir: [4096]u8 = undefined;
const at = dir[0..try tmp.dir.realPath(testing.io, &dir)];
var line: [4200]u8 = undefined;
- // Newcol makes a column of one empty +New; a file looked from it
- // joins that column and takes the scratch's place.
+ // Newcol makes an empty column and gives its tag the keyboard; no pane
+ // has it, so focus reads empty.
try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status);
- const scratch = p.panes[p.active].?.serial;
- try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status);
- try testing.expect(p.paneBySerial(scratch) == null);
- var want: [32]u8 = undefined;
- const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch});
- var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n');
- while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone));
+ try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]);
+ try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes);
+ try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status);
+ try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]);
+ try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]);
}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index ffdea95b..0e489be7 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -565,6 +565,32 @@ const serialOf = th.serialOf;
const Node = tree.Node;
const Status = tree.Status;
+test "a stream read at an offset inside its last answer gets the rest of it again, as bash's read seeks back" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ p.update(.tick);
+ while (p.nextEffect()) |_| {}
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen: u64 = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+ notePath(p, "dump", "/a");
+ notePath(p, "dump", "/b");
+ const Read = struct {
+ fn at(pp: *Pardes, h: u32, off: u64) []const u8 {
+ return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 128 }).bytes;
+ }
+ };
+ try testing.expectEqualStrings("dump /a\n", Read.at(p, f, frozen));
+ // bash kept "dump" and seeked back to just past it.
+ try testing.expectEqualStrings(" /a\n", Read.at(p, f, frozen + 4));
+ // At the end of what it was given: the next record.
+ try testing.expectEqualStrings("dump /b\n", Read.at(p, f, frozen + 8));
+ // A client that reads every record at one offset still gets new ones.
+ notePath(p, "dump", "/c");
+ try testing.expectEqualStrings("dump /c\n", Read.at(p, f, frozen + 8));
+}
+
test "event records are acme's bytes, one per read, and .again when empty" {
const gpa = testing.allocator;
const p = try withFile(gpa, "Msg fs-ran\n");
@@ -908,8 +934,13 @@ test "a Dump written and a Restore made are in the log, with their files" {
const log = try freezeLog(core);
defer core.gpa.free(log.bytes);
var want: [64]u8 = undefined;
- try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})));
+ try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})) != null);
}
+ // The replacement's last says which serial each pane had.
+ const log = try freezeLog(restored);
+ defer restored.gpa.free(log.bytes);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })));
}
test "opens of the log share the open records, and a closed one frees its record" {
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index d2b2a791..13381066 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -29,14 +29,15 @@ const Node = tree.Node;
/// Filesystem state a pane carries beside its editor state.
pub const State = struct {
/// The range `data` and `xdata` read and write through. acme clears it
- /// when the first client opens `addr` (editors/acme/xfid.c:105), which
- /// suits a client that holds the fid open and leaves a shell reading back
- /// `0 0` from the address it just wrote. Here it is the pane's own
- /// register, cleared by truncating the file, so that `cp addr dot` and
+ /// when the first client opens `addr` (editors/acme/xfid.c:105-108),
+ /// which suits a client that holds the fid open; a shell cannot, and
+ /// would evaluate every `echo /re/ > addr` from #0 and never advance.
+ /// Here it is the pane's own register, cleared by neither an open nor
+ /// a truncation: `0` or `,` starts over, and `cp addr dot` and
/// `cat addr` answer what was written.
addr: Range = .{},
/// The last address written to `addr` failed, so there is none: `data`
- /// and `xdata` refuse until one is written or `addr` is truncated,
+ /// and `xdata` refuse until a good one is written,
/// rather than act at the address before it, which acme would do.
addr_failed: bool = false,
limit: ?Range = null,
@@ -46,6 +47,10 @@ pub const State = struct {
event_reader: bool = false,
events: events.Queue = .{},
nomark: bool = false,
+ /// The revision a truncation of `data`, `xdata` or `body` left: the
+ /// write that follows it at once joins its undo step, so a shell's
+ /// `printf x > data` is one step, as acme's replace is.
+ joined: ?u32 = null,
noscroll: bool = false,
tag_snap: std.ArrayList(u8) = .empty,
/// An open reads pty/data, so output queues for it; a second is refused.
@@ -58,6 +63,9 @@ pub const State = struct {
lock: ?u32 = null,
/// The host started a shell it could not teach to mark its prompts.
unmarked: bool = false,
+ /// The pty's size as pty/ctl's winsize last set it, until the pane's own
+ /// grid resizes and gives the pty its size again.
+ winsize: ?[2]u16 = null,
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
@@ -189,7 +197,9 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
@memcpy(new[0..lo], f.content[0..lo]);
@memcpy(new[lo..][0..take], bytes[0..take]);
@memcpy(new[lo + take ..], f.content[hi..]);
- if (!pane.fs.nomark) panes.File.pushUndo(p, pane);
+ const join = pane.fs.joined == f.revision;
+ pane.fs.joined = null;
+ if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, new);
return take;
}
@@ -507,7 +517,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
const word = std.mem.trim(u8, req.data, " \t\r\n");
- if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "takes 1 or 0");
+ if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "bad flag: takes 1 or 0");
const on = word[0] == '1';
const pf = &pane.fs;
switch (file) {
@@ -515,9 +525,12 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL);
f.saved_revision = if (on) f.revision -% 1 else f.revision;
},
+ // acme's nomark joins the writes after it into one undo step. The
+ // step starts here: the text as it is now is what Undo brings back,
+ // and turning mark on again only ends it.
.mark => {
+ if (!on and !pf.nomark) panes.File.pushUndo(p, pane);
pf.nomark = !on;
- if (on) panes.File.pushUndo(p, pane);
},
.scroll => pf.noscroll = !on,
else => unreachable,
@@ -592,11 +605,11 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
}
const free = p.freeSlot() orelse return null;
const content = p.gpa.dupe(u8, text) catch return null;
- const np = panes.Output.open(p, free, dir, .errors, "", content) catch {
+ _ = panes.Output.open(p, free, dir, .errors, "", content) catch {
p.gpa.free(content);
return null;
};
- exec.placeDoc(p, id, free, np);
+ exec.placeNew(p, id, id, free, .errors);
return text.len;
}
@@ -610,6 +623,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
switch (file) {
.body => if (fileOf(pane) != null) {
_ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err;
+ pf.joined = fileOf(pane).?.revision;
pf.addr = .{};
setDot(pane, .{});
},
@@ -619,6 +633,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const q1 = @max(q0, pf.addr.q1);
const before = dotOf(pane);
_ = spliceBody(p, pane, q0, q1, "") orelse return .err;
+ pf.joined = fileOf(pane).?.revision;
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
pf.addr = .{ .q0 = q0, .q1 = q0 };
},
@@ -634,10 +649,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pane.tag.vsel.active = false;
pane.tag.nsel = 0;
},
- .addr => {
- pf.addr = .{};
- pf.addr_failed = false;
- },
+ // A shell's `>` truncates before it writes: the address written
+ // is evaluated from where the last one left off, as with `>>`.
+ // `0` (or `,`) is how to start over. See State.addr.
+ .addr => {},
.limit => pf.limit = null,
.dot => if (fileOf(pane) != null) setDot(pane, .{}),
else => {},
@@ -875,7 +890,7 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size);
for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| {
try testing.expectEqual(E.INVAL, wr(p, node, bad).errno());
- try testing.expectEqualStrings("takes 1 or 0", wr(p, node, bad).reply.ename);
+ try testing.expectEqualStrings("bad flag: takes 1 or 0", wr(p, node, bad).reply.ename);
}
}
@@ -905,6 +920,34 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno());
}
+test "a replace through data, truncate then write, is one undo step" {
+ const p = try withFile(testing.allocator, "one two\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .addr), "/two/");
+ _ = call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .data), .truncate = true });
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "2").reply.status);
+ try testing.expectEqualStrings("one 2\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one two\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+}
+
+test "writes under mark 0 are one undo step, even with no history before them" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "0").reply.status);
+ _ = wr(p, body, "two\n");
+ _ = wr(p, body, "three\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "1").reply.status);
+ try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes);
+}
+
test "truncating the tag clears its editable tail" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 3599b612..393516ce 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -60,6 +60,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ pane.fs.winsize = .{ cols, rows };
},
.sig => {
const which = signalNamed(words.next() orelse return false) orelse return false;
@@ -79,13 +80,20 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
/// ctl reads back its state in the words it takes, as a Plan 9 ctl does.
pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply {
- p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch
+ const size = winsize(pane);
+ p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ size[0], size[1] }) catch
return Reply.fail(req.tag, E.NOMEM);
return tree.stagedReply(p, req);
}
-/// Three right-aligned fields: cols, rows and busy: whether a run written now
-/// would be told `busy` (a command runs, or text is typed at the prompt).
+/// The pty's size: what ctl's winsize set, else the pane's grid.
+fn winsize(pane: *const Pane) [2]u16 {
+ return pane.fs.winsize orelse .{ pane.cols, pane.rows };
+}
+
+/// Three right-aligned fields: the pty's cols and rows, and busy: whether a
+/// run written now would be told `busy` (a command runs, or text is typed at
+/// the prompt). A newline ends the line, as a Plan 9 status file's does.
pub const status_len: u64 = 36;
pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
@@ -97,7 +105,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or
(if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false)));
const out = p.fs.stage(p.gpa);
- out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, @intFromBool(busy) }) catch {};
+ const size = winsize(pane);
+ out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {};
return tree.stagedReply(p, req);
}
@@ -287,6 +296,12 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
pf.run = null;
+ finish(p, slot, pane, done.status);
+}
+
+/// Answers a run whose command ended with `status`: the header and the
+/// output it printed.
+fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void {
const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch
return answer(p, slot, "error out of memory", .{});
defer p.gpa.free(printed.text);
@@ -305,7 +320,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
// A D that carries no status says nothing of how the command went.
var code: [16]u8 = undefined;
- const status = if (done.status) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
+ const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
// The header is the whole first line, so a count there can never be
// mistaken for output; `cut` with no count: its start is not there to
// count from.
@@ -317,6 +332,22 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
answer(p, slot, "exit {s}", .{status});
}
+/// The shell itself exited (a run's `exit 3`, or one typed): a run waiting
+/// on the command it ran answers with the shell's status and what it
+/// printed, and the log says `exit` before the pane's `del`. Unknown, the
+/// status is not guessed: the run hears `error shell gone` when the pane
+/// goes.
+pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void {
+ const code = status orelse return;
+ if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) {
+ pane.fs.run = null;
+ pardes.panes.Terminal.endOutputHere(pane);
+ finish(p, slot, pane, code);
+ };
+ var buf: [4]u8 = undefined;
+ pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable);
+}
+
/// The pane closed or its shell was replaced: the command's end will never
/// be reported, so the run says so instead of waiting forever. A line not
/// sent yet waits on for the replacement's first prompt.
@@ -466,6 +497,12 @@ test "every pty/ctl verb, and every refusal" {
try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows);
try testing.expectEqual(cols, pane.cols);
try testing.expectEqual(rows, pane.rows);
+ // ctl and status read back the pty's size, until the pane resizes.
+ try testing.expectEqualStrings("winsize 132 44\n", rd(p, ctl, 0, 64).bytes);
+ try testing.expect(std.mem.startsWith(u8, rd(p, Node.of(serialOf(p), .pty_status), 0, 64).bytes, " 132 44 "));
+ p.update(.{ .resize = .{ .cols = 76, .rows = 24 } });
+ try testing.expect(pane.cols != 132);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ pane.cols, pane.rows }), rd(p, ctl, 0, 64).bytes);
for ([_]struct { line: []const u8, want: pardes.PtySignal }{
.{ .line = "sig INT", .want = .int },
@@ -527,6 +564,8 @@ test "owned cwd pty exec rejects long paths before applying its batch" {
const p = try withTerm(testing.allocator);
defer p.deinit();
const pane = p.panes[0].?;
+ // A one-row tag: the long path would wrap it onto rows and resize the pty.
+ pane.tag_expanded = false;
const ctl = Node.of(pane.serial, .pty_ctl);
var path: [1025]u8 = @splat('d');
path[0] = '/';
@@ -554,7 +593,7 @@ test "pty/status reports the grid and who holds the tty" {
const a = rd(p, status, 0, 256);
try testing.expectEqual(Status.ok, a.reply.status);
var want: [64]u8 = undefined;
- const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 });
+ const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 0 });
try testing.expectEqualStrings(whole, a.bytes);
try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len);
try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes);
@@ -563,7 +602,7 @@ test "pty/status reports the grid and who holds the tty" {
p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable };
const held = rd(p, status, 0, 256);
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }),
+ try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 1 }),
held.bytes,
);
}
@@ -645,12 +684,12 @@ test "run answers how its command ended once the shell is back at a prompt" {
// Before its first prompt the shell cannot take a line yet: the line
// waits for it, and pty/status does not call the shell busy meanwhile.
const status = Node.of(serialOf(p), .pty_status);
- try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0 "));
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0\n"));
const early = call(p, .{ .tag = 1, .op = .open, .node = node });
const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" });
try testing.expectEqualStrings("", waiting.pty());
try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
- try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1 "));
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1\n"));
// A respawn before that prompt keeps it waiting for the new shell's.
p.acknowledgeShell(0, "/bin/sh", true);
try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
@@ -746,8 +785,52 @@ test "run answers how its command ended once the shell is back at a prompt" {
_ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" });
try p.removePane(0, null);
try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes);
+ // It still stats while the run is open (a reader's fstat), on the
+ // open or on the file's name, and not once it is released.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node, .handle = gone.reply.handle }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node }).reply.status);
_ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle });
for (p.fs.opens) |o| try testing.expect(o.node == 0);
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 21, .op = .getattr, .node = node }).errno());
+}
+
+test "a shell that exits under a run answers its status, and the log says exit before del" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const node = Node.of(serial, .pty_run);
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ p.update(.{ .output = .{ .pane = 0, .bytes = prompt } });
+ while (p.nextEffect()) |_| {}
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+
+ const r = call(p, .{ .tag = 4, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 5, .op = .write, .node = node, .handle = r.reply.handle, .data = "echo bye; exit 3\n" });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "echo bye; exit 3\r\n\x1b]133;C;aid=pardes\x07bye\r\n" } });
+ p.update(.{ .exited = .{ .pane = 0, .status = 3 } });
+ p.update(.{ .eof = .{ .pane = 0 } });
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.paneBySerial(serial) == null);
+ try testing.expectEqualStrings("exit 3\nbye\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = r.reply.handle, .size = 64 }).bytes);
+ var want: [128]u8 = undefined;
+ // A followed log reads a record at a time: the exit, then the del.
+ var records: [2][]const u8 = undefined;
+ var at = frozen;
+ for (&records) |*rec| {
+ rec.* = try gpa.dupe(u8, call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = at, .size = 4096 }).bytes);
+ at += rec.len;
+ }
+ defer for (records) |rec| gpa.free(rec);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "exit {d} 3\n", .{serial}), records[0]);
+ try testing.expect(std.mem.startsWith(u8, records[1], try std.fmt.bufPrint(&want, "del {d} ", .{serial})));
+ // A write to its pty/data now finds no pane.
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_data), "x").errno());
+ _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = r.reply.handle });
+ _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = f });
}
test "run on a command pane says no shell is there" {
diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig
index fce77f10..927552e2 100644
--- a/src/ninep/testing.zig
+++ b/src/ninep/testing.zig
@@ -56,6 +56,15 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer {
return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size });
}
+/// Whether the log, read whole from a fresh open, holds `needle`.
+pub fn logHas(p: *Pardes, needle: []const u8) bool {
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ return std.mem.indexOf(u8, text, needle) != null;
+}
+
pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer {
return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data });
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index f118e4b4..ad95f6bf 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -130,6 +130,13 @@ pub const Open = struct {
/// place of the older. The listener fills it in and answers it
/// (src/9p_io.zig, `answerHeld`).
held: ?Held = null,
+ /// The last answer a stream open (a followed log, event, pty/data) gave,
+ /// and the offset it was read at. A read at an offset inside it is
+ /// answered from it again: bash's `read` takes a chunk, keeps a line and
+ /// lseeks back to just past it, and a stream has already moved on.
+ replay: ?Replay = null,
+
+ pub const Replay = struct { off: u64, bytes: []u8 };
/// `asker` is the connection the read came on, which only the listener
/// knows.
@@ -145,6 +152,7 @@ pub const Open = struct {
},
.event, .pty_data, .ctl => {},
}
+ if (o.replay) |r| gpa.free(r.bytes);
o.* = .{};
}
};
@@ -377,7 +385,7 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
return switch (req.op) {
.lookup => lookup(p, req, target),
- .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) },
+ .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse runAttr(p, req, target) orelse return Reply.fail(req.tag, E.NOENT) },
.setattr => setattr(p, req, target),
.open => open(p, req, target),
.release => release(p, req),
@@ -387,6 +395,24 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
}
+/// A run's answer outlives its pane, so its file still stats while a run
+/// is open on it (a reader's fstat, cat's), as acme's fid keeps its window.
+/// The stat names the open when it came on one; a mount's comes on the
+/// file's walked name, and serials are never reused, so any run open on
+/// this node is the reader's.
+fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr {
+ const t = switch (target) {
+ .pane => |t| t,
+ .top => return null,
+ };
+ if (t.file != .pty_run) return null;
+ const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| {
+ if (o.node == req.node and o.what == .run) break o;
+ } else null;
+ if ((run orelse return null).what != .run) return null;
+ return .{ .name = t.file.fileName(), .node = Node.of(t.serial, t.file), .mode = t.file.mode(), .mtime = p.fs.started };
+}
+
fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
switch (target) {
.top => |f| return .{
@@ -569,7 +595,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// closing it leaves the pane alone.
.new => {
const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE);
- p.newScratchBelow(p.active);
+ p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
return .{ .tag = req.tag, .handle = made.serial };
},
@@ -692,6 +718,32 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply {
}
fn read(p: *Pardes, req: Req, target: Target) Reply {
+ const stream = if (openOf(p, req)) |o| switch (o.what) {
+ .log => o,
+ .event => |reader| if (reader) o else null,
+ .pty_data => o,
+ else => null,
+ } else null;
+ const s = stream orelse return readFile(p, req, target);
+ // Strictly inside: a client that reads every record at one offset (the
+ // offset is ignored on a stream, as in Plan 9) always gets new ones.
+ if (s.replay) |r| if (req.off > r.off and req.off < r.off + r.bytes.len) {
+ const from = r.bytes[@intCast(req.off - r.off)..];
+ const n = @min(from.len, req.size);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, from[0..n]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
+ };
+ const reply = readFile(p, req, target);
+ if (reply.status != .ok) return reply;
+ const bytes = p.fsPayload(reply);
+ if (bytes.len == 0) return reply;
+ const kept = p.gpa.dupe(u8, bytes) catch return reply;
+ if (s.replay) |r| p.gpa.free(r.bytes);
+ s.replay = .{ .off = req.off, .bytes = kept };
+ return reply;
+}
+
+fn readFile(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
.README => help: {
diff --git a/src/panes.zig b/src/panes.zig
index 8049da4e..8ca2bb4d 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -25,10 +25,12 @@ pub const EditText = struct { text: []u8, row0: i32 };
pub const Pane = struct {
/// The words that throw unsaved text away (builtins.zig warnModified).
- pub const Discarding = enum { Exit, Restore };
+ pub const Discarding = enum { Exit, Restore, get };
- /// One mouse selection (block-shaped), per button. c/r are text-area relative;
- /// r counts from the tag's first row (the body starts at Pane.tag_rows).
+ /// One mouse sweep, per button: the press (c0, r0) and where it is now
+ /// (c1, r1), a stream between them as acme's (edit.sweepCols). c/r are
+ /// text-area relative; r counts from the tag's first row (the body
+ /// starts at Pane.tag_rows).
pub const Sel = struct {
state: enum { none, dragging, done } = .none,
source_id: u64 = 0,
@@ -38,12 +40,15 @@ pub const Pane = struct {
r1: i32 = 0,
};
- /// A rectangle keeps one source span per rendered row. Wrapped segments
- /// remain separate rows when copied, even when they share a source line.
+ /// A captured sweep keeps one source span per rendered row. Wrapped
+ /// segments remain separate rows when copied, even when they share a
+ /// source line.
pub const PointerRow = struct {
row: i32,
lo: usize,
hi: usize,
+ /// Not the sweep's last row: its highlight runs to the right edge.
+ to_edge: bool = false,
prefix: [32]u8 = @splat(0),
prefix_len: u8 = 0,
raw_terminal: bool = false,
@@ -181,6 +186,11 @@ pub const Pane = struct {
/// scrolls to keep its caret in view while it has the keyboard; pointer
/// rows count from this line, not the tag's first.
tag_top: u16 = 0,
+ /// The tag shows all its rows (acme's `tagexpand`, on by default); off,
+ /// one row, as acme's Tagup leaves it. Down on the
+ /// tag's last line and Up on its first, typing, or Alt-Down and Alt-Up
+ /// in the tag, as acme's arrows in a tag (plan9port text.c:776).
+ tag_expanded: bool = true,
/// How long the tag's computed prefix was when its positions were last
/// placed. They count from the start of the prefix, so when it grows or
/// shrinks (a rename, the dirty marker, a PDF's page) what follows it
@@ -512,6 +522,16 @@ pub const Pane = struct {
}
}
+ /// Every line of the file's text fits in the body's rows; the empty
+ /// line after a final newline is no text.
+ pub fn textFits(pane: *Pane) bool {
+ if (pane.file) |*f| {
+ const lines = File.nlines(pane.gpa, f) - @intFromBool(std.mem.endsWith(u8, f.content, "\n"));
+ return lines <= pane.rows;
+ }
+ return false;
+ }
+
pub fn ensureCursorVisible(pane: *Pane) void {
const off = pane.scroll();
var in_context = false;
@@ -539,7 +559,13 @@ pub const Pane = struct {
pane.scrollBy(pane.body.cur_row - margin - off);
} else if (pane.body.cur_row > last - margin) {
var to = pane.body.cur_row + margin;
- if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1)));
+ if (pane.file) |*f| {
+ // The margin only reveals text: a pane its whole text
+ // fits in never scrolls for it. A longer one keeps
+ // helix's clamp at the buffer's last line.
+ const lines: i32 = @intCast(File.nlines(pane.gpa, f));
+ to = @min(to, if (off == 0 and pane.textFits()) @max(pane.body.cur_row, lines - 1 - @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) else lines - 1);
+ }
if (comptime Pdf.enabled) {
if (pane.pdf) |pv|
to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
diff --git a/src/pardes.zig b/src/pardes.zig
index eac21a14..6294b6cb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -452,6 +452,7 @@ test {
_ = @import("tagline.zig");
_ = @import("exec.zig");
_ = @import("look.zig");
+ _ = @import("sam_edit.zig");
_ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("regexp.zig");
@@ -1091,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes
try std.testing.expectEqual(@as(usize, 2), p.ncol);
p.active = 0; // the left column is current
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
const lf = layout.findPane(p, 0) orelse return error.LostLeftPane;
const rf = layout.findPane(p, right) orelse return error.LostRightPane;
try std.testing.expectEqual(lf.col, rf.col);
- layout.joinCol(p); // no right neighbor left: inert
+ layout.joinCol(p, 0); // no right neighbor left: inert
try std.testing.expectEqual(@as(usize, 1), p.ncol);
}
@@ -1178,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" {
defer p.deinit();
const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 });
defer tty.deinit();
+ p.settings.placement = .pardes;
+ tty.settings.placement = .pardes;
if (collapsed) {
layout.toggleCollapse(p, from);
layout.toggleCollapse(tty, from);
@@ -1190,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" {
// The Look origin need not be the currently active pane.
p.active = (from + 1) % 3;
const free = p.freeSlot().?;
- const doc = try p.openTutorView(free);
- exec.placeDoc(p, from, free, doc);
+ _ = try p.openTutorView(free);
+ exec.placeNew(p, from, from, free, .doc);
_ = exec.spawnTty(tty, from);
p.sync();
tty.sync();
@@ -1214,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it
for ([_]u16{ 199, 200 }) |width| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 });
defer p.deinit();
+ p.settings.placement = .pardes;
const shell = p.panes[0].?;
- const doc = try p.openTutorView(1);
- exec.placeDoc(p, 0, 1, doc);
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
p.sync();
try std.testing.expectEqual(shell, p.panes[0].?);
try std.testing.expectEqual(@as(usize, 1), p.active);
@@ -1232,6 +1236,167 @@ test "first document keeps its originating shell and only opens a column when it
}
}
+test "acme placement: an empty active column takes the new pane whole" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+ p.newColumn(0); // its tag has the keyboard: it is the active column
+ tagline.exitHeader(p);
+ p.sync();
+ // A Look from the shell's text still lands in the active column, as
+ // acme's makenewwindow puts it in activecol first (util.c:456).
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
+ try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqual(@as(?usize, 1), p.activeColumn());
+ // Never a new column.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+}
+
+test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 });
+ defer p.deinit();
+ const top = p.active;
+ _ = try p.setTestFile("one\ntwo\nthree\n");
+ p.sync();
+ p.setActiveColumn(0);
+ // Most of the pane is blank: a document looked from it starts under its
+ // three lines (makenewwindow, util.c:482-486).
+ const second = p.freeSlot().?;
+ _ = try p.openTutorView(second);
+ exec.placeNew(p, top, top, second, .doc);
+ p.sync();
+ const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3;
+ try std.testing.expectEqual(text_end, p.rects[second].y);
+ // Fill both with text: no blank space is big, so the biggest pane is
+ // halved.
+ const full = "x\n" ** 80;
+ for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full));
+ p.sync();
+ const big = if (p.rects[second].h >= p.rects[top].h) second else top;
+ const was = p.rects[big];
+ const third = p.freeSlot().?;
+ _ = try p.openTutorView(third);
+ exec.placeNew(p, second, second, third, .doc);
+ p.sync();
+ try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]);
+ try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y);
+ try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h);
+}
+
+test "acme placement: New and 9P's new take the bottom half of the last pane" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const last = p.active;
+ p.sync();
+ const was = p.rects[last];
+ p.newScratchFor(last, .fs);
+ const made = p.active;
+ p.sync();
+ try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h);
+ try std.testing.expect(p.rects[made].y > was.y + BOX_H);
+}
+
+test "Placement pardes brings back the old rules, and says so in Config" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
+ try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement);
+ const top = p.active;
+ p.sync();
+ const was = p.rects[top];
+ p.newScratchBelow(top);
+ p.sync();
+ // Directly under the asking pane, as New always put it.
+ try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement"));
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+}
+
+test "an empty column shows its theme's empty_col under its tag: acme's white, else the border" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 20 });
+ defer p.deinit();
+ p.newColumn(0);
+ tagline.exitHeader(p);
+ inline for (.{ "acme", "dark" }) |name| {
+ try std.testing.expect(p.executeBuiltinLine(0, "Theme " ++ name));
+ p.chrome_animation.displayed = colors.ChromeTheme.fromTheme(p.theme());
+ p.sync();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const s = try p.render(arena.allocator());
+ const cell = s.at(p.col_x[1] + 3, p.screen_h - 2);
+ const want = p.chromeTheme().empty_col;
+ try std.testing.expectEqual(want, cell.style.bg.rgb);
+ if (std.mem.eql(u8, name, "acme")) try std.testing.expectEqual([3]u8{ 0xff, 0xff, 0xff }, want) else try std.testing.expectEqual(p.chromeTheme().border, want);
+ }
+}
+
+test "BootShell keep leaves the untouched boot shell a dragged document joins; replace closes it" {
+ inline for (.{ "keep", "replace" }) |word| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const shell = p.active;
+ try std.testing.expect(p.panes[shell].?.isTerminal());
+ try std.testing.expect(p.executeBuiltinLine(shell, "BootShell " ++ word));
+ _ = try p.openTutorView(1);
+ layout.insert(p, 0, 1, 1);
+ p.sync();
+ exec.evictLonePristineTty(p, 0, 1);
+ p.sync();
+ const kept = std.mem.eql(u8, word, "keep");
+ try std.testing.expectEqual(kept, p.panes[shell] != null);
+ try std.testing.expectEqual(@as(usize, if (kept) 2 else 1), p.col_n[0]);
+ }
+}
+
+test "a pane its whole text fits in never scrolls to keep a margin" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\ntwo\nthree\n");
+ // A second pane under it, as acme's placement puts one: right under the
+ // three lines, which leaves the file exactly three body rows.
+ const below = p.freeSlot().?;
+ _ = try p.openTutorView(below);
+ exec.placeNew(p, 0, 0, below, .doc);
+ p.active = 0;
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 3), p.rects[0].h - pane.tag_rows);
+ for ("jj") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.sync();
+ try std.testing.expectEqual(@as(i32, 2), pane.body.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.scroll());
+}
+
+test "dragging a column's only pane to another leaves that column in place, empty" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
+ const moved = p.active;
+ p.sync();
+ const weights = p.col_weight;
+ const tag_serial = p.col_serial[1];
+ layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2);
+ p.sync();
+ // acme's coldragwin: colclose then coladd (cols.c:516-517); the column
+ // left behind keeps its place, width and tag.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(tag_serial, p.col_serial[1]);
+ try std.testing.expectEqualDeep(weights, p.col_weight);
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[0]);
+ // ...and a pane dragged into the empty column takes it whole.
+ layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2);
+ p.sync();
+ try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[0]);
+}
+
test "pane defaults keep creation of scratch panes in column tags" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -1250,37 +1415,19 @@ test "pane defaults keep creation of scratch panes in column tags" {
try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file));
}
-test "an unsaved file marker sits between its path and builtins until Save" {
+test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = try p.setTestFile("before\n");
-
const clean = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null);
-
panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n"));
const dirty = try tagline.tagText(p, p.scratch.allocator(), pane);
- const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker;
- const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin;
- try std.testing.expect(marker_at < save_at);
-
- try std.testing.expect(p.executeBuiltinLine(0, "Save"));
- while (p.nextEffect()) |effect| p.perform(effect);
- const saved = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null);
-
- panes.File.changed(p, 0, "external\n");
- const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null);
-
- // A generated output is file-shaped and Save can write it to a path, but
- // there is no file of its own for it to be dirty against.
- pane.file.?.output = .{ .from = .search };
- panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n"));
- const output = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null);
+ try std.testing.expectEqualStrings(clean, dirty);
+ try std.testing.expect(std.mem.indexOf(u8, dirty, " *") == null);
+ // The dirty file and index's flag still say it.
+ try std.testing.expect(ctlfs.pane.dirtyOf(pane));
}
test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" {
@@ -1491,6 +1638,11 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
_ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true);
Sent.drain(p);
try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1));
+ // A block left open by its indented last line gets the blank line that
+ // ends it: a second Enter.
+ _ = exec.executeFrom(p, 0, "def f():\n return 1", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("def f():\r return 1\r\r", Sent.said(1));
// Several selections executed at once: each is sent, not the first.
{
const text = try edit.flatSurface(p, &file.body);
@@ -1526,6 +1678,16 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
Sent.drain(p);
try std.testing.expectEqualStrings("print(2)\r", Sent.said(2));
try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none);
+ // The question is in the log, and a script answers it on the ctl.
+ {
+ const th = @import("ninep/testing.zig");
+ _ = exec.executeFrom(p, 0, "print(5)", true);
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} repl a b\n", .{file.serial})));
+ const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
+ try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
+ try std.testing.expectEqualStrings("print(5)\r", answered.pty());
+ }
_ = exec.executeFrom(p, 0, "print(3)", true);
p.update(.{ .key = .{ .cp = Key.escape } });
Sent.drain(p);
@@ -2261,7 +2423,7 @@ test "a save the host could not do leaves the pane dirty" {
const Refusing = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void {
const core: *Pardes = @ptrCast(@alignCast(ctx.?));
- core.saveFailed(pane, "save", error.PermissionDenied);
+ core.saveFailed(pane, "/x/y.txt", error.PermissionDenied);
}
};
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -3046,7 +3208,7 @@ test "column tags follow their column through split and close" {
try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0));
p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep");
p.active = p.col_panes[0][0];
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0));
}
@@ -3055,7 +3217,8 @@ test "column tags render edit hover and scroll from the column edge" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.acknowledgePanelPresentation(&.{});
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
@@ -3111,7 +3274,8 @@ test "column tag first-cell execution keeps New local to the clicked column" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
for (0..2) |column| {
p.active = p.col_panes[1 - column][0];
@@ -3147,7 +3311,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" {
try std.testing.expectEqual(@as(usize, 0), p.header_scroll);
}
-test "a column tag with a newline in it takes a row per line and pushes its panes down" {
+test "a newline typed into a column tag is a space: the tag stays one row" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
@@ -3157,26 +3321,9 @@ test "a column tag with a newline in it takes a row per line and pushes its pane
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } });
p.update(.{ .key = .{ .cp = Key.enter } });
p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } });
- try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0));
- try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight());
- const frame = try p.render(p.scratch.allocator());
- try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y);
- try std.testing.expectEqual(TOPBAR_H + 1, frame.cursor.?.y);
- try std.testing.expectEqual(@as(u8, 'S'), frame.at(TAG_TEXT_INSET, TOPBAR_H + 1).text[0]);
-
- // A click on the second row lands on the second line.
- p.update(.{ .key = .{ .cp = Key.escape } });
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_row);
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_col);
-
- // Taking the newline back out gives the panes their row again.
- p.update(.{ .key = .{ .cp = Key.home } });
- p.update(.{ .key = .{ .cp = Key.backspace } });
- try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0));
_ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqualStrings("New Snarf", tagline.columnTag(p, 0));
+ try std.testing.expectEqual(COLBAR_H, p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y);
}
@@ -3188,7 +3335,7 @@ test "tiny screens keep pane space before adding a column tag" {
_ = try p.render(p.scratch.allocator());
}
-test "ColumnTags reclaims the row and releases hidden header focus without losing commands" {
+test "a screen too short for the column row releases a column tag's focus without losing its commands" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("unchanged body\n");
@@ -3200,24 +3347,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
try std.testing.expect(p.header_focus and p.header_column != null);
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(!p.settings.column_tags);
- try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
- try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
- try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h);
- try std.testing.expect(!p.header_focus and p.header_column == null);
- try std.testing.expect(!p.header_drag);
- try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0));
-
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(p.settings.column_tags);
- try std.testing.expectEqual(shown, p.rects[0]);
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- try std.testing.expect(p.header_column != null);
p.update(.{ .resize = .{ .cols = 60, .rows = 2 } });
- try std.testing.expect(p.settings.column_tags);
try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
try std.testing.expect(!p.header_focus and p.header_column == null);
@@ -3267,15 +3397,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does"
p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?);
p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- // and the header lets go of the keyboard, as a pane tag does
- try std.testing.expect(!p.header_focus);
+ // and the workspace tag lets go of the keyboard: the new, empty
+ // column's tag has it
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
}
test "window keys walk from a pane up through the column and workspace tags and back" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.active = p.col_panes[0][0];
const window = struct {
@@ -3562,6 +3694,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" {
const pane = p.panes[id].?;
try p.deinitPane(pane);
layout.removePane(p, id);
+ layout.dropColumn(p, id);
p.panes[id] = null;
}
while (p.nextEffect()) |_| {}
@@ -4104,9 +4237,15 @@ pub const Pardes = struct {
look_walk_owner: ?u32 = null,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
- /// The pane (by serial) that joined a column this step; `replaceStillborn`
- /// checks it once the step is over and nothing holds the column's panes.
- stillborn_joiner: ?u32 = null,
+ /// The column whose tag the running command came from, while it runs:
+ /// the column acme's commands take from their Text's col.
+ exec_column: ?usize = null,
+ /// acme's activecol (dat.c:37), by column serial, 0 for none: the
+ /// column last typed in (acme.c:487), clicked in with the select button
+ /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new
+ /// pane (util.c:467). Where the acme placement puts new panes
+ /// (exec.placeNew). A Look click moves the keyboard but not this.
+ active_column: u32 = 0,
settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } },
locations_config: locations_config.Config = .{},
locations_cache: @import("locations_cache.zig").Cache = .{},
@@ -4141,6 +4280,13 @@ pub const Pardes = struct {
pointer_tag_hit: ?TagHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
+ /// The last plain B1 click, for acme's double-click (text.c:881,
+ /// textselect: a second click at the same point within 500 ms).
+ last_click: ?struct { id: usize, on_tag: bool, row: i32, col: i32, ms: i64 } = null,
+ /// A B1 chord's edits share the undo point it took (mouse.zig).
+ chord_hold: bool = false,
+ /// The look just run found nothing (look.zig missed).
+ look_missed: bool = false,
look_hover_wait: ?look.LookHoverWait = null,
look_hover_preview: ?look.LookHoverPreview = null,
pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},
@@ -4356,10 +4502,12 @@ pub const Pardes = struct {
p.panes[0].?.body.mode = .tty;
p.oneColumn();
// A bare tty is still an editor: it opens with somewhere to put
- // text under the shell. The shell keeps the keyboard.
+ // text under the shell. The shell keeps the keyboard. A boot
+ // layout, like .classic's, not a placement.
if (p.boot == .tty) {
- p.newScratchBelow(0);
- p.active = 0;
+ const np = try p.newScratch(0, 1);
+ layout.insert(p, 0, 1, 1);
+ layout.splitBelow(p, 0, np);
}
},
.classic => {
@@ -4466,24 +4614,21 @@ pub const Pardes = struct {
return a;
}
- /// The workspace tag's height: a row per line of it while the setting
- /// keeps it, zero once a native shell takes its commands over. Layout and
- /// hit testing read this rather than the constant, the way they read
- /// `columnBarHeight`.
+ /// The workspace tag's height: one row while the setting keeps it, as
+ /// acme's row tag is, zero once a native shell takes its commands over.
+ /// Layout and hit testing read this rather than the constant, the way
+ /// they read `columnBarHeight`.
pub fn topBarHeight(p: *const Pardes) u16 {
if (!p.settings.workspace_tag) return 0;
- const lines = if (p.global_tag.own) |own| std.mem.count(u8, own, "\n") + 1 else TOPBAR_H;
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(TOPBAR_H, p.screen_h / 3)));
+ return TOPBAR_H;
}
- /// The column tags' height: a row per line of the tallest one.
+ /// The column tags' height: one row, as acme's (cols.c:244, colresize,
+ /// gives a column tag one font height); a newline in one is a space
+ /// (tagline.flattenHeaders).
pub fn columnBarHeight(p: *const Pardes) u16 {
- if (!p.settings.column_tags or p.screen_h < 3) return 0;
- var lines: usize = COLBAR_H;
- for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| {
- lines = @max(lines, std.mem.count(u8, own, "\n") + 1);
- };
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3)));
+ if (p.screen_h < 3) return 0;
+ return COLBAR_H;
}
fn detachCwds(p: *Pardes, parents: []const *Pane) !void {
@@ -4618,34 +4763,56 @@ pub const Pardes = struct {
return np;
}
- /// The empty pane that stands in for one that closed, keeping its column
- /// alive and its directory. Unlike `newScratch` it inherits nothing: the
- /// pane it replaces is on its way out.
- fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane {
- const content = try p.gpa.dupe(u8, "");
- errdefer p.gpa.free(content);
- const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content);
- np.body.cur_pinned = true;
- return np;
- }
-
- /// New: a scratch below the calling pane, in its column.
+ /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew
+ /// places it.
pub fn newScratchBelow(p: *Pardes, from_id: usize) void {
- const free = p.freeSlot() orelse return;
- const sf = layout.findPane(p, from_id) orelse return;
- const np = p.newScratch(from_id, free) catch return;
- layout.insert(p, sf.col, sf.idx + 1, free);
- layout.splitBelow(p, from_id, np);
- p.active = free;
+ p.newScratchFor(from_id, .scratch);
}
- /// Newcol: a scratch in a fresh column beside the calling pane.
- pub fn newScratchColumn(p: *Pardes, from_id: usize) void {
+ /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
+ pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
const free = p.freeSlot() orelse return;
- if (!layout.canSplitColumn(p, from_id)) return;
_ = p.newScratch(from_id, free) catch return;
- std.debug.assert(layout.splitColumn(p, from_id, free, false));
- p.active = free;
+ exec.placeNew(p, from_id, from_id, free, kind);
+ }
+
+ /// Newcol: an empty column right of the calling pane's, the keyboard
+ /// on its tag, for the next pane made to fill.
+ pub fn newColumn(p: *Pardes, from_id: usize) void {
+ const col = p.keyboardColumn(from_id) orelse return;
+ const c = layout.insertColumn(p, col, false) orelse return;
+ tagline.enterHeader(p, c);
+ }
+
+ pub fn setActiveColumn(p: *Pardes, c: usize) void {
+ if (c < p.ncol) p.active_column = layout.columnSerial(p, c);
+ }
+
+ /// The active column, while it is still there.
+ pub fn activeColumn(p: *const Pardes) ?usize {
+ return layout.columnBySerial(p, p.active_column);
+ }
+
+ /// The column the keyboard is in: its tag's, or its pane's.
+ pub fn noteTyped(p: *Pardes) void {
+ if (p.header_focus) {
+ if (p.header_column) |c| p.setActiveColumn(c);
+ } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col);
+ }
+
+ /// New, run from column `c`'s tag.
+ pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void {
+ p.exec_column = c;
+ defer p.exec_column = null;
+ p.newScratchBelow(from_id);
+ }
+
+ /// The column a command acts on: the one whose tag it came from, else
+ /// the one whose tag has the keyboard, else `id`'s.
+ pub fn keyboardColumn(p: *Pardes, id: usize) ?usize {
+ if (p.exec_column) |c| return c;
+ if (p.header_focus) if (p.header_column) |c| return c;
+ return if (layout.findPane(p, id)) |f| f.col else null;
}
pub fn freeSlot(p: *Pardes) ?usize {
@@ -4916,15 +5083,23 @@ pub const Pardes = struct {
if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes);
// The in-process filesystem reports the same way a real host does, so
// an OOM here leaves the pane dirty rather than looking saved.
- if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory);
+ if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, path, error.OutOfMemory);
}
- pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void {
+ /// A write of `path` the host could not do: the pane stays dirty, the
+ /// message row says `Save <path>: <why>`, and the 9P write that asked
+ /// for it, waiting on the save, fails with that (`fs.late_failure`).
+ pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
f.saved_revision = f.revision -% 1;
};
- p.reportError(id, what, err);
+ var what: [limits.host_path_cap + 8]u8 = undefined;
+ p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
+ const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
+ const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
+ @memcpy(p.fs.late_failure[0..kept.len], kept);
+ p.fs.late_failure_len = @intCast(kept.len);
}
/// The path a watch is about: a real file's, or a PDF's.
@@ -5091,10 +5266,6 @@ pub const Pardes = struct {
look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply);
- // A pane that joined a column holding only a stillborn scratch takes
- // it now, before the answer: one read of index after the look shows
- // the replacement done, and the log has its del with the new.
- exec.replaceStillborn(p);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -5106,7 +5277,6 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
- defer exec.replaceStillborn(p);
// A tick with nothing animating is the one event that cannot change
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
@@ -5191,8 +5361,11 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
- exec.commandDone(p, e.pane, e.status);
+ .exited => |e| if (p.panes[e.pane]) |pane| {
+ if (pane.command == null)
+ ctlfs.pty.shellExited(p, pane, e.status)
+ else if (!pane.command_done)
+ exec.commandDone(p, e.pane, e.status);
},
.eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
// Output is over; the command is over when its child exits.
@@ -5236,6 +5409,7 @@ pub const Pardes = struct {
p.pointer_tag_hit = null;
},
.tick => {
+ mouse.edgeScroll(p);
p.chrome_animation.advance();
p.presentation.advance();
look.advanceLookHover(p);
@@ -5320,36 +5494,66 @@ pub const Pardes = struct {
}
}
- fn handleKey(p: *Pardes, key: Key) void {
- if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
- // A question waiting on a pane takes the next key, whatever mode the
- // pane or a header edit is in: that key is its answer.
- for (p.panes, 0..) |slot, id| {
- const asked = slot orelse continue;
- if (asked.prompt == .repl_choice) {
+ /// Answers the question pane `id` asks on its notice band, logged as
+ /// `ask <serial> <what> <choices>`: Del's side (`k` above, `j` below) or
+ /// the REPL to send to (its letter). Null, or a choice not offered,
+ /// takes the question back. A key answers it, or `answer` on the pane's
+ /// ctl; false when the pane asks nothing.
+ pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
+ const asked = p.panes[id] orelse return false;
+ switch (asked.prompt) {
+ .repl_choice => {
asked.prompt = .none;
- const ask = p.repl_ask orelse return;
+ const ask = p.repl_ask orelse return true;
p.repl_ask = null;
defer p.gpa.free(ask.text);
- // Its letter sends it there; anything else takes it back.
- if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return;
- const serial = ask.offered[key.cp - 'a'];
- if (serial == 0) return;
+ const letter = choice orelse return true;
+ if (letter < 'a' or letter > 'z') return true;
+ const serial = ask.offered[letter - 'a'];
+ if (serial == 0) return true;
// The pane offered, still that REPL and still running: it
// may have closed or been rebound while the question stood.
if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r|
- if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to))
- return exec.sendToRepl(p, id, to, ask.text);
+ if (r.lang == ask.lang and r.letter == letter and exec.replLive(p, to)) {
+ exec.sendToRepl(p, id, to, ask.text);
+ return true;
+ };
p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent");
- return;
+ },
+ .del_side => {
+ asked.prompt = .none;
+ if (choice == 'k') exec.runBuiltin(p, .Del, id, "", "k");
+ if (choice == 'j') exec.runBuiltin(p, .Del, id, "", "j");
+ },
+ else => return false,
+ }
+ return true;
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ // A double-click is two clicks with nothing between them.
+ p.last_click = null;
+ if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
+ // A question waiting on a pane takes the next key, whatever mode the
+ // pane or a header edit is in: that key is its answer.
+ for (p.panes, 0..) |slot, id| {
+ const asked = slot orelse continue;
+ switch (asked.prompt) {
+ // Its letter sends it there; anything else takes it back.
+ .repl_choice => _ = p.answerQuestion(id, if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') null else @intCast(key.cp)),
+ .del_side => _ = p.answerQuestion(id, if (hit(key, config.del_above)) 'k' else if (hit(key, config.del_below)) 'j' else null),
+ else => continue,
}
- if (asked.prompt != .del_side) continue;
- asked.prompt = .none;
- if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k");
- if (hit(key, config.del_below)) exec.runBuiltin(p, .Del, id, "", "j");
return;
}
const pane = p.panes[p.active] orelse return;
+ p.noteTyped();
+ // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in
+ // either mode: acme's Tagdown and Tagup (plan9port text.c:776).
+ if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) {
+ pane.tag_expanded = key.cp == Key.down;
+ return;
+ }
// An open prompt takes the keys even when it answers for a header.
const header = if (pane.prompt != .none) null else tagline.focusedHeader(p);
if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) {
@@ -5398,16 +5602,11 @@ pub const Pardes = struct {
// global window ops (any mode): Alt-n new terminal below, Alt-c move
// the active terminal into a fresh column
if (hit(key, config.new_shell_below)) {
- tagline.exitHeader(p);
const free = p.freeSlot() orelse return;
const nt = p.newShell(free, "") catch return;
nt.greet = true;
- const src = layout.splitParent(p, p.active);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ // From a tag, as a 9P new is: acme's t->w == nil.
+ exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell);
return;
}
for (config.jump_keys) |jk| {
@@ -5486,6 +5685,9 @@ pub const Pardes = struct {
const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
break :word shown[b.lo..b.hi];
};
+ // A column tag's command acts on that column, empty or not.
+ p.exec_column = if (header != null) p.header_column else null;
+ defer p.exec_column = null;
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
@@ -5950,59 +6152,38 @@ pub const Pardes = struct {
// ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
/// Close a pane; its rows go to `to`, or where layout.absorbVWeight
- /// sends them when that is null. Focus on it follows its rows: to the
- /// pane that grew into them, or to the scratch left standing in an
- /// emptied column. Every route that closes a pane comes through here or
- /// removeColumn, so none picks focus for itself, and none by recency.
+ /// sends them when that is null. Focus on it follows its rows to the
+ /// pane that grew into them. A column outlives its panes, as acme's
+ /// does (cols.c:186): closing its last one leaves it empty, the keyboard
+ /// on its tag; only Delcol takes a column away. The session's last pane
+ /// closing ends the session. Every route that closes a pane comes
+ /// through here or removeColumn, so none picks focus for itself, and
+ /// none by recency.
pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void {
const pane = p.panes[id] orelse return;
- // A column outlives its panes. Closing the last one leaves an empty
- // pane standing in its place and in its directory; only Delcol ever
- // takes a column away.
const place = layout.findPane(p, id);
- const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false;
- var dir_buf: [limits.host_path_cap]u8 = undefined;
- var dir: []const u8 = "";
- if (last_in_column) {
- const had = paneDir(pane);
- const n = @min(had.len, dir_buf.len);
- @memcpy(dir_buf[0..n], had[0..n]);
- dir = dir_buf[0..n];
- }
+ const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null;
// Before anything is torn down, so a failure here changes nothing.
try p.deinitPane(pane);
- // ...and after it, so the stand-in inherits none of the closing pane.
- const heir: ?usize = heir: {
- if (!last_in_column) break :heir null;
- const f = place.?;
- const free = p.freeSlot() orelse break :heir null;
- _ = p.newScratchIn(free, dir) catch break :heir null;
- layout.insert(p, f.col, f.idx + 1, free);
- break :heir free;
- };
const gainer = layout.absorbVWeight(p, id, to);
- const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null;
+ const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null;
layout.removePane(p, id);
p.panes[id] = null;
- if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ if (p.active != id) return;
+ p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty();
+ if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c);
}
- pub fn removeColumn(p: *Pardes, id: usize) !void {
- const place = layout.findPane(p, id) orelse return;
- const had_focus = layout.findPane(p, p.active);
- const beside = if (had_focus != null and had_focus.?.col == place.col)
- p.besideColumn(place.col, p.active)
- else
- null;
+ /// Delcol: close column `col` and every pane in it.
+ pub fn removeColumn(p: *Pardes, col: usize) !void {
+ if (col >= p.ncol) return;
+ const had_focus = p.keyboardColumn(p.active) == col;
+ const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null;
var ids: [MAX_PANES]usize = undefined;
var parents: [MAX_PANES]*Pane = undefined;
- const count = p.col_n[place.col];
+ const count = p.col_n[col];
for (0..count) |i| {
- ids[i] = p.col_panes[place.col][i];
+ ids[i] = p.col_panes[col][i];
parents[i] = p.panes[ids[i]].?;
}
try p.detachCwds(parents[0..count]);
@@ -6011,11 +6192,14 @@ pub const Pardes = struct {
layout.removePane(p, closed);
p.panes[closed] = null;
}
- if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ layout.dropColumn(p, col);
+ if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty();
+ }
+
+ /// No pane is left: the session is over.
+ fn quitEmpty(p: *Pardes) void {
+ p.quit = true;
+ p.emit(.quit);
}
/// The pane Last should fall back to when the jumplist has nothing: the
@@ -6034,14 +6218,13 @@ pub const Pardes = struct {
return null;
}
- /// The pane that takes over `id`'s place when column `col` goes: in the
- /// column layout.removePane gives the width to, the one level with the
- /// middle of `id`. Asked before the column goes, while rects still hold.
- fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize {
+ /// The pane that takes over the keyboard from column `col`: in the
+ /// column layout.dropColumn gives the width to, the one level with the
+ /// middle of `r` (of the screen without one). Asked while rects hold.
+ fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize {
if (p.ncol < 2) return null;
const next = if (col > 0) col - 1 else col + 1;
- const r = p.rects[id];
- const mid = r.y + r.h / 2;
+ const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2;
var pick: ?usize = null;
for (p.col_panes[next][0..p.col_n[next]]) |k| {
if (p.panes[k] == null) continue;
@@ -6122,6 +6305,7 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
pub fn sync(p: *Pardes) void {
+ tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
}
@@ -6172,11 +6356,30 @@ pub const Pardes = struct {
}
pane.tag_lead = lead;
}
- const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
- pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
+ // A row per shown row: the tag's lines wrapped at its width.
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
+ var wanted = lines;
+ var caret_row: usize = @intCast(@max(0, pane.tag.cur_row));
+ if (tagline.prefixIn(wa, pane)) |prefix| {
+ if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| {
+ if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| {
+ lines = shown.rows();
+ // Collapsed, one row, as acme's Tagup sets taglines to 1
+ // (plan9port text.c:786).
+ wanted = if (pane.tag_expanded) lines else 1;
+ const cur = modal.offsetAt(whole, .{ .row = caret_row, .col = @intCast(@max(0, pane.tag.cur_col)) });
+ caret_row = std.mem.count(u8, shown.text[0..@min(shown.fromReal(cur), shown.text.len)], "\n");
+ } else |_| {}
+ } else |_| {}
+ } else |_| {}
+ if (!pane.tag_expanded) wanted = 1;
+ pane.tag_rows = @intCast(@min(wanted, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
if (pane.collapsed) pane.tag_rows = BOX_H;
if (pane.focus == .tag and pane.prompt != .name) {
- const cur: usize = @intCast(@max(0, pane.tag.cur_row));
+ const cur: usize = caret_row;
var top: usize = pane.tag_top;
if (cur < top) top = cur;
if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows;
@@ -6191,6 +6394,7 @@ pub const Pardes = struct {
panes.Terminal.resizeGrid(pane, p.gpa, cols, rows);
p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ pane.fs.winsize = null;
}
pane.cols = cols;
pane.rows = rows;
@@ -6660,43 +6864,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expect(pane.prompt == .none and pane.input.own == null);
}
-test "a stillborn pane gives its column to the first pane that joins it" {
+test "Newcol makes an empty column, and the next pane made from its tag fills it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
defer p.deinit();
- try std.testing.expect(p.executeBuiltinLine(0, "Newcol"));
- const blank = p.active;
- p.sync(); // Tty splits by the rects a frame has laid out
- try std.testing.expect(p.executeBuiltinLine(blank, "Tty"));
+ const first = p.active;
+ const panes_before = p.col_n[0];
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(panes_before, p.col_n[0]);
+ // The keyboard is on the new column's tag; no pane has it.
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
+ try std.testing.expectEqual(first, p.active);
+ p.sync();
+ try std.testing.expect(p.executeBuiltinLine(first, "Tty"));
const shell = p.active;
- p.update(.tick); // the step ends: the placeholder goes
- try std.testing.expect(p.panes[blank] == null);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]);
- try std.testing.expectEqual(shell, p.active);
+ try std.testing.expect(shell != first);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(shell, p.col_panes[1][0]);
+ try std.testing.expect(!p.header_focus);
- // Typed into, body or tag, it is a pane like any other.
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const typed = p.active;
+ // The next Tty splits as it always has: nothing is empty any more.
p.sync();
- for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- try std.testing.expect(p.executeBuiltinLine(typed, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[typed] != null);
- try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]);
+ try std.testing.expect(p.executeBuiltinLine(shell, "Tty"));
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
+}
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const tagged = p.active;
+test "a column tag's command acts on that column" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
+ defer p.deinit();
+ const first = p.active;
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 3), p.ncol);
p.sync();
- try p.panes[tagged].?.appendTag(" make");
- try std.testing.expect(p.executeBuiltinLine(tagged, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[tagged] != null);
+ // New from the middle column's tag lands in it, not below `first`.
+ p.exec_column = 1;
+ try std.testing.expect(p.executeBuiltinLine(first, "New"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(p.active, p.col_panes[1][0]);
+ // Delcol from the empty right column's tag closes that one.
+ p.exec_column = 2;
+ try std.testing.expect(p.executeBuiltinLine(first, "Delcol"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ // A pane's own Delcol closes its column whatever tag has the keyboard.
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.header_focus);
+ try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
}
test "Del from a key asks which neighbour takes the rows; nothing else asks" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 });
defer p.deinit();
+ p.settings.placement = .pardes; // the neighbours below are what is under test
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
const top = p.active;
@@ -6759,6 +6987,28 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(top, p.active);
try std.testing.expectEqual(h[2], p.rects[low].h);
+ // A script answers the question on the pane's ctl, as the log's `ask`
+ // names it: `answer -` takes it back, a side closes the pane toward it.
+ {
+ const th = @import("ninep/testing.zig");
+ p.newScratchBelow(top);
+ const asked = p.active;
+ p.sync();
+ const serial = p.panes[asked].?.serial;
+ const ctl = ctlfs.Node.of(serial, .ctl);
+ try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer k\n").errno());
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} del k j\n", .{serial})));
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
+ try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
+ try std.testing.expect(p.paneBySerial(serial) == null);
+ }
+
// Bare Del from anything but a key closes at once and gives the rows up,
// as it always has.
p.newScratchBelow(top);
@@ -6793,7 +7043,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 });
defer p.deinit();
const shell = p.active;
- p.newScratchColumn(shell);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const right = p.active;
p.sync();
p.newScratchBelow(shell);
@@ -6825,45 +7076,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
p.sync();
p.active = left_low;
p.sync();
- try p.removeColumn(left_low);
+ try p.removeColumn(layout.findPane(p, left_low).?.col);
p.sync();
try std.testing.expectEqual(right_low, p.active);
}
-test "closing the last pane in a column leaves an empty one in its place" {
+test "closing the last pane in a column leaves the column empty, its tag focused" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });
defer p.deinit();
const shell = p.active;
- p.newScratchColumn(shell);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const lone = p.active;
try std.testing.expect(lone != shell);
try std.testing.expectEqual(@as(usize, 2), p.ncol);
const at = layout.findPane(p, lone).?;
try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice());
- defer std.testing.allocator.free(had);
+ p.sync();
try p.removePane(lone, null);
p.sync();
- // The column outlives the pane: still two columns, and the one that was
- // emptied holds a fresh empty pane in the same directory.
+ // The column outlives the pane, as acme's does: still two columns, the
+ // emptied one with no pane and the keyboard on its tag.
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const heir = p.col_panes[at.col][0];
- try std.testing.expect(heir != lone);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]);
try std.testing.expect(p.panes[lone] == null);
- const hp = p.panes[heir].?;
- try std.testing.expectEqualStrings("", hp.file.?.content);
- try std.testing.expectEqualStrings(had, hp.cwdSlice());
- try std.testing.expectEqual(heir, p.active);
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+ try std.testing.expectEqual(shell, p.active);
try std.testing.expect(!p.quit);
+ // Down from it goes nowhere: nothing is below.
+ try std.testing.expect(p.executeBuiltinLine(shell, "Down"));
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+
// ...and Delcol is what actually takes the column away.
- try p.removeColumn(heir);
+ try p.removeColumn(at.col);
p.sync();
try std.testing.expectEqual(@as(usize, 1), p.ncol);
+ try std.testing.expectEqual(shell, p.active);
+
+ // The session's last pane closing ends it.
+ try p.removePane(shell, null);
+ try std.testing.expect(p.quit);
}
test "the bare tty startup opens a text pane under the shell, unfocused" {
@@ -7029,6 +7285,7 @@ test "hopping between two panes does not grow the jump stack" {
if (platform == .web) return;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines
defer p.deinit();
p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 13c02231..58dd2c3c 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -2045,7 +2045,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void {
.serial = result.serial,
.revision = result.file.?.revision,
};
- exec.placeDoc(core, id, free, result);
+ exec.placeNew(core, id, id, free, .doc);
layout.compute(core);
core.active = id;
pane.search_pane = free;
diff --git a/src/regexp.zig b/src/regexp.zig
index fe68585d..249763c2 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -41,7 +41,12 @@ pub const Regex = struct {
/// patterns take 45-80 ms, and a Debug build is ten times slower.
pub const budget: u64 = if (builtin.mode == .Debug) 4_000_000 else 32_000_000;
- pub fn compile(pat: []const u8) error{Bad}!Regex {
+ pub const e_anchor = "in a pattern with \\n, ^ can only come first and $ only just before a \\n";
+
+ /// `Anchor`: a pattern that names a newline has `^` other than first,
+ /// or `$` other than just before a `\n`, which mvzr would read as the
+ /// ends of the whole text and so never match where sam would.
+ pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex {
if (pat.len == 0) return error.Bad;
var buf: [256]u8 = undefined;
var len: usize = 0;
@@ -62,12 +67,22 @@ pub const Regex = struct {
i += 1;
piece = pat[i - 1 .. i + 1];
if (pat[i] == 'n') spans = true;
+ // mvzr slices two hex digits after `\x` without looking,
+ // so a short one panics it: refuse it here.
+ if (pat[i] == 'x' and (i + 2 >= pat.len or
+ !std.ascii.isHex(pat[i + 1]) or !std.ascii.isHex(pat[i + 2]))) return error.Bad;
} else if (in_class) {
in_class = c != ']';
} else if (c == '[') {
in_class = true;
} else if (c == '.' and spans) {
piece = "[^\\n]";
+ } else if (spans and c == '^' and i != 0) {
+ return error.Anchor;
+ } else if (spans and c == '$') {
+ // `x$\n` is `x\n`; any other `$` would be the text's end.
+ if (std.mem.startsWith(u8, pat[i + 1 ..], "\\n")) continue;
+ return error.Anchor;
}
if (!emit) continue;
if (len + piece.len > buf.len) return error.Bad;
@@ -91,6 +106,20 @@ pub const Regex = struct {
mvzr.steps_left = rx.steps;
mvzr.exhausted = false;
defer rx.steps = mvzr.steps_left;
+ // A `^` pattern that spans lines: mvzr's `^` is its haystack's start,
+ // so each line start from `from` on is tried as one.
+ // ponytail: a search per line start, each to `hi`; the step budget
+ // bounds it.
+ if (rx.spans and rx.bol) {
+ var s = if (from == 0 or text[from - 1] == '\n') from else (std.mem.indexOfScalarPos(u8, text[0..hi], from, '\n') orelse return null) + 1;
+ while (s <= last and s <= hi) {
+ const hit = rx.re.match(text[s..hi]);
+ if (mvzr.exhausted) return error.TooSlow;
+ if (hit) |m| if (m.start == 0) return .{ .start = s, .end = s + m.end };
+ s = (std.mem.indexOfScalarPos(u8, text[0..hi], s, '\n') orelse return null) + 1;
+ }
+ return null;
+ }
var start: usize = if (rx.spans) 0 else if (std.mem.lastIndexOfScalar(u8, text[0..from], '\n')) |nl| nl + 1 else 0;
var at = from - start;
// `^` cannot match in the middle of a line.
@@ -136,6 +165,20 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li
try std.testing.expectEqual(c.end, m.end);
}
_ = try Regex.compile("a.*a\\nq");
+ // `^` at every line start and `$` before a newline, when a pattern
+ // spans lines; anywhere else they are refused, never silently wrong.
+ const defs = "x = 1\ndef a\n\ndef b\n";
+ var def = try Regex.compile("^def .*\\n");
+ const d = (try def.find(defs, 0, defs.len, defs.len)).?;
+ try std.testing.expectEqual(@as(usize, 6), d.start);
+ try std.testing.expectEqual(@as(usize, 12), d.end);
+ try std.testing.expectEqual(@as(usize, 13), (try def.find(defs, 7, defs.len, defs.len)).?.start);
+ var blank = try Regex.compile("^\\n");
+ try std.testing.expectEqual(@as(usize, 12), (try blank.find(defs, 0, defs.len, defs.len)).?.start);
+ var dollar = try Regex.compile("1$\\n");
+ try std.testing.expectEqual(@as(usize, 4), (try dollar.find(defs, 0, defs.len, defs.len)).?.start);
+ try std.testing.expectError(error.Anchor, Regex.compile("(^|\\n)def"));
+ try std.testing.expectError(error.Anchor, Regex.compile("a$\\nb$"));
var none = try Regex.compile("zzz");
try std.testing.expect(try none.find(text, 0, text.len, text.len) == null);
try std.testing.expectError(error.Bad, Regex.compile(""));
@@ -185,3 +228,10 @@ test "the match is the leftmost, however long the line" {
try std.testing.expectEqual(@as(usize, 2), f.start);
try std.testing.expectEqual(@as(usize, 7), f.end);
}
+
+test "a \\x without two hex digits is refused, not handed to mvzr to panic on" {
+ for ([_][]const u8{ "\\x", "a\\x1", "\\x1b[\\x", "[\\x]", "\\xg1" }) |pat|
+ try std.testing.expectError(error.Bad, Regex.compile(pat));
+ var rx = try Regex.compile("\\x41");
+ try std.testing.expectEqual(@as(usize, 1), (try rx.find("xA", 0, 1, 2)).?.start);
+}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
new file mode 100644
index 00000000..679823da
--- /dev/null
+++ b/src/sam_edit.zig
@@ -0,0 +1,717 @@
+//! acme's Edit (editors/acme/edit.c, ecmd.c, elog.c): sam's command
+//! language over one pane's body. The commands are parsed as edit.c parses
+//! them, run as ecmd.c runs them against the text as it was before the Edit,
+//! and their changes collected in order, as elog.c logs them, then applied
+//! at once: an Edit is one undo step, and one that fails changes nothing.
+//!
+//! Addresses are the `addr` file's (src/ninep/addr.zig), so they take
+//! pardes's `line:col` too; regular expressions are src/regexp.zig's, mvzr
+//! searched as sam searches, with its step budget.
+//!
+//! Left out: the file commands (`b B D e r w f X Y`), the pipes (`< | >`),
+//! the `'` and `"` addresses, and `\1`..`\9` in `s` (mvzr has no
+//! submatches). `u` undoes or redoes only as a whole Edit. acme warns about
+//! changes out of sequence (elog.c:130) and applies them anyway; here the
+//! Edit is refused.
+const std = @import("std");
+const regexp = @import("regexp.zig");
+const addr_lang = @import("ninep/addr.zig");
+
+pub const Range = addr_lang.Range;
+
+pub const Op = struct { q0: u32, q1: u32, text: []const u8 };
+
+/// What an Edit came to: the changes in order, the dot it leaves (in the
+/// text before them), and what `p` and `=` printed.
+pub const Result = struct {
+ ops: []const Op,
+ dot: Range,
+ printed: []const u8,
+ /// A bare `u`/`u-3`: undo (positive) or redo (negative) that many.
+ undo: i32 = 0,
+};
+
+pub const Failure = error{ Edit, OutOfMemory };
+
+/// Why an Edit failed, in acme's words, when `run` answered error.Edit.
+pub const Why = struct {
+ buf: [200]u8 = undefined,
+ len: usize = 0,
+ pub fn text(w: *const Why) []const u8 {
+ return w.buf[0..w.len];
+ }
+};
+
+const Cmd = struct {
+ c: u8,
+ addr: ?[]const u8 = null,
+ re: ?[]const u8 = null,
+ text: []const u8 = "",
+ sub: ?*Cmd = null,
+ next: ?*Cmd = null,
+ num: i32 = 0,
+ global: bool = false,
+ to: ?[]const u8 = null,
+};
+
+/// Runs `command` over `text` from `dot`, the file named `name` (for `=`).
+/// Everything returned lives in `arena`.
+pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const u8, command: []const u8, why: *Why) Failure!Result {
+ var src = try arena.alloc(u8, command.len + 1);
+ @memcpy(src[0..command.len], command);
+ src[command.len] = '\n';
+ if (command.len > 0 and command[command.len - 1] == '\n') src = src[0..command.len];
+ var ps: Parser = .{ .arena = arena, .src = src, .why = why };
+ var first: ?*Cmd = null;
+ var last: ?*Cmd = null;
+ while (try ps.parse(0)) |c| {
+ if (last) |l| l.next = c else first = c;
+ last = c;
+ }
+ const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" };
+ // acme's u runs at once (ecmd.c:532), outside the edit log; here it is
+ // an Edit of its own.
+ if (cmds.c == 'u') {
+ if (cmds.next != null) return fail(why, "u is only an Edit of its own here", .{});
+ return .{ .ops = &.{}, .dot = dot, .printed = "", .undo = cmds.num };
+ }
+ var ex: Exec = .{ .arena = arena, .text = text, .name = name, .why = why };
+ var d = dot;
+ var it: ?*Cmd = cmds;
+ while (it) |c| : (it = c.next) d = try ex.exec(c, d);
+ return .{ .ops = ex.ops.items, .dot = d, .printed = ex.out.items };
+}
+
+/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
+/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
+/// hands an Edit the lines after it until it does not.
+pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
+ var why: Why = .{};
+ const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
+ var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
+ while (true) {
+ const c = ps.parse(0) catch return std.mem.eql(u8, why.text(), "unmatched `{'") or ps.open_text;
+ if (c == null) return ps.open_text;
+ }
+}
+
+fn fail(why: *Why, comptime fmt: []const u8, args: anytype) Failure {
+ why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0;
+ return error.Edit;
+}
+
+// ---- parsing: edit.c ----
+
+const Parser = struct {
+ arena: std.mem.Allocator,
+ src: []const u8,
+ i: usize = 0,
+ /// The last pattern given, for an empty one (edit.c:562, getregexp).
+ lastpat: []const u8 = "",
+ /// The last `parse` that answered null met a `}`, not the end.
+ braced: bool = false,
+ /// A text block of `a`, `c` or `i` ran out before its `.` line.
+ open_text: bool = false,
+ why: *Why,
+
+ fn getch(ps: *Parser) ?u8 {
+ if (ps.i >= ps.src.len) return null;
+ ps.i += 1;
+ return ps.src[ps.i - 1];
+ }
+
+ fn nextc(ps: *const Parser) ?u8 {
+ return if (ps.i < ps.src.len) ps.src[ps.i] else null;
+ }
+
+ fn skipbl(ps: *Parser) ?u8 {
+ while (ps.nextc()) |c| {
+ if (c != ' ' and c != '\t') return c;
+ ps.i += 1;
+ }
+ return null;
+ }
+
+ fn atnl(ps: *Parser) Failure!void {
+ _ = ps.skipbl();
+ const c = ps.getch() orelse '\n';
+ if (c != '\n') return fail(ps.why, "newline expected (saw {c})", .{c});
+ }
+
+ fn okdelim(ps: *Parser, c: u8) Failure!void {
+ if (c == '\\' or std.ascii.isAlphanumeric(c)) return fail(ps.why, "bad delimiter {c}", .{c});
+ }
+
+ /// edit.c:216: a count, 1 when there is none; `-` allowed when signok > 1.
+ fn getnum(ps: *Parser, signok: u8) i32 {
+ var sign: i32 = 1;
+ if (signok > 1 and ps.nextc() == '-') {
+ sign = -1;
+ ps.i += 1;
+ }
+ const c = ps.nextc() orelse return sign;
+ if (c < '0' or c > '9') return sign;
+ var n: i32 = 0;
+ while (ps.nextc()) |d| {
+ if (d < '0' or d > '9') break;
+ n = n *| 10 +| (d - '0');
+ ps.i += 1;
+ }
+ return sign * n;
+ }
+
+ /// An address's text, blanks dropped, or null: the syntax of edit.c's
+ /// compoundaddr and simpleaddr, evaluated later by addr.zig.
+ fn address(ps: *Parser) Failure!?[]const u8 {
+ var out: std.ArrayList(u8) = .empty;
+ while (true) {
+ const c = ps.skipbl() orelse break;
+ switch (c) {
+ '0'...'9', '#', '.', '$', '+', '-', ',', ';' => {
+ ps.i += 1;
+ try out.append(ps.arena, c);
+ // pardes's line:col
+ if (c >= '0' and c <= '9' and ps.nextc() == ':' and ps.i + 1 < ps.src.len and
+ ps.src[ps.i + 1] >= '0' and ps.src[ps.i + 1] <= '9')
+ {
+ ps.i += 1;
+ try out.append(ps.arena, ':');
+ }
+ },
+ '/', '?' => {
+ ps.i += 1;
+ try out.append(ps.arena, c);
+ while (ps.nextc()) |d| {
+ if (d == '\n') break;
+ ps.i += 1;
+ try out.append(ps.arena, d);
+ if (d == '\\') {
+ if (ps.nextc()) |e| if (e != '\n') {
+ ps.i += 1;
+ try out.append(ps.arena, e);
+ };
+ continue;
+ }
+ if (d == c) break;
+ }
+ },
+ '\'' => return fail(ps.why, "can't handle '", .{}),
+ '"' => return fail(ps.why, "file addresses are not supported", .{}),
+ else => break,
+ }
+ }
+ return if (out.items.len == 0) null else out.items;
+ }
+
+ /// edit.c:562: up to the delimiter; `\delim` is the delimiter, `\\` stays.
+ fn getregexp(ps: *Parser, delim: u8) Failure![]const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ while (true) {
+ var c = ps.getch() orelse break;
+ if (c == '\\') {
+ if (ps.nextc() == delim) {
+ c = ps.getch().?;
+ } else if (ps.nextc() == '\\') {
+ try buf.append(ps.arena, c);
+ c = ps.getch().?;
+ }
+ } else if (c == delim or c == '\n') {
+ if (c == '\n') ps.i -= 1;
+ break;
+ }
+ try buf.append(ps.arena, c);
+ }
+ if (buf.items.len > 0) ps.lastpat = buf.items;
+ if (ps.lastpat.len == 0) return fail(ps.why, "no regular expression defined", .{});
+ return ps.lastpat;
+ }
+
+ /// edit.c:388: `\n` is a newline; for `s` every other escape is kept
+ /// for it to read (`\&`, `\\`).
+ fn getrhs(ps: *Parser, delim: u8, cmd: u8) Failure![]const u8 {
+ var s: std.ArrayList(u8) = .empty;
+ while (ps.getch()) |c0| {
+ var c = c0;
+ if (c == delim or c == '\n') {
+ ps.i -= 1;
+ break;
+ }
+ if (c == '\\') {
+ c = ps.getch() orelse return fail(ps.why, "bad right hand side", .{});
+ if (c == '\n') {
+ ps.i -= 1;
+ c = '\\';
+ } else if (c == 'n') {
+ c = '\n';
+ } else if (c != delim and (cmd == 's' or c != '\\')) {
+ try s.append(ps.arena, '\\');
+ }
+ }
+ try s.append(ps.arena, c);
+ }
+ return s.items;
+ }
+
+ /// edit.c:425: `/text/` on the line, or lines up to one that is `.`.
+ fn collecttext(ps: *Parser) Failure![]const u8 {
+ if (ps.skipbl() == '\n') {
+ ps.i += 1;
+ var s: std.ArrayList(u8) = .empty;
+ while (ps.i < ps.src.len) {
+ const end = std.mem.indexOfScalarPos(u8, ps.src, ps.i, '\n') orelse ps.src.len;
+ const line = ps.src[ps.i..end];
+ ps.i = @min(end + 1, ps.src.len);
+ if (std.mem.eql(u8, line, ".")) return s.items;
+ try s.appendSlice(ps.arena, line);
+ try s.append(ps.arena, '\n');
+ }
+ ps.open_text = true;
+ return s.items;
+ }
+ const delim = ps.getch() orelse return "";
+ try ps.okdelim(delim);
+ const s = try ps.getrhs(delim, 'a');
+ if (ps.nextc() == delim) ps.i += 1;
+ try ps.atnl();
+ return s;
+ }
+
+ /// edit.c:467, parsecmd.
+ fn parse(ps: *Parser, nest: u32) Failure!?*Cmd {
+ const a = try ps.address();
+ _ = ps.skipbl() orelse return null;
+ const c = ps.getch() orelse return null;
+ const cmd = try ps.arena.create(Cmd);
+ cmd.* = .{ .c = c, .addr = a };
+ switch (c) {
+ '\n' => {},
+ 'a', 'c', 'i' => cmd.text = try ps.collecttext(),
+ 'd', 'p' => try ps.atnl(),
+ '=' => {
+ _ = ps.skipbl();
+ const start = ps.i;
+ while (ps.nextc()) |d| : (ps.i += 1) if (d == '\n') break;
+ cmd.text = std.mem.trimEnd(u8, ps.src[start..ps.i], " \t");
+ ps.i = @min(ps.i + 1, ps.src.len);
+ if (cmd.text.len != 0 and !std.mem.eql(u8, cmd.text, "#")) return fail(ps.why, "newline expected", .{});
+ },
+ 's' => {
+ cmd.num = ps.getnum(1);
+ _ = ps.skipbl();
+ const delim = ps.getch() orelse '\n';
+ if (delim == '\n') return fail(ps.why, "no address", .{});
+ try ps.okdelim(delim);
+ cmd.re = try ps.getregexp(delim);
+ cmd.text = try ps.getrhs(delim, 's');
+ if (ps.nextc() == delim) {
+ ps.i += 1;
+ if (ps.nextc() == 'g') {
+ ps.i += 1;
+ cmd.global = true;
+ } else if (ps.nextc()) |d| if (d >= '1' and d <= '9') {
+ // `s/a/b/2`, the count after, as some write it for
+ // sam's `s2/a/b/`.
+ cmd.num = ps.getnum(1);
+ };
+ }
+ try ps.atnl();
+ },
+ 'x', 'y', 'g', 'v' => {
+ // x without a pattern is every line (ecmd.c:568, linelooper).
+ const n = ps.nextc();
+ if (c != 'x' or (n != ' ' and n != '\t' and n != '\n')) {
+ _ = ps.skipbl();
+ const delim = ps.getch() orelse '\n';
+ if (delim == '\n') return fail(ps.why, "no address", .{});
+ try ps.okdelim(delim);
+ cmd.re = try ps.getregexp(delim);
+ }
+ if (ps.skipbl() == '\n') {
+ ps.i += 1;
+ const p = try ps.arena.create(Cmd);
+ p.* = .{ .c = 'p' };
+ cmd.sub = p;
+ } else {
+ cmd.sub = (try ps.parse(nest)) orelse return fail(ps.why, "defcmd", .{});
+ }
+ },
+ 'm', 't' => {
+ cmd.to = (try ps.address()) orelse return fail(ps.why, "bad address", .{});
+ try ps.atnl();
+ },
+ 'u' => {
+ if (a != null) return fail(ps.why, "command takes no address", .{});
+ cmd.num = ps.getnum(2);
+ try ps.atnl();
+ },
+ '{' => {
+ var last: ?*Cmd = null;
+ while (true) {
+ if (ps.skipbl() == '\n') ps.i += 1;
+ ps.braced = false;
+ const next = (try ps.parse(nest + 1)) orelse {
+ // sam reads on for the `}`; an Edit has no more to read.
+ if (!ps.braced) return fail(ps.why, "unmatched `{{'", .{});
+ break;
+ };
+ if (last) |l| l.next = next else cmd.sub = next;
+ last = next;
+ }
+ },
+ '}' => {
+ ps.braced = true;
+ try ps.atnl();
+ if (nest == 0) return fail(ps.why, "right brace with no left brace", .{});
+ return null;
+ },
+ 'b', 'B', 'D', 'e', 'r', 'w', 'f', 'X', 'Y', '<', '|', '>' => return fail(ps.why, "{c} is not supported in pardes", .{c}),
+ else => return fail(ps.why, "unknown command {c}", .{c}),
+ }
+ return cmd;
+ }
+};
+
+// ---- running: ecmd.c, logging: elog.c ----
+
+const Exec = struct {
+ arena: std.mem.Allocator,
+ text: []const u8,
+ name: []const u8,
+ why: *Why,
+ ops: std.ArrayList(Op) = .empty,
+ out: std.ArrayList(u8) = .empty,
+ nest: u32 = 0,
+
+ fn clip(n: usize) u32 {
+ return std.math.cast(u32, n) orelse std.math.maxInt(u32);
+ }
+
+ /// An address from `dot`, or `dot` when there is none (aDot).
+ fn address(ex: *Exec, expr: ?[]const u8, dot: Range) Failure!Range {
+ const e = expr orelse return dot;
+ var a: addr_lang.Addr = .{ .text = ex.text, .lim = null, .expr = e };
+ const r = a.address(dot) orelse return fail(ex.why, "{s}", .{a.err});
+ if (a.i < e.len) return fail(ex.why, "bad address syntax", .{});
+ if (r.q0 > r.q1) return fail(ex.why, "addresses out of order", .{});
+ return r;
+ }
+
+ /// elog.c's order: each change at or after the end of the one before,
+ /// since they all address the text as it was.
+ fn log(ex: *Exec, q0: u32, q1: u32, text: []const u8) Failure!void {
+ if (q0 == q1 and text.len == 0) return;
+ if (ex.ops.items.len > 0) {
+ const prev = ex.ops.items[ex.ops.items.len - 1];
+ if (q0 < prev.q1 or q0 < prev.q0) return fail(ex.why, "changes out of sequence", .{});
+ }
+ try ex.ops.append(ex.arena, .{ .q0 = q0, .q1 = q1, .text = text });
+ }
+
+ fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex {
+ return regexp.Regex.compile(pat) catch |err| switch (err) {
+ error.Anchor => fail(ex.why, "{s}", .{regexp.Regex.e_anchor}),
+ error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
+ };
+ }
+
+ fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
+ if (from > hi) return null;
+ return rx.find(ex.text, from, hi, hi) catch fail(ex.why, "{s}", .{addr_lang.e_slow});
+ }
+
+ /// ecmd.c:62, cmdexec: runs `c` from `dot`, answering the dot it leaves.
+ fn exec(ex: *Exec, c: *Cmd, dot: Range) Failure!Range {
+ const r = try ex.address(c.addr, dot);
+ switch (c.c) {
+ '\n' => return r,
+ 'a' => {
+ try ex.log(r.q1, r.q1, c.text);
+ return .{ .q0 = r.q1, .q1 = r.q1 };
+ },
+ 'i' => {
+ try ex.log(r.q0, r.q0, c.text);
+ return .{ .q0 = r.q0, .q1 = r.q0 };
+ },
+ 'c' => {
+ try ex.log(r.q0, r.q1, c.text);
+ return .{ .q0 = r.q0, .q1 = r.q0 };
+ },
+ 'd' => {
+ try ex.log(r.q0, r.q1, "");
+ return .{ .q0 = r.q0, .q1 = r.q0 };
+ },
+ 'p' => {
+ try ex.out.appendSlice(ex.arena, ex.text[r.q0..r.q1]);
+ return r;
+ },
+ '=' => {
+ // ecmd.c:665, printposn
+ if (c.text.len > 0) {
+ try ex.out.print(ex.arena, "{s}:#{d}", .{ ex.name, r.q0 });
+ if (r.q1 != r.q0) try ex.out.print(ex.arena, ",#{d}", .{r.q1});
+ } else {
+ const l1 = 1 + std.mem.count(u8, ex.text[0..r.q0], "\n");
+ var l2 = l1 + std.mem.count(u8, ex.text[r.q0..r.q1], "\n");
+ if (r.q1 > r.q0 and ex.text[r.q1 - 1] == '\n') l2 -= 1;
+ try ex.out.print(ex.arena, "{s}:{d}", .{ ex.name, l1 });
+ if (l2 != l1) try ex.out.print(ex.arena, ",{d}", .{l2});
+ }
+ try ex.out.append(ex.arena, '\n');
+ return r;
+ },
+ 's' => return ex.substitute(c, r),
+ 'x', 'y' => {
+ const ranges = if (c.re) |pat| try ex.looper(pat, r, c.c == 'x') else try ex.lines(r);
+ ex.nest += 1;
+ defer ex.nest -= 1;
+ var d = r;
+ for (ranges) |each| d = try ex.exec(c.sub.?, each);
+ return d;
+ },
+ 'g', 'v' => {
+ var rx = try ex.compile(c.re.?, c.c);
+ const hit = (try ex.find(&rx, r.q0, r.q1)) != null;
+ if (hit != (c.c == 'v')) return ex.exec(c.sub.?, r);
+ return r;
+ },
+ 'm', 't' => {
+ const to = try ex.address(c.to, dot);
+ const bytes = ex.text[r.q0..r.q1];
+ if (c.c == 't') {
+ try ex.log(to.q1, to.q1, bytes);
+ } else if (r.q1 <= to.q0) {
+ // ecmd.c:408, move
+ try ex.log(r.q0, r.q1, "");
+ try ex.log(to.q1, to.q1, bytes);
+ } else if (r.q0 >= to.q1) {
+ try ex.log(to.q1, to.q1, bytes);
+ try ex.log(r.q0, r.q1, "");
+ } else if (!(r.q0 == to.q0 and r.q1 == to.q1)) {
+ return fail(ex.why, "move overlaps itself", .{});
+ }
+ return r;
+ },
+ 'u' => return fail(ex.why, "u is only an Edit of its own here", .{}),
+ '{' => {
+ var it = c.sub;
+ while (it) |each| : (it = each.next) _ = try ex.exec(each, r);
+ return r;
+ },
+ else => return fail(ex.why, "unknown command {c}", .{c.c}),
+ }
+ }
+
+ /// ecmd.c:443, s_cmd.
+ fn substitute(ex: *Exec, c: *Cmd, r: Range) Failure!Range {
+ var rx = try ex.compile(c.re.?, 's');
+ var n = c.num;
+ var op: ?usize = null;
+ var p1: usize = r.q0;
+ var did = false;
+ while (p1 <= r.q1) {
+ const m = (try ex.find(&rx, p1, r.q1)) orelse break;
+ if (m.start == m.end) {
+ if (op != null and m.start == op.?) {
+ p1 += 1;
+ continue;
+ }
+ p1 = m.end + 1;
+ } else p1 = m.end;
+ op = m.end;
+ n -= 1;
+ if (n > 0) continue;
+ var buf: std.ArrayList(u8) = .empty;
+ var i: usize = 0;
+ while (i < c.text.len) : (i += 1) {
+ const ch = c.text[i];
+ if (ch == '\\' and i + 1 < c.text.len) {
+ i += 1;
+ const e = c.text[i];
+ if (e >= '1' and e <= '9') return fail(ex.why, "no \\{c}: mvzr keeps no submatches", .{e});
+ try buf.append(ex.arena, e);
+ } else if (ch == '&') {
+ try buf.appendSlice(ex.arena, ex.text[m.start..m.end]);
+ } else try buf.append(ex.arena, ch);
+ }
+ try ex.log(clip(m.start), clip(m.end), buf.items);
+ did = true;
+ if (!c.global) break;
+ }
+ if (!did and ex.nest == 0) return fail(ex.why, "no substitution", .{});
+ return r;
+ }
+
+ /// ecmd.c:796, looper: the matches (x) or what lies between them (y).
+ fn looper(ex: *Exec, pat: []const u8, r: Range, xy: bool) Failure![]Range {
+ var rx = try ex.compile(pat, if (xy) 'x' else 'y');
+ var out: std.ArrayList(Range) = .empty;
+ var op: ?usize = if (xy) null else r.q0;
+ var p: usize = r.q0;
+ while (p <= r.q1) {
+ var tr: Range = undefined;
+ var sel_end: usize = undefined;
+ if (try ex.find(&rx, p, r.q1)) |m| {
+ if (m.start == m.end) {
+ if (op != null and m.start == op.?) {
+ p += 1;
+ continue;
+ }
+ p = m.end + 1;
+ } else p = m.end;
+ tr = if (xy) .{ .q0 = clip(m.start), .q1 = clip(m.end) } else .{ .q0 = clip(op.?), .q1 = clip(m.start) };
+ sel_end = m.end;
+ } else {
+ // no match, but y still takes what is left
+ if (xy or op.? > r.q1) break;
+ tr = .{ .q0 = clip(op.?), .q1 = r.q1 };
+ p = @as(usize, r.q1) + 1;
+ sel_end = r.q1;
+ }
+ op = sel_end;
+ try out.append(ex.arena, tr);
+ }
+ return out.items;
+ }
+
+ /// ecmd.c:840, linelooper: each line in the range, the first from where
+ /// the range starts, the last cut where it ends.
+ fn lines(ex: *Exec, r: Range) Failure![]Range {
+ var out: std.ArrayList(Range) = .empty;
+ var p: usize = r.q0;
+ while (p < r.q1) {
+ const nl = std.mem.indexOfScalarPos(u8, ex.text, p, '\n');
+ const e = @min(if (nl) |i| i + 1 else ex.text.len, r.q1);
+ if (e <= p) break;
+ try out.append(ex.arena, .{ .q0 = clip(p), .q1 = clip(e) });
+ p = e;
+ }
+ return out.items;
+ }
+};
+
+/// The text after `ops`, which are in order and do not overlap.
+pub fn apply(gpa: std.mem.Allocator, text: []const u8, ops: []const Op) ![]u8 {
+ var n: usize = text.len;
+ for (ops) |o| n = n - (o.q1 - o.q0) + o.text.len;
+ const out = try gpa.alloc(u8, n);
+ var at: usize = 0;
+ var from: usize = 0;
+ for (ops) |o| {
+ const keep = text[from..o.q0];
+ @memcpy(out[at..][0..keep.len], keep);
+ at += keep.len;
+ @memcpy(out[at..][0..o.text.len], o.text);
+ at += o.text.len;
+ from = o.q1;
+ }
+ @memcpy(out[at..], text[from..]);
+ return out;
+}
+
+/// Where `dot` lands after `ops`: each end moved by the changes before it;
+/// an empty dot where a change starts selects what it put there, as
+/// elogapply does (elog.c:283).
+pub fn moveDot(dot: Range, ops: []const Op) Range {
+ var shift: i64 = 0;
+ for (ops) |o| {
+ if (dot.q0 == dot.q1 and o.q0 == dot.q0 and (o.text.len > 0)) {
+ const q: u32 = @intCast(@as(i64, dot.q0) + shift);
+ return .{ .q0 = q, .q1 = q + @as(u32, @intCast(o.text.len)) };
+ }
+ if (o.q1 > dot.q0) break;
+ shift += @as(i64, @intCast(o.text.len)) - @as(i64, o.q1 - o.q0);
+ }
+ const q0: u32 = @intCast(@as(i64, dot.q0) + shift);
+ var shift1: i64 = 0;
+ for (ops) |o| {
+ if (o.q1 > dot.q1 or (o.q0 == o.q1 and o.q0 == dot.q1)) break;
+ shift1 += @as(i64, @intCast(o.text.len)) - @as(i64, o.q1 - o.q0);
+ }
+ const q1: u32 = @intCast(@max(@as(i64, q0), @as(i64, dot.q1) + shift1));
+ return .{ .q0 = q0, .q1 = q1 };
+}
+
+fn edited(text: []const u8, command: []const u8) ![]u8 {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ var why: Why = .{};
+ const res = run(arena_state.allocator(), text, .{}, "t", command, &why) catch |err| {
+ if (err == error.Edit) std.debug.print("Edit: {s}\n", .{why.text()});
+ return err;
+ };
+ return apply(std.testing.allocator, text, res.ops);
+}
+
+fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void {
+ const got = try edited(text, command);
+ defer std.testing.allocator.free(got);
+ try std.testing.expectEqualStrings(want, got);
+}
+
+test "sam's classic commands" {
+ try expectEdit("foo x foo y foo\n", ",x/foo/c/foobar/", "foobar x foobar y foobar\n");
+ try expectEdit("a b\nc d\n", ",x/ /c/_/", "a_b\nc_d\n");
+ // As in acme (ecmd.c:810), y also takes the empty stretch after the last match.
+ try expectEdit("one\ntwo\nthree\n", ",y/\\n/c/X/", "X\nX\nX\nX");
+ try expectEdit("aaa\n", ",s/a/b/g", "bbb\n");
+ try expectEdit("aaa\n", ",s/a/b/", "baa\n");
+ try expectEdit("aaa\n", ",s2/a/b/", "aba\n");
+ try expectEdit("aaa\n", ",s/a/b/2", "aba\n");
+ try expectEdit("cat dog\n", ",s/[a-z]+/<&>/g", "<cat> <dog>\n");
+ // x within x, g within x.
+ try expectEdit("int a;\nfloat b;\nint c;\n", ",x/.*\\n/g/^int/x/[a-z];/c/X;/", "int X;\nfloat b;\nint X;\n");
+ try expectEdit("keep 1\ndrop 2\nkeep 3\n", ",x g/drop/d", "keep 1\nkeep 3\n");
+ try expectEdit("keep 1\ndrop 2\nkeep 3\n", ",x v/keep/d", "keep 1\nkeep 3\n");
+ // Lines, a/i, grouping, m and t.
+ try expectEdit("a\nb\n", ",x a/;/", "a\n;b\n;");
+ try expectEdit("a\nb\n", "1 i/>/", ">a\nb\n");
+ try expectEdit("abc\n", "/b/{\ni/[/\na/]/\n}", "a[b]c\n");
+ try expectEdit("one\ntwo\n", "1 m $", "two\none\n");
+ try expectEdit("one\ntwo\n", "1 t $", "one\ntwo\none\n");
+ try expectEdit("one\ntwo\nthree\n", "2 d", "one\nthree\n");
+ // ^ at every line start in a pattern that spans lines (dogfood round 5).
+ try expectEdit("x = 1\ndef a\ndef b\n", ",x/^def .*\\n/a/X\\n/", "x = 1\ndef a\nX\ndef b\nX\n");
+ // pardes's line:col.
+ try expectEdit("one\ntwo\n", "2:2 i/-/", "one\nt-wo\n");
+}
+
+test "an Edit that fails halfway changes nothing, and says why in acme's words" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ for ([_][2][]const u8{
+ .{ ",x/foo/c/bar/\n/nomatch/d", "no match for regexp" },
+ .{ ",s/zzz/y/", "no substitution" },
+ .{ "k", "unknown command k" },
+ .{ ",x/a/{\na/1/\ni/2/\n}", "changes out of sequence" },
+ .{ "}", "right brace with no left brace" },
+ .{ ",x/foo/{", "unmatched `{'" },
+ .{ ",x/foo/{\nd", "unmatched `{'" },
+ .{ "w /tmp/x", "w is not supported in pardes" },
+ .{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" },
+ .{ "1 m 1,2", "move overlaps itself" },
+ .{ ",x/(^|\\n)foo/d", "in a pattern with \\n, ^ can only come first and $ only just before a \\n" },
+ }) |c| {
+ var why: Why = .{};
+ try std.testing.expectError(error.Edit, run(arena_state.allocator(), "foo a\nfoo\n", .{}, "t", c[0], &why));
+ try std.testing.expectEqualStrings(c[1], why.text());
+ }
+}
+
+test "an Edit wants the lines after it until its braces and text blocks close" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+ try std.testing.expect(needsMore(a, ",x/foo/{"));
+ try std.testing.expect(needsMore(a, ",x/foo/{\ni/[/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/{\ni/[/\n}"));
+ try std.testing.expect(needsMore(a, "$a\nline one"));
+ try std.testing.expect(!needsMore(a, "$a\nline one\n."));
+ try std.testing.expect(!needsMore(a, ",s/{/x/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/c/bar/"));
+}
+
+test "the dot an Edit leaves selects what a change put where it stood" {
+ const ops = [_]Op{.{ .q0 = 2, .q1 = 4, .text = "XYZ" }};
+ try std.testing.expectEqual(Range{ .q0 = 2, .q1 = 5 }, moveDot(.{ .q0 = 2, .q1 = 2 }, &ops));
+ try std.testing.expectEqual(Range{ .q0 = 6, .q1 = 7 }, moveDot(.{ .q0 = 5, .q1 = 6 }, &ops));
+}
diff --git a/src/surface.zig b/src/surface.zig
index bc0a019d..15de128f 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -423,7 +423,9 @@ pub const Chrome = struct {
/// The canonical interface: what a frame of pardes IS. The tty shell writes
/// these cells to vaxis one-to-one; the SDL shells rasterize them.
pub const Surface = struct {
- pub const PointerShape = enum(u8) { arrow, link, target };
+ /// `box` is acme's boxcursor, shown while a pane or a column is carried
+ /// by its grip (cols.c:485, rows.c:151).
+ pub const PointerShape = enum(u8) { arrow, link, target, box };
pointer_shape: PointerShape = .arrow,
body_layers: [MAX_PANES]Layer = @splat(.{}),
tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}),
diff --git a/src/tagline.zig b/src/tagline.zig
index d0907cbd..68fa2645 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -23,6 +23,8 @@ pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
+/// The mark a tag carried after an unsaved file's name before the grip
+/// showed it; a dump from then still has it in its saved tags.
const dirty_marker = " *";
/// A file with changes it has not saved.
@@ -140,12 +142,9 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
var idbuf: [48]u8 = undefined;
return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) });
}
- if (pane.file) |f| {
- const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
- if (dirty(pane))
- return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
- return name;
- }
+ // Unsaved text shows on the grip, as acme's modbutton does, not as a
+ // mark in the text: the prefix is the name alone.
+ if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
return pane.cwdSlice();
}
@@ -213,13 +212,6 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
t.cur_row = @intCast(cursor.row);
t.cur_col = @intCast(cursor.col);
t.cur_pinned = true;
- // Reveal the suffix together with its commands, not merely the
- // first command letter at the viewport's far right edge.
- if (p.paneIdOf(pane)) |id| {
- const line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
- const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
- pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
- }
}
// The text may have changed since (a 9P write, a rename), so the
// remembered cursor is pulled back inside it. It is the cursor that
@@ -239,12 +231,83 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
pane.focus = .tag;
}
-/// Where display column `col` of the tag's shown row `row` (tag scroll
-/// included) puts its cursor: the tag as shown, prefix and all.
+/// A pane's tag as it is shown: its lines, each wrapped onto more rows at
+/// the tag's width as acme's frame wraps a tag at its right edge (plan9port
+/// acme wind.c:140, wintaglines, counts the frame's lines). `soft` are the
+/// offsets in `text` of the newlines the wrapping put in.
+pub const Shown = struct {
+ text: []u8,
+ soft: []const usize,
+
+ /// The tag's own offset of a shown one.
+ pub fn toReal(s: Shown, off: usize) usize {
+ var n: usize = 0;
+ for (s.soft) |b| {
+ if (b < off) n += 1 else break;
+ }
+ return off - n;
+ }
+
+ /// The shown offset of the tag's own one.
+ pub fn fromReal(s: Shown, real: usize) usize {
+ var off = real;
+ for (s.soft) |b| {
+ if (b <= off) off += 1 else break;
+ }
+ return off;
+ }
+
+ pub fn rows(s: Shown) usize {
+ return modal.cursorLineCount(s.text);
+ }
+};
+
+/// `whole` wrapped at `width` display columns.
+pub fn wrapTag(arena: std.mem.Allocator, whole: []const u8, width: usize) !Shown {
+ var out: std.ArrayList(u8) = .empty;
+ var soft: std.ArrayList(usize) = .empty;
+ const w = @max(1, width);
+ var lines = std.mem.splitScalar(u8, whole, '\n');
+ var first = true;
+ while (lines.next()) |line| {
+ if (!first) try out.append(arena, '\n');
+ first = false;
+ var rest = line;
+ while (panes.File.displayWidth(rest) > w) {
+ var cut = modal.graphemeStart(rest, panes.File.rawAtDisplay(rest, w));
+ if (cut == 0) cut = modal.nextGrapheme(rest, 0);
+ try out.appendSlice(arena, rest[0..cut]);
+ try soft.append(arena, out.items.len);
+ try out.append(arena, '\n');
+ rest = rest[cut..];
+ }
+ try out.appendSlice(arena, rest);
+ }
+ return .{ .text = out.items, .soft = soft.items };
+}
+
+/// The tag's width for wrapping: its columns, one kept for the caret past
+/// a full row's end.
+pub fn tagWidth(p: *const Pardes, id: usize) usize {
+ return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)) -| 1);
+}
+
+pub fn shownTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) !Shown {
+ const whole = try tagText(p, arena, pane);
+ const id = p.paneIdOf(pane) orelse return .{ .text = whole, .soft = &.{} };
+ return wrapTag(arena, whole, tagWidth(p, id));
+}
+
+/// Where display column `col` of the tag's shown row `row` puts its
+/// cursor: the tag as shown, prefix and all, wrapped rows included.
pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
+ const arena = p.scratch.allocator();
+ const shown = shownTag(p, arena, pane) catch return null;
+ const whole = tagText(p, arena, pane) catch return null;
const at = row + pane.tag_top;
- const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at);
- return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) };
+ const line = modal.lineSlice(shown.text, at);
+ const off = modal.lineStartOffset(shown.text, at) + modal.graphemeStart(line, panes.File.rawAtDisplay(line, col));
+ return modal.positionAt(whole, @min(shown.toReal(off), whole.len));
}
/// Whether display column `col` of the tag's shown row `row` is on a name
@@ -327,6 +390,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void {
if (p.panes[p.active]) |pane| pane.focus = .body;
p.header_focus = true;
p.header_column = column;
+ if (column) |c| p.setActiveColumn(c);
const t = focusedHeader(p) orelse return exitHeader(p);
const text = edit.flatSurface(p, t) catch "";
const rows = modal.cursorLineCount(text);
@@ -459,3 +523,51 @@ pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { l
if (hi <= start or lo >= end) return null;
return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start };
}
+
+/// Column and workspace tags are one line, as acme's: a newline typed,
+/// pasted or restored from a dump into one becomes a space, the caret and
+/// the selection kept on the same characters.
+pub fn flattenHeaders(p: *Pardes) void {
+ for (p.col_tags[0..p.ncol]) |*t| flatten(t);
+ flatten(&p.global_tag);
+}
+
+fn flatten(t: *Text) void {
+ const own = t.own orelse return;
+ if (std.mem.indexOfScalar(u8, own, '\n') == null) return;
+ const cur = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const anchor = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
+ std.mem.replaceScalar(u8, own, '\n', ' ');
+ t.cur_row = 0;
+ t.cur_col = @intCast(cur);
+ t.vsel.row = 0;
+ t.vsel.col = @intCast(anchor);
+ t.nsel = 0;
+}
+
+test "a column or workspace tag is one line: a newline in it is a space" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ p.col_tags[0].own = try p.gpa.dupe(u8, "New\nSnarf");
+ p.col_tags[0].cur_row = 1;
+ p.col_tags[0].cur_col = 2;
+ p.global_tag.own = try p.gpa.dupe(u8, "Newcol\nExit");
+ p.sync();
+ try std.testing.expectEqualStrings("New Snarf", columnTag(p, 0));
+ try std.testing.expectEqualStrings("Newcol Exit", p.global_tag.own.?);
+ try std.testing.expectEqual(@as(i32, 0), p.col_tags[0].cur_row);
+ try std.testing.expectEqual(@as(i32, 6), p.col_tags[0].cur_col);
+ try std.testing.expectEqual(pardes.COLBAR_H, p.columnBarHeight());
+ try std.testing.expectEqual(pardes.TOPBAR_H, p.topBarHeight());
+}
+
+test "a pane tag wraps a long line onto rows, and offsets map both ways" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const shown = try wrapTag(arena_state.allocator(), "/a/b.txt Del Snarf Undo Put\nMore", 10);
+ try std.testing.expectEqualStrings("/a/b.txt D\nel Snarf U\nndo Put\nMore", shown.text);
+ try std.testing.expectEqual(@as(usize, 4), shown.rows());
+ try std.testing.expectEqual(@as(usize, 10), shown.toReal(11)); // the 'e' of Del
+ try std.testing.expectEqual(@as(usize, 11), shown.fromReal(10));
+}
+
diff --git a/src/themes/acme.zig b/src/themes/acme.zig
index 17ef4224..621ffa23 100644
--- a/src/themes/acme.zig
+++ b/src/themes/acme.zig
@@ -35,6 +35,8 @@ pub const theme = .{
.lineno = .{ 0x99, 0x99, 0x80 },
.scroll_track = .{ 0x99, 0x99, 0x4c },
.scroll_thumb = .{ 0xff, 0xff, 0xea },
+ // acme paints a column with no window white (cols.c:187).
+ .empty_col = .{ 0xff, 0xff, 0xff },
.border = .{ 0x00, 0x00, 0x00 },
.tag_rule = .{ 0x88, 0x88, 0xcc },
.rule_px = 2,
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 822c2d7f..96aec9a9 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1050,12 +1050,13 @@ const Shell = struct {
return true;
}
_ = libc.close(pt.file.handle);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
const command = if (core.panes[e.id]) |pn| pn.command != null else false;
- const status = if (command) host_io.exitStatus(pt.pid, 100) else null;
+ const status = host_io.exitStatus(pt.pid, 100);
if (status == null) host_io.retireShell(pt.pid);
s.ptys[e.id] = null;
- if (command) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
+ if (command or status != null) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
}
core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},
@@ -1272,7 +1273,7 @@ const Shell = struct {
const s = of(ctx);
// A command whose exit is recorded is reaped: its group may be
// another's by now.
- if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig);
+ if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig, pt.cmd.watched);
}
/// The pane is gone: hang its pty up, which the kernel passes on to the
@@ -1314,7 +1315,7 @@ const Shell = struct {
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = of(ctx);
filesystem.write(s.core, path, bytes) catch |err|
- return s.core.saveFailed(pane, "save", err);
+ return s.core.saveFailed(pane, path, err);
if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) {
if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) {
.text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) },
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 03829178..c053d663 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -42,7 +42,8 @@ export class DomRenderer {
render() {
const count = this.wasm.pardes_frame();
- this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text";
+ const shape = this.wasm.pardes_pointer_shape();
+ this.root.style.cursor = shape === 1 ? "pointer" : shape === 3 ? "move" : "text";
const cols = this.wasm.pardes_frame_cols();
const rows = this.wasm.pardes_frame_rows();
if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`);