const std = @import("std"); const pardes = @import("pardes.zig"); const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const config = @import("config.zig"); const panes = @import("panes.zig"); const animation = @import("animation.zig"); const Box = animation.Box; const Pardes = pardes.Pardes; const Pane = pardes.Pane; pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 }; const MAX_COLS = pardes.MAX_COLS; const MAX_PANES = pardes.MAX_PANES; const BOX_H = pardes.BOX_H; pub const column_weight_unit: u64 = 1 << 32; pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; pub const MovePlacement = struct { preview_col: usize, above_id: usize, row: u16, above_y: u16, above_h: u16, }; /// Move the keyboard to the nearest pane in `dir`; false when there is none. pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bool { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; for (p.panes, 0..) |slot, i| { if (slot == null or i == from) continue; const r = p.rects[i]; const vov = a.y < r.y + r.h and r.y < a.y + a.h; const hov = a.x < r.x + r.w and r.x < a.x + a.w; const ok = switch (dir) { .left => r.x + r.w <= a.x and vov, .right => r.x >= a.x + a.w and vov, .up => r.y + r.h <= a.y and hov, .down => r.y >= a.y + a.h and hov, }; if (!ok) continue; const d: i32 = switch (dir) { .left => @as(i32, a.x) - @as(i32, r.x + r.w), .right => @as(i32, r.x) - @as(i32, a.x + a.w), .up => @as(i32, a.y) - @as(i32, r.y + r.h), .down => @as(i32, r.y) - @as(i32, a.y + a.h), }; if (best == null or d < best_d) { best = i; best_d = d; } } const b = best orelse return false; p.active = b; // a count typed before the hop was meant for the pane you left p.panes[b].?.body.normal.clear(); return true; } 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; return null; } /// What releasing column `source`'s grip at `x` does: past a neighbour's /// middle it takes that column's place; short of it, as in acme, the /// column's left edge moves to `x - grab`, clamped as setColumnPairWidth /// clamps it. Still on the grip, or at the leftmost column, nothing moves. /// The drag preview and the release both ask here, so they cannot disagree. pub const ColumnDrop = union(enum) { stay, reorder: usize, edge: u16 }; pub fn columnDrop(p: *const Pardes, source: usize, x: u16, grab: u16) ColumnDrop { if (source >= p.ncol) return .stay; var insertion: usize = 0; for (0..p.ncol) |column| { if (column == source) continue; if (x >= p.col_x[column] + p.col_w[column] / 2) insertion += 1; } if (insertion != source) return .{ .reorder = insertion }; if (source == 0 or (x >= p.col_x[source] and x < p.col_x[source] + config.GUTTER)) return .stay; if (p.col_w[source - 1] + p.col_w[source] < 2 * config.MINW) return .stay; const lo = p.col_x[source - 1] + config.MINW; const hi = (p.col_x[source] + p.col_w[source]) -| config.MINW; return .{ .edge = std.math.clamp(x -| grab, lo, @max(lo, hi)) }; } /// Share out the width of columns `left` and `left + 1` so the left one is /// `left_width` cells, neither below MINW. A border drag and a column grip /// dropped inside its own place both end here. pub fn setColumnPairWidth(p: *Pardes, left: usize, left_width: u64) void { if (left + 1 >= p.ncol) return; const combined: u64 = p.col_w[left] + p.col_w[left + 1]; // Too narrow for both to keep MINW: any split would squeeze one away. if (combined < 2 * @as(u64, config.MINW)) return; const width = std.math.clamp(left_width, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); const pair = p.col_weight[left] + p.col_weight[left + 1]; if (pair <= 1 or combined == 0) return; const numerator = @as(u128, pair) * width + combined / 2; const share = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); p.col_weight[left] = share; p.col_weight[left + 1] = pair - share; } pub fn reorderColumn(p: *Pardes, from: usize, to: usize) void { if (from >= p.ncol or to >= p.ncol or from == to) return; const ids = p.col_panes[from]; const count = p.col_n[from]; const weight = p.col_weight[from]; const tag = p.col_tags[from]; const serial = p.col_serial[from]; var at = from; while (at != to) { const next = if (at < to) at + 1 else at - 1; p.col_panes[at] = p.col_panes[next]; p.col_n[at] = p.col_n[next]; p.col_weight[at] = p.col_weight[next]; p.col_tags[at] = p.col_tags[next]; p.col_serial[at] = p.col_serial[next]; at = next; } p.col_panes[to] = ids; p.col_n[to] = count; p.col_weight[to] = weight; p.col_tags[to] = tag; p.col_serial[to] = serial; if (p.header_column) |column| { if (column == from) p.header_column = to else if (from < to and column > from and column <= to) { p.header_column = column - 1; } else if (from > to and column >= to and column < from) p.header_column = column + 1; } p.pointer_tag_hit = null; p.look_hover_wait = null; p.look_hover_preview = null; compute(p); } pub fn targetColumn(p: *Pardes, cur_x: u16) usize { var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; for (0..p.ncol) |c| { if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { tc = c; break; } } return tc; } pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { if (h < 2) return null; const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; const lo = y +| min_each; const hi = y + h - min_each; if (lo > hi) return y + h / 2; return std.math.clamp(cur_y, lo, hi); } pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { const src = findPane(p, id) orelse return null; const tc = targetColumn(p, cur_x); if (tc == src.col and p.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = p.rects[id]; if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; } var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_panes[c][k]; heights[pid] = p.rects[pid].h; } } if (p.col_n[src.col] > 1) { const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx + 1]; heights[sib] +|= p.rects[id].h; } var y: u16 = p.topBarHeight() + p.columnBarHeight(); var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_panes[tc][k]; if (pid == id) continue; const h = heights[pid]; const row = splitRowForExtent(y, h, cur_y) orelse { y +|= h; continue; }; const placement: MovePlacement = .{ .preview_col = tc, .above_id = pid, .row = row, .above_y = y, .above_h = h, }; last = placement; if (cur_y < y + h) return placement; y +|= h; } return last; } pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { const placement = movePlacement(p, id, cur_x, cur_y) orelse return; const src = findPane(p, id) orelse return; const source_multi = p.col_n[src.col] > 1; var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_panes[c][k]; heights[pid] = p.rects[pid].h; } } removePane(p, id); if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { 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 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[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); } pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { if (col >= p.ncol) return; for (0..p.col_n[col]) |k| { const pid = p.col_panes[col][k]; if (p.panes[pid]) |pane| if (!pane.collapsed) { pane.vweight = @floatFromInt(@max(1, heights[pid])); }; } } pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { if (k + 1 >= p.col_n[cc]) return; const a = p.panes[p.col_panes[cc][k]] orelse return; const b = p.panes[p.col_panes[cc][k + 1]] orelse return; const ra = p.rects[p.col_panes[cc][k]]; const rb = p.rects[p.col_panes[cc][k + 1]]; const combined: f32 = @floatFromInt(ra.h + rb.h); var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); if (combined <= 0) return; // Dragging a folded tag open is an explicit resize, so switch this pair // back to visible-height weights without consuming other folded weights. if ((a.collapsed and nt > BOX_H) or (b.collapsed and combined - nt > BOX_H)) { snapColWeights(p, cc); a.vweight = @floatFromInt(@max(1, ra.h)); b.vweight = @floatFromInt(@max(1, rb.h)); if (nt > BOX_H) a.collapsed = false; if (combined - nt > BOX_H) b.collapsed = false; } if (a.collapsed or b.collapsed) return; const pair = a.vweight + b.vweight; a.vweight = pair * (nt / combined); b.vweight = pair - a.vweight; } pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k }; } } return null; } pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void { var k = p.col_n[c]; while (k > idx) : (k -= 1) p.col_panes[c][k] = p.col_panes[c][k - 1]; p.col_panes[c][idx] = id; p.col_n[c] += 1; } 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; 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]; 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 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; const source = findPane(p, source_id) orelse 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; const weight = p.col_weight[source.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) { 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); tagline.exitHeader(p); var j = p.ncol; while (j > c) : (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.col_weight[c] = new_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.ncol += 1; return true; } pub fn snapColWeights(p: *Pardes, c: usize) void { for (0..p.col_n[c]) |k| { const pid = p.col_panes[c][k]; if (p.panes[pid]) |pp| if (!pp.collapsed) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; } } /// Hand a closing pane's rows to `to`. With none named they go up: past any /// results listing to the nearest pane above that is not one, or to the /// pane below when the closing one is first in its column. /// Give a closing pane's rows to `to`, or to the nearest document above it /// (the pane below for the top one); the pane that got them is returned. pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize { const f = findPane(p, id) orelse return null; if (p.col_n[f.col] <= 1) return null; snapColWeights(p, f.col); var sib = to orelse if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1]; var k = f.idx; while (to == null and k > 0) : (k -= 1) { sib = p.col_panes[f.col][k - 1]; if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break; } const s = p.panes[sib] orelse return null; s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); return sib; } pub fn splitParent(p: *Pardes, want: usize) usize { const need = 2 * BOX_H + 3; 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]; }; var tallest = want; for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { const pid = p.col_panes[c][k]; if (p.rects[pid].h >= need) return pid; if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; }; return tallest; } 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; 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))); 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 (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"))); if (want < nw.vweight) { src.vweight += nw.vweight - want; nw.vweight = want; } }; } pub fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { const f = findPane(p, source_id) orelse return false; compute(p); return p.col_w[f.col] >= min_cells * 2; } pub fn panelBox(rect: Rect) Box { return .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; } pub fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { if (total == 0) return 0; const pixels = (@as(u128, width) * prefix + total / 2) / total; return @intCast(@min(@as(u128, width), pixels)); } pub fn toggleCollapse(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const at = findPane(p, id) orelse return; compute(p); const was_collapsed = pane.collapsed; const old_height = p.rects[id].h; const remembered_height = pane.vweight; // Convert only visible panes to row weights. The toggle then changes one // pair, rather than letting the column redistribute the released space. snapColWeights(p, at.col); const neighbor = expandedNeighbor(p, at.col, at.idx, .above) orelse expandedNeighbor(p, at.col, at.idx, .below); if (was_collapsed) { if (neighbor) |other| { const wanted: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(std.math.maxInt(u16))), @max(1, @round(remembered_height)))); const give = @min(wanted -| old_height, p.rects[other].h -| BOX_H); p.panes[other].?.vweight -= @floatFromInt(give); pane.vweight = @floatFromInt(@max(1, old_height + give)); } pane.collapsed = false; } else { // A folded pane's weight stores its former height, including its tag, // so Dump/Restore and a later expansion retain a useful target size. pane.vweight = @floatFromInt(@max(1, old_height)); pane.collapsed = true; if (neighbor) |other| p.panes[other].?.vweight += @floatFromInt(old_height -| BOX_H); } compute(p); } /// The nearest pane on one side of `index` in its column that is not folded /// to its tag: the one Collapse lends rows to and Del k / Del j gives them /// to. A folded pane only keeps its weight for later, so rows handed to it /// would spread over the whole column instead. pub fn expandedNeighbor(p: *const Pardes, col: usize, index: usize, side: enum { above, below }) ?usize { var k = index; while (if (side == .above) k > 0 else k + 1 < p.col_n[col]) { k = if (side == .above) k - 1 else k + 1; const id = p.col_panes[col][k]; if (p.panes[id]) |pane| if (!pane.collapsed) return id; } return null; } fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usize) void { var y: u16 = p.topBarHeight() + p.columnBarHeight(); const available = p.screen_h -| y; var expanded_left: usize = 0; var weight: f32 = 0; for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { if (!pane.collapsed) { expanded_left += 1; weight += pane.vweight; } }; const expanded_height = available -| @as(u16, @intCast(folded)); var budget = expanded_height; var folded_left = folded; for (p.col_panes[c][0..p.col_n[c]]) |id| { const pane = p.panes[id] orelse continue; const room = p.screen_h -| y; const height: u16 = if (pane.collapsed) blk: { folded_left -= 1; break :blk @min(room, BOX_H); } else blk: { expanded_left -= 1; const limit = @min(budget, room -| @as(u16, @intCast(folded_left))); const share = @as(f32, @floatFromInt(expanded_height)) * pane.vweight / (if (weight > 0) weight else 1); const h = if (expanded_left == 0) limit else @min(limit -| @as(u16, @intCast(expanded_left)), @max(1, @as(u16, @intFromFloat(@round(share))))); budget -|= h; break :blk h; }; p.rects[id] = .{ .x = x, .y = y, .w = width, .h = height }; y +|= height; } } 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; }; var wsum: u128 = 0; for (0..p.ncol) |c| wsum += p.col_weight[c]; if (wsum == 0) wsum = 1; // Round cumulative boundaries so widths still sum to the available screen. var prefix: u128 = 0; for (0..p.ncol) |c| { const last = c + 1 == p.ncol; const x = columnBoundary(p.screen_w, prefix, wsum); prefix += p.col_weight[c]; const end: u16 = if (last) p.screen_w else columnBoundary(p.screen_w, prefix, wsum); const cw = end -| x; p.col_x[c] = x; p.col_w[c] = cw; var folded: usize = 0; for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { if (pane.collapsed) folded += 1; }; if (folded > 0) { computeCollapsedColumn(p, c, x, cw, folded); continue; } var vsum: f32 = 0; for (0..p.col_n[c]) |k| { if (p.panes[p.col_panes[c][k]]) |pane| vsum += pane.vweight; } if (vsum <= 0) vsum = 1; var y: u16 = p.topBarHeight() + p.columnBarHeight(); const avail_h = p.screen_h -| y; for (0..p.col_n[c]) |k| { const id = p.col_panes[c][k]; const pane = p.panes[id] orelse continue; const lastk = k + 1 == p.col_n[c]; const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; const room = p.screen_h -| y; const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } } } test "collapse transfers height upward and restores the original layout" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); insert(p, 0, 1, 1); insert(p, 0, 2, 2); p.panes[0].?.vweight = 3; p.panes[1].?.vweight = 2; compute(p); const original = p.rects; toggleCollapse(p, 1); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); try std.testing.expectEqual(@as(f32, @floatFromInt(original[1].h)), p.panes[1].?.vweight); try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); try std.testing.expect(p.rects[0].h > original[0].h); try std.testing.expectEqualDeep(original[2], p.rects[2]); toggleCollapse(p, 1); try std.testing.expectEqualDeep(original, p.rects); toggleCollapse(p, 2); try std.testing.expectEqual(@as(u16, 1), p.rects[2].h); try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); } test "collapse changes only one neighbor at the top middle and bottom" { for (0..4) |target| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } for ([_]f32{ 11, 7, 5, 3 }, 0..) |weight, id| p.panes[id].?.vweight = weight; compute(p); const original = p.rects; const recipient = if (target > 0) target - 1 else 1; for (0..5) |_| { toggleCollapse(p, target); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[target].h); try std.testing.expectEqual(original[recipient].h + original[target].h - BOX_H, p.rects[recipient].h); for (0..4) |id| if (id != target and id != recipient) try std.testing.expectEqualDeep(original[id], p.rects[id]); toggleCollapse(p, target); try std.testing.expectEqualDeep(original, p.rects); } } } test "collapse skips folded neighbors and expansion reclaims only its neighbor" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 62 }); defer p.deinit(); for (1..5) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } compute(p); toggleCollapse(p, 1); toggleCollapse(p, 2); const original = p.rects; toggleCollapse(p, 3); try std.testing.expectEqual(original[0].h + original[3].h - BOX_H, p.rects[0].h); try std.testing.expectEqualDeep(original[4], p.rects[4]); for (1..4) |id| try std.testing.expectEqual(@as(u16, BOX_H), p.rects[id].h); toggleCollapse(p, 3); try std.testing.expectEqualDeep(original, p.rects); // With no expanded pane above, give the space to the first one below. toggleCollapse(p, 0); const with_folded_above = p.rects; toggleCollapse(p, 3); try std.testing.expectEqual(with_folded_above[4].h + with_folded_above[3].h - BOX_H, p.rects[4].h); for (0..3) |id| try std.testing.expectEqualDeep(with_folded_above[id], p.rects[id]); toggleCollapse(p, 3); try std.testing.expectEqualDeep(with_folded_above, p.rects); } test "collapse local transfers remain bounded through repeated tiny window resizes" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); defer p.deinit(); for (1..5) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } 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); var used: u16 = 0; for (0..5) |id| { const pane = p.panes[id].?; const rect = p.rects[id]; try std.testing.expect(std.math.isFinite(pane.vweight) and pane.vweight > 0); try std.testing.expect(rect.h <= p.screen_h -| rect.y); if (pane.collapsed) try std.testing.expect(rect.h <= BOX_H); used += rect.h; } try std.testing.expect(used <= p.screen_h); } } test "collapse expansion is limited to space available in one neighbor" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); insert(p, 0, id, id); } compute(p); toggleCollapse(p, 2); snapColWeights(p, 0); // An intervening resize gave most of the donor's space to another pane. p.panes[0].?.vweight += p.panes[1].?.vweight - 2; p.panes[1].?.vweight = 2; compute(p); const before = p.rects; toggleCollapse(p, 2); try std.testing.expectEqual(@as(u16, 2), p.rects[2].h); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[1].h); try std.testing.expectEqualDeep(before[0], p.rects[0]); try std.testing.expectEqualDeep(before[3], p.rects[3]); } test "collapse single and all panes leave unused column space" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); toggleCollapse(p, 0); try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); _ = try p.newShell(1, ""); insert(p, 0, 1, 1); toggleCollapse(p, 1); try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); try std.testing.expectEqual(p.rects[0].y + 1, p.rects[1].y); try std.testing.expect(p.rects[1].y + 1 < p.screen_h); toggleCollapse(p, 0); try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y); } test "collapse survives tiny resizes column tags and TagBottom" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); insert(p, 0, 1, 1); insert(p, 0, 2, 2); 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); var used: u16 = 0; for (p.col_panes[0][0..p.col_n[0]]) |id| { const rect = p.rects[id]; try std.testing.expect(rect.h <= p.screen_h -| rect.y); used += rect.h; } try std.testing.expect(used <= p.screen_h); if (height >= 5) try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); } } } p.screen_h = 32; compute(p); try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); } test "collapse can be expanded by dragging its boundary" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); insert(p, 0, 1, 1); toggleCollapse(p, 0); applyRowSplit(p, 0, 0, p.rects[0].y + 5); compute(p); try std.testing.expect(!p.panes[0].?.collapsed); try std.testing.expectEqual(@as(u16, 6), p.rects[0].h); toggleCollapse(p, 1); p.settings.tag_bottom = true; applyRowSplit(p, 0, 0, p.rects[1].y - 5); compute(p); try std.testing.expect(!p.panes[1].?.collapsed); try std.testing.expectEqual(@as(u16, 6), p.rects[1].h); } test "collapse dump restores folded state and retained expansion weight" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); p.panes[0].?.vweight = 7; compute(p); const original_height = p.rects[0].h; toggleCollapse(p, 0); try dump.dumpState(p); const restored = try dump.initFromDump(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }, p.dump_out.?); defer restored.deinit(); try std.testing.expect(restored.panes[0].?.collapsed); try std.testing.expectEqual(@as(f32, @floatFromInt(original_height)), restored.panes[0].?.vweight); try std.testing.expectEqual(@as(u16, 1), restored.rects[0].h); toggleCollapse(restored, 0); try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h); } test "column reorder moves only crossed state and preserves weights" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 }); defer p.deinit(); p.ncol = 4; p.col_n = @splat(0); for (0..4) |c| { p.col_weight[c] = c * 13 + 7; p.col_serial[c] = @intCast(c + 10); p.col_panes[c][0] = c; p.col_tags[c].cur_col = @intCast(c + 20); } compute(p); const original_weights = p.col_weight; const outer_x = p.col_x[0]; const outer_w = p.col_w[0]; p.header_focus = true; p.header_column = 1; p.header_scroll = 9; reorderColumn(p, 1, 3); try std.testing.expectEqual(original_weights[1], p.col_weight[3]); try std.testing.expectEqual(original_weights[2], p.col_weight[1]); try std.testing.expectEqual(@as(u32, 11), p.col_serial[3]); try std.testing.expectEqual(@as(?usize, 3), p.header_column); try std.testing.expectEqual(@as(i32, 21), tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(@as(usize, 9), p.header_scroll); try std.testing.expectEqual(outer_x, p.col_x[0]); try std.testing.expectEqual(outer_w, p.col_w[0]); reorderColumn(p, 3, 1); try std.testing.expectEqualSlices(u64, original_weights[0..4], p.col_weight[0..4]); try std.testing.expectEqual(@as(i32, 21), p.col_tags[1].cur_col); }