summaryrefslogtreecommitdiff
path: root/src/layout.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 18:53:37 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitb8c5e8772e646fa21f6afdd2a875a1bd52848bd5 (patch)
tree70bca3d554501c49107b27396c7756e0e8ad62a3 /src/layout.zig
parentc4ab29bce30f427decf3e199eebecfd415714a96 (diff)
downloadpardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.tar.gz
pardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.zip
A column can be empty, as acme's can; new panes go where acme's makenewwindow puts them
Newcol makes an empty column; closing a column's last pane leaves it empty with the keyboard on its tag; Delcol and Joincol alone take a column away; the session's last pane closing quits. The +New stand-in and replaceStillborn / stillborn_joiner are gone. Every new pane goes through exec.placeNew, which follows acme's makenewwindow (active column, empty column whole, blank space, halving the biggest) or, with Placement pardes, the old rules. ColumnTags is gone: column tags are always shown. A grip drag shows acme's box cursor. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/layout.zig')
-rw-r--r--src/layout.zig238
1 files changed, 176 insertions, 62 deletions
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);