summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 18:53:37 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitb8c5e8772e646fa21f6afdd2a875a1bd52848bd5 (patch)
tree70bca3d554501c49107b27396c7756e0e8ad62a3 /src
parentc4ab29bce30f427decf3e199eebecfd415714a96 (diff)
downloadpardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.tar.gz
pardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.zip
A column can be empty, as acme's can; new panes go where acme's makenewwindow puts them
Newcol makes an empty column; closing a column's last pane leaves it empty with the keyboard on its tag; Delcol and Joincol alone take a column away; the session's last pane closing quits. The +New stand-in and replaceStillborn / stillborn_joiner are gone. Every new pane goes through exec.placeNew, which follows acme's makenewwindow (active column, empty column whole, blank space, halving the biggest) or, with Placement pardes, the old rules. ColumnTags is gone: column tags are always shown. A grip drag shows acme's box cursor. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md16
-rw-r--r--src/Mini.zig2
-rw-r--r--src/Output.zig10
-rw-r--r--src/builtins.zig25
-rw-r--r--src/config.zig24
-rw-r--r--src/draw.zig7
-rw-r--r--src/dump.zig17
-rw-r--r--src/exec.zig160
-rw-r--r--src/fs-help.txt8
-rw-r--r--src/gui/gui.zig30
-rw-r--r--src/layout.zig238
-rw-r--r--src/look.zig17
-rw-r--r--src/mouse.zig11
-rw-r--r--src/ninep/ctl.zig19
-rw-r--r--src/ninep/pane.zig4
-rw-r--r--src/ninep/tree.zig2
-rw-r--r--src/pardes.zig457
-rw-r--r--src/pdf_view.zig2
-rw-r--r--src/surface.zig4
-rw-r--r--src/tagline.zig1
-rw-r--r--src/web/app.mjs3
21 files changed, 711 insertions, 346 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index f8451baf..874082d8 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,22 @@
## 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.
+- 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/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/builtins.zig b/src/builtins.zig
index 72df0271..c2b4d62f 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -169,7 +169,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 => false,
+ .toggle, .transition, .scene, .dump_dir, .placement => false,
.shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true,
};
unreachable;
@@ -818,11 +818,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);
}
};
@@ -937,7 +937,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);
}
};
@@ -1049,9 +1052,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);
}
};
@@ -1059,8 +1062,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
}
};
@@ -1221,8 +1224,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/config.zig b/src/config.zig
index 2eaaa505..48f49d50 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -73,11 +73,11 @@ pub const leader_path = paths: {
.Newcol = "cn",
.Delcol = "cd",
.Joincol = "cj",
+ .Placement = "cp",
.Debug = "td",
.Msg = null,
.Colors = "tc",
.FocusTint = null,
- .ColumnTags = null,
.SyntaxBold = null,
.Verbose = null,
.TreeContextTagStyle = null,
@@ -616,7 +616,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`).
@@ -664,8 +663,12 @@ 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,
pub const ShaderAnimation = enum { off, on, always };
+ pub const Placement = enum { acme, pardes };
/// Each pass the bundled Crt or a Shadertoy file.
pub const Post = struct {
@@ -818,7 +821,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 };
@@ -838,6 +841,7 @@ pub const Runtime = struct {
/// it of files.
shader,
shader_animation,
+ placement,
};
pub const Setting = struct {
@@ -861,7 +865,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 } },
@@ -890,6 +893,7 @@ 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 = .placement },
};
pub fn find(name: []const u8) ?Setting {
@@ -1048,6 +1052,14 @@ 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 flips between the two, as a tag word should.
+ .placement => {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ state.placement = if (text.len == 0)
+ (if (state.placement == .acme) .pardes else .acme)
+ else
+ std.meta.stringToEnum(Placement, text) orelse return false;
+ },
.theme, .font => return false,
}
return true;
@@ -1217,6 +1229,7 @@ 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}),
+ .placement => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.placement) }),
};
if (context.startup_config_path) |path|
@@ -1468,6 +1481,7 @@ pub const Runtime = struct {
.dump_dir => "~/dumps",
.shader => "~/crt.glsl",
.shader_animation => "always",
+ .placement => "pardes",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1483,7 +1497,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",
@@ -1518,6 +1531,7 @@ pub const Runtime = struct {
},
.shader => "Shader ~/crt.glsl\n",
.shader_animation => "ShaderAnimation always\n",
+ .placement => "Placement pardes\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
diff --git a/src/draw.zig b/src/draw.zig
index 2d757635..b12ab52c 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -163,6 +163,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;
@@ -974,14 +975,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"));
@@ -1121,7 +1122,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..8e669b77 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -217,7 +217,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;
}
@@ -638,14 +639,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;
diff --git a/src/exec.zig b/src/exec.zig
index ee7c31e9..117970d9 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -322,12 +322,7 @@ 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;
}
@@ -357,29 +352,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).
@@ -552,7 +524,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 +579,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;
}
@@ -691,7 +657,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 +701,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 +750,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 +868,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 2b42be1f..0121a831 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -8,10 +8,10 @@ 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 [w] (stops what pardes ran, w: by first 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)
@@ -41,5 +41,5 @@ Pitfalls, one each:
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/gui/gui.zig b/src/gui/gui.zig
index 84ec1c5c..26e9e24d 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;
@@ -1847,6 +1865,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,
@@ -2292,6 +2311,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()});
@@ -2427,6 +2449,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,
@@ -5270,7 +5293,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;
@@ -5810,7 +5837,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) {
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 020aacb8..b6a89b64 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1251,20 +1251,15 @@ 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;
};
// focus an existing pane on this path (rescrolled), else open
@@ -1273,7 +1268,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
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);
+ 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) {
@@ -1285,9 +1280,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
.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);
},
}
}
diff --git a/src/mouse.zig b/src/mouse.zig
index 22eb3270..5f0a6c4b 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -337,6 +337,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 +453,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);
@@ -775,12 +779,15 @@ 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;
+ if (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col);
// a file moved into the left column evicts a lone unused shell
if (p.panes[d.id]) |mt| if (mt.file != null) {
if (layout.findPane(p, d.id)) |f| if (f.col == 0)
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 18068ab1..b4ae7231 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -308,6 +308,7 @@ 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 }),
+ .placement => try w.print("{s} {s}\n", .{ word, @tagName(s.placement) }),
.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}),
@@ -1120,7 +1121,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();
@@ -1130,14 +1131,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/pane.zig b/src/ninep/pane.zig
index 714a2566..99acf24b 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -593,11 +593,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;
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index b3f3dde6..5ef69bb4 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -577,7 +577,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 };
},
diff --git a/src/pardes.zig b/src/pardes.zig
index 00843b78..bad512a9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1092,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);
}
@@ -1179,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);
@@ -1191,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();
@@ -1215,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);
@@ -1233,6 +1236,112 @@ 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 "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();
@@ -3047,7 +3156,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));
}
@@ -3056,7 +3165,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);
@@ -3112,7 +3222,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];
@@ -3189,7 +3300,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");
@@ -3201,24 +3312,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);
@@ -3268,15 +3362,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 {
@@ -3563,6 +3659,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()) |_| {}
@@ -4099,9 +4196,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 = .{},
@@ -4347,10 +4450,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 => {
@@ -4469,7 +4574,7 @@ pub const Pardes = struct {
/// The column tags' height: a row per line of the tallest one.
pub fn columnBarHeight(p: *const Pardes) u16 {
- if (!p.settings.column_tags or p.screen_h < 3) return 0;
+ if (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);
@@ -4609,34 +4714,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 {
@@ -5088,10 +5215,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
@@ -5103,7 +5226,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;
@@ -5347,6 +5469,7 @@ pub const Pardes = struct {
return;
}
const pane = p.panes[p.active] orelse return;
+ p.noteTyped();
// 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) {
@@ -5395,16 +5518,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| {
@@ -5483,6 +5601,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;
@@ -5947,59 +6068,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]);
@@ -6008,11 +6108,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
@@ -6031,14 +6134,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;
@@ -6648,43 +6750,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;
@@ -6781,7 +6907,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);
@@ -6813,45 +6940,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" {
@@ -7017,6 +7149,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/surface.zig b/src/surface.zig
index b6f41853..a0471e51 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -397,7 +397,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 59c51b4d..c04cbc66 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -321,6 +321,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);
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}`);