summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig11
-rw-r--r--src/exec.zig17
-rw-r--r--src/layout.zig63
-rw-r--r--src/ninep/tree.zig26
-rw-r--r--src/pardes.zig58
5 files changed, 151 insertions, 24 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index b197e715..daf195bb 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -444,18 +444,23 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
return &m.log[(first + i) % limits.message_log];
}
-/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
/// What a route that would open a pane says when every slot is taken.
/// "no space": 9ns's word for ENOSPC (it has none for ENFILE).
pub const no_pane_slots = "no space for a pane: {d} max";
+/// ...and when the column it would go in has no rows for one.
+pub const no_pane_room = "no space for a pane in that column: each keeps its tag and {d} rows";
+/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
// Every route that opens a pane says the cap the same way.
- if (err == error.NoPaneSlots) {
- w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {};
+ if (err == error.NoPaneSlots or err == error.NoPaneRoom) {
+ if (err == error.NoPaneSlots)
+ w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}
+ else
+ w.print(no_pane_room, .{pardes.layout.min_body_rows}) catch {};
// A 9P look or exec that would open the pane fails with it too.
p.fs.no_pane_slot = true;
@memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
diff --git a/src/exec.zig b/src/exec.zig
index d4480e00..6de7991c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -808,6 +808,8 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
/// 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;
+ layout.compute(p);
+ const before = p.rects;
switch (p.settings.placement) {
.acme => switch (kind) {
// New: the bottom half of the last pane in its own column
@@ -821,6 +823,21 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
},
.pardes => pardesPlace(p, from, id, kind),
}
+ // No pane is made shorter than its tag and two rows: where the place
+ // chosen has not the room, the column's tallest pane gives it, and
+ // where none can, the pane is refused, and closed at the step's end
+ // (Pardes.unplaced) once whatever made it is done with it.
+ if (!layout.placedRoomy(p, id, &before)) if (layout.findPane(p, id)) |f| {
+ layout.removePane(p, id);
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, f.col);
+ if (!layout.splitTallest(p, f.col, id) or !layout.placedRoomy(p, id, &before)) {
+ if (layout.findPane(p, id) == null) layout.insert(p, f.col, p.col_n[f.col], id);
+ p.unplaced.set(id);
+ p.reportError(from, "placement", error.NoPaneRoom);
+ return;
+ }
+ };
if (kind == .command) return;
tagline.exitHeader(p);
p.active = id;
diff --git a/src/layout.zig b/src/layout.zig
index b9b24e79..4c974fb7 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -408,6 +408,46 @@ pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
splitAt(p, c, id, y);
}
+/// The body rows a placement leaves every pane it makes or shrinks, under
+/// its tag (acme's minht keeps one).
+pub const min_body_rows = 2;
+
+fn minRows(p: *Pardes, id: usize) u16 {
+ return p.wantedTagRows(id) + min_body_rows;
+}
+
+/// After placing `id`: whether it, and every pane of its column that the
+/// placement shrank, still has its tag and `min_body_rows` (from `before`,
+/// the rects as they were). A pane alone in its column always has room: the
+/// window can be no taller.
+pub fn placedRoomy(p: *Pardes, id: usize, before: *const [MAX_PANES]Rect) bool {
+ compute(p);
+ const f = findPane(p, id) orelse return true;
+ if (p.col_n[f.col] == 1) return true;
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ if (k != id and p.rects[k].h >= before[k].h) continue;
+ if (p.rects[k].h < minRows(p, k)) return false;
+ }
+ return true;
+}
+
+/// Places `id` in column `c` by halving its tallest unfolded pane, when
+/// that leaves both their rows; false, and `id` in no column, when not.
+pub fn splitTallest(p: *Pardes, c: usize, id: usize) bool {
+ compute(p);
+ var tall: ?usize = null;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (k == id or p.panes[k].?.collapsed) continue;
+ if (tall == null or p.rects[k].h > p.rects[tall.?].h) tall = k;
+ }
+ const v = tall orelse return false;
+ if (p.rects[v].h < minRows(p, v) + minRows(p, id)) return false;
+ splitAt(p, c, id, p.rects[v].y + p.rects[v].h / 2);
+ return true;
+}
+
/// 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 {
@@ -419,9 +459,9 @@ fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
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);
+ // Each keeps a tag and min_body_rows, where acme's minht keeps one.
+ const lo = r.y +| minRows(p, v);
+ const hi = (r.y + r.h) -| minRows(p, id);
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;
@@ -550,7 +590,7 @@ pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize {
}
pub fn splitParent(p: *Pardes, want: usize) usize {
- const need = 2 * BOX_H + 3;
+ const need = 2 * (BOX_H + min_body_rows);
if (p.rects[want].h >= need) return want;
if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| {
if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k];
@@ -567,23 +607,28 @@ pub fn splitParent(p: *Pardes, want: usize) usize {
pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
- const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ // Its tag may wrap to more than one row: the rows under it are its body.
+ const tag_h = @max(BOX_H, src.tag_rows);
+ const body: u16 = if (src_h > tag_h) src_h - tag_h else 1;
const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
- const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
+ // ...and the source min_body_rows of its own (exec.placeNew), when it
+ // has them: a boot layout splits before any row is laid out.
+ const least = @min(min_body_rows, body);
+ const keep = std.math.clamp(cur, least, @max(least, body -| (BOX_H + 3)));
if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| {
const pid = p.col_panes[f.col][k];
if (p.panes[pid]) |pp| if (pp != nw and !pp.collapsed) {
pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
};
};
- if (!src.collapsed) src.vweight = @floatFromInt(BOX_H + keep);
- nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep)));
+ if (!src.collapsed) src.vweight = @floatFromInt(tag_h + keep);
+ nw.vweight = @floatFromInt(@max(1, src_h -| (tag_h + keep)));
if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) {
// trimmed: every row ends in a newline, and the empty line after
// the last one is not a result
- const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n")));
+ const want: f32 = @floatFromInt(BOX_H + @max(min_body_rows, panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))));
if (want < nw.vweight) {
src.vweight += nw.vweight - want;
nw.vweight = want;
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index aec3cab7..414a0b6e 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -713,8 +713,11 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// closing it leaves the pane alone.
.new => {
const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.screen => .{ .snapshot = null },
@@ -1427,6 +1430,8 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows.
+ p.update(.{ .resize = .{ .cols = 80, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
@@ -1496,3 +1501,24 @@ test "look and exec read back what their own open's write touched; a fresh open
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index f2791e33..ac6d52cd 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4271,6 +4271,10 @@ pub const Pardes = struct {
/// 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,
+ /// Panes made this step that found no room in their column
+ /// (exec.placeNew): closed at the step's end, once whatever made them
+ /// is done with them.
+ unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(),
/// 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
@@ -6388,7 +6392,26 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
+ /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps
+ /// it: what a placement must leave it above its body rows.
+ pub fn wantedTagRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return BOX_H;
+ if (pane.collapsed or !pane.tag_expanded) return BOX_H;
+ if (p.rects[id].w == 0) return pane.tag_rows; // never laid out
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
+ const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
+ return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
+ }
+
pub fn sync(p: *Pardes) void {
+ while (p.unplaced.findFirstSet()) |id| {
+ p.unplaced.unset(id);
+ if (p.panes[id] != null) p.removePane(id, null) catch {};
+ }
tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
@@ -7042,7 +7065,10 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(low, p.active); // focus follows the rows
// Esc takes the question back and the pane stays; so does any other key,
- // and so does a click.
+ // and so does a click. (Top gets rows back first: split at its cursor,
+ // it kept its tag and two, too few to give a pane below its own.)
+ p.panes[top].?.vweight = p.panes[low].?.vweight;
+ p.sync();
p.newScratchBelow(top);
const again = p.active;
p.sync();
@@ -7096,17 +7122,6 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial})));
- // A pane closed while its question stands ends the question in the log.
- p.newScratchBelow(top);
- const closing = p.active;
- p.sync();
- const closing_serial = p.panes[closing].?.serial;
- p.update(.{ .key = .{ .cp = ' ' } });
- p.update(.{ .key = .{ .cp = 'd' } });
- try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
- try p.removePane(closing, null);
- try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ closing_serial, closing_serial })));
- p.active = asked;
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
@@ -7891,3 +7906,22 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
p.advance(p.stepped_ns + frame);
try std.testing.expectEqual(@as(?u64, null), p.nextWake());
}
+
+test "a pane closed while its question stands ends the question in the log" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ p.settings.placement = .pardes;
+ p.newScratchBelow(0);
+ const closing = p.active;
+ p.newScratchBelow(closing);
+ p.sync();
+ p.active = closing; // a middle pane: Del from a key asks which side
+ const serial = p.panes[closing].?.serial;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
+ try p.removePane(closing, null);
+ var want: [64]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial })));
+}