const std = @import("std"); pub const TOPBAR_H: u16 = 1; pub const GUTTER: u16 = 2; pub const BOX_H: u16 = 1; pub const MINW: u16 = 10; pub const MINH: u16 = 3; pub const PREFIX_W: u16 = 5; pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const MousePos = struct { col: u16, row: u16 }; pub const ScrollbarButton = enum { left, middle, right }; pub const Scrollbar = struct { total: usize, offset: usize, len: usize, }; pub fn Layout(comptime max_cols: usize, comptime max_panes: usize) type { return struct { ncol: usize, col_weight: [max_cols]f32, col_terms: [max_cols][max_panes]usize, col_n: [max_cols]usize, }; } pub fn layoutFindTerm(l: anytype, id: usize) ?struct { col: usize, idx: usize } { var c: usize = 0; while (c < l.ncol) : (c += 1) { var k: usize = 0; while (k < l.col_n[c]) : (k += 1) { if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k }; } } return null; } pub fn layoutRemove(l: anytype, id: usize) void { const f = layoutFindTerm(l, id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1]; l.col_n[c] -= 1; if (l.col_n[c] == 0) { if (l.ncol > 1) l.col_weight[if (c > 0) c - 1 else c + 1] += l.col_weight[c]; var j = c; while (j + 1 < l.ncol) : (j += 1) { l.col_terms[j] = l.col_terms[j + 1]; l.col_n[j] = l.col_n[j + 1]; l.col_weight[j] = l.col_weight[j + 1]; } l.ncol -= 1; } } pub fn layoutInsert(l: anytype, c: usize, idx: usize, id: usize) void { var k = l.col_n[c]; while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1]; l.col_terms[c][idx] = id; l.col_n[c] += 1; } pub fn layoutAppendColumn(l: anytype, id: usize) void { if (l.ncol >= l.col_weight.len) return; const c = l.ncol; l.col_weight[c] = 1; l.col_terms[c][0] = id; l.col_n[c] = 1; l.ncol += 1; } pub const MovePlacement = struct { preview_col: usize, above_id: usize, row: u16, above_y: u16, above_h: u16, }; pub fn computeGeom( l: anytype, items: anytype, sw: u16, sh: u16, rects: anytype, col_x: anytype, col_w: anytype, comptime vweightFn: anytype, ) void { if (l.ncol == 0) return; var wsum: f32 = 0; for (0..l.ncol) |c| wsum += l.col_weight[c]; if (wsum <= 0) wsum = 1; var x: u16 = 0; for (0..l.ncol) |c| { const last = c + 1 == l.ncol; const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(sw)) * l.col_weight[c] / wsum)))); col_x[c] = x; col_w[c] = cw; var vsum: f32 = 0; for (0..l.col_n[c]) |k| { if (vweightFn(items, l.col_terms[c][k])) |vweight| vsum += vweight; } if (vsum <= 0) vsum = 1; var y: u16 = TOPBAR_H; const avail_h = sh -| TOPBAR_H; for (0..l.col_n[c]) |k| { const id = l.col_terms[c][k]; const vweight = vweightFn(items, id) orelse continue; const lastk = k + 1 == l.col_n[c]; const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * vweight / vsum)))); rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } x +|= cw; } } fn targetColumn(l: anytype, col_x: anytype, col_w: anytype, cur_x: u16) usize { var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; for (0..l.ncol) |c| { if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { tc = c; break; } } return tc; } fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { if (h < 2) return null; const min_each: u16 = if (h >= MINH * 2) 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( l: anytype, rects: anytype, col_x: anytype, col_w: anytype, id: usize, cur_x: u16, cur_y: u16, ) ?MovePlacement { const src = layoutFindTerm(l, id) orelse return null; const tc = targetColumn(l, col_x, col_w, cur_x); if (tc == src.col and l.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = rects[id]; if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; } const max_panes = l.col_terms[0].len; var heights: [max_panes]u16 = @splat(0); for (0..l.ncol) |c| { var k: usize = 0; while (k < l.col_n[c]) : (k += 1) { const pid = l.col_terms[c][k]; heights[pid] = rects[pid].h; } } if (l.col_n[src.col] > 1) { const sib = if (src.idx > 0) l.col_terms[src.col][src.idx - 1] else l.col_terms[src.col][src.idx + 1]; heights[sib] +|= rects[id].h; } var y: u16 = TOPBAR_H; var last: ?MovePlacement = null; var k: usize = 0; while (k < l.col_n[tc]) : (k += 1) { const pid = l.col_terms[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; } fn setColumnWeights( l: anytype, items: anytype, col: usize, heights: anytype, comptime setVWeight: anytype, ) void { if (col >= l.ncol) return; var k: usize = 0; while (k < l.col_n[col]) : (k += 1) { const pid = l.col_terms[col][k]; setVWeight(items, pid, @as(f32, @floatFromInt(@max(1, heights[pid])))); } } pub fn moveTerm( l: anytype, items: anytype, rects: anytype, col_x: anytype, col_w: anytype, id: usize, cur_x: u16, cur_y: u16, comptime setVWeight: anytype, ) void { const placement = movePlacement(l, rects, col_x, col_w, id, cur_x, cur_y) orelse return; const src = layoutFindTerm(l, id) orelse return; const source_multi = l.col_n[src.col] > 1; const max_panes = l.col_terms[0].len; var heights: [max_panes]u16 = @splat(0); for (0..l.ncol) |c| { var k: usize = 0; while (k < l.col_n[c]) : (k += 1) { const pid = l.col_terms[c][k]; heights[pid] = rects[pid].h; } } layoutRemove(l, id); if (source_multi and src.col < l.ncol and l.col_n[src.col] > 0) { const sib = if (src.idx > 0) l.col_terms[src.col][src.idx - 1] else l.col_terms[src.col][src.idx]; heights[sib] +|= rects[id].h; } const af = layoutFindTerm(l, 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; layoutInsert(l, af.col, af.idx + 1, id); setColumnWeights(l, items, af.col, &heights, setVWeight); if (source_multi and src.col < l.ncol and src.col != af.col) setColumnWeights(l, items, src.col, &heights, setVWeight); } pub fn paneAt(rects: anytype, pane_count: usize, col: u16, row: u16) ?usize { for (0..pane_count) |i| { const r = rects[i]; if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i; } return null; } pub fn paneAtSlots(rects: anytype, slots: anytype, col: u16, row: u16) ?usize { for (slots.*, 0..) |slot, i| { if (slot == null) continue; const r = rects[i]; if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return i; } return null; } pub fn clampMousePos(screen_w: u16, screen_h: u16, mouse: anytype) MousePos { return .{ .col = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1), .row = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1), }; } pub fn inMoveBox(r: Rect, col: u16, row: u16) bool { return col < r.x + GUTTER and row < r.y + BOX_H; } pub fn scrollbarClickDelta(r: Rect, button: ScrollbarButton, col: u16, row: u16) ?i32 { if (col >= r.x + GUTTER or row < r.y + BOX_H) return null; const local: i32 = @as(i32, row) - @as(i32, r.y + BOX_H); return switch (button) { .left, .middle => -local, .right => local, }; } pub fn drawGutter( root: anytype, r: Rect, active: bool, active_box: [3]u8, inactive_box: [3]u8, track: [3]u8, thumb: [3]u8, scrollbar: ?Scrollbar, ) void { const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); const box_bg = if (active) active_box else inactive_box; var gy: u16 = 0; while (gy < r.h) : (gy += 1) { const bg = if (gy < BOX_H) box_bg else track; var gx: u16 = 0; while (gx < GUTTER) : (gx += 1) { gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } }); } } const sb = scrollbar orelse return; const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; if (track_h == 0) return; const total = if (sb.total == 0) 1 else sb.total; const len = @max(1, (track_h * @max(1, sb.len)) / total); const pos = (track_h * sb.offset) / total; var sy: usize = 0; while (sy < track_h) : (sy += 1) { if (sy < pos or sy >= pos + len) continue; var gx: u16 = 0; while (gx < GUTTER) : (gx += 1) { gut.writeCell(gx, @intCast(BOX_H + sy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = thumb } } }); } } } pub fn overlayDash(win: anytype, col: u16, row: u16, glyph: []const u8) void { var cell = win.readCell(col, row) orelse return; cell.char.grapheme = glyph; cell.default = false; win.writeCell(col, row, cell); } pub fn drawBorderV(win: anytype, col: u16, screen_h: u16) void { var row: u16 = TOPBAR_H; while (row < screen_h) : (row += 1) overlayDash(win, col, row, "╎"); } pub fn drawBorderH(win: anytype, x: u16, width: u16, row: u16) void { var col = x; while (col < x + width) : (col += 1) overlayDash(win, col, row, "╌"); } pub fn drawMovePreview(win: anytype, l: anytype, rects: anytype, col_x: anytype, col_w: anytype, id: usize, cur_x: u16, cur_y: u16, color: [3]u8) void { const placement = movePlacement(l, rects, col_x, col_w, id, cur_x, cur_y) orelse return; var col: u16 = col_x[placement.preview_col]; while (col < col_x[placement.preview_col] + col_w[placement.preview_col]) : (col += 1) { win.writeCell(col, placement.row, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = color } } }); } win.writeCell(cur_x, placement.row, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = color } } }); } pub fn drawHoverResizeHints( win: anytype, l: anytype, rects: anytype, col_x: anytype, col_w: anytype, screen_h: u16, hover_col: u16, hover_row: u16, ) void { var c: usize = 0; while (c + 1 < l.ncol) : (c += 1) { if (hover_col == col_x[c] + col_w[c] -| 1) drawBorderV(win, hover_col, screen_h); } var cc: usize = 0; while (cc < l.ncol) : (cc += 1) { if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue; var k: usize = 0; while (k + 1 < l.col_n[cc]) : (k += 1) { const r = rects[l.col_terms[cc][k]]; if (hover_row == r.y + r.h -| 1) drawBorderH(win, col_x[cc], col_w[cc], hover_row); } } } pub fn drawInteractionPreview( win: anytype, l: anytype, rects: anytype, col_x: anytype, col_w: anytype, screen_h: u16, drag: anytype, hover_col: u16, hover_row: u16, move_color: [3]u8, ) void { switch (drag) { .border_v => |d| drawBorderV(win, d.cur_x, screen_h), .border_h => |d| drawBorderH(win, col_x[d.col], col_w[d.col], d.cur_y), .move => |d| drawMovePreview(win, l, rects, col_x, col_w, d.id, d.cur_x, d.cur_y, move_color), else => drawHoverResizeHints(win, l, rects, col_x, col_w, screen_h, hover_col, hover_row), } } const TestLayout = Layout(4, 8); const TestItem = struct { vweight: f32 = 1 }; fn testVWeight(items: []const TestItem, id: usize) ?f32 { return items[id].vweight; } fn setTestVWeight(items: []TestItem, id: usize, weight: f32) void { items[id].vweight = weight; } fn expectYH(r: Rect, y: u16, h: u16) !void { try std.testing.expectEqual(y, r.y); try std.testing.expectEqual(h, r.h); } test "move into one-pane column appends at marker row" { var layout: TestLayout = .{ .ncol = 2, .col_weight = @splat(1), .col_terms = undefined, .col_n = @splat(0), }; layout.col_n[0] = 2; layout.col_terms[0][0] = 0; layout.col_terms[0][1] = 1; layout.col_n[1] = 1; layout.col_terms[1][0] = 2; var items = [_]TestItem{ .{ .vweight = 10 }, .{ .vweight = 20 }, .{ .vweight = 30 }, .{} }; var rects: [8]Rect = undefined; var col_x: [4]u16 = undefined; var col_w: [4]u16 = undefined; computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight); const placement = movePlacement(&layout, &rects, &col_x, &col_w, 0, 75, 18) orelse return error.NoMovePlacement; try std.testing.expectEqual(@as(usize, 2), placement.above_id); try std.testing.expectEqual(@as(u16, 18), placement.row); moveTerm(&layout, items[0..], &rects, &col_x, &col_w, 0, 75, 18, setTestVWeight); computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight); try std.testing.expectEqual(@as(usize, 2), layout.ncol); try std.testing.expectEqual(@as(usize, 1), layout.col_n[0]); try std.testing.expectEqual(@as(usize, 2), layout.col_n[1]); try std.testing.expectEqual(@as(usize, 2), layout.col_terms[1][0]); try std.testing.expectEqual(@as(usize, 0), layout.col_terms[1][1]); try expectYH(rects[1], 1, 30); try expectYH(rects[2], 1, 17); try expectYH(rects[0], 18, 13); } test "move into multi-pane column only splits the pane above" { var layout: TestLayout = .{ .ncol = 2, .col_weight = @splat(1), .col_terms = undefined, .col_n = @splat(0), }; layout.col_n[0] = 3; layout.col_terms[0][0] = 0; layout.col_terms[0][1] = 1; layout.col_terms[0][2] = 2; layout.col_n[1] = 1; layout.col_terms[1][0] = 3; var items = [_]TestItem{ .{ .vweight = 10 }, .{ .vweight = 10 }, .{ .vweight = 10 }, .{ .vweight = 30 } }; var rects: [8]Rect = undefined; var col_x: [4]u16 = undefined; var col_w: [4]u16 = undefined; computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight); const before_top = rects[0]; const before_bottom = rects[2]; const placement = movePlacement(&layout, &rects, &col_x, &col_w, 3, 20, 15) orelse return error.NoMovePlacement; try std.testing.expectEqual(@as(usize, 1), placement.above_id); try std.testing.expectEqual(@as(u16, 15), placement.row); moveTerm(&layout, items[0..], &rects, &col_x, &col_w, 3, 20, 15, setTestVWeight); computeGeom(&layout, items[0..], 100, 31, &rects, &col_x, &col_w, testVWeight); try std.testing.expectEqual(@as(usize, 1), layout.ncol); try std.testing.expectEqual(@as(usize, 4), layout.col_n[0]); try std.testing.expectEqual(@as(usize, 0), layout.col_terms[0][0]); try std.testing.expectEqual(@as(usize, 1), layout.col_terms[0][1]); try std.testing.expectEqual(@as(usize, 3), layout.col_terms[0][2]); try std.testing.expectEqual(@as(usize, 2), layout.col_terms[0][3]); try expectYH(rects[0], before_top.y, before_top.h); try expectYH(rects[1], 11, 4); try expectYH(rects[3], 15, 6); try expectYH(rects[2], before_bottom.y, before_bottom.h); }