diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Messages.zig | 11 | ||||
| -rw-r--r-- | src/exec.zig | 17 | ||||
| -rw-r--r-- | src/layout.zig | 63 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 26 | ||||
| -rw-r--r-- | src/pardes.zig | 58 |
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 }))); +} |
