summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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}`);