const std = @import("std"); const pardes = @import("pardes"); const layout = pardes.layout; const config = pardes.config; const Pardes = pardes.Pardes; fn mouse(p: *Pardes, kind: pardes.Mouse.Kind, col: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = kind, .col = col, .row = pardes.TOPBAR_H } }); } fn tagLayer(p: *Pardes, column: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { if (layer.rows > 0 and layer.kind == .column and layer.id == column) return layer; } return error.MissingColumnTagLayer; } fn headerMouse(layer: *const pardes.Layer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(display_col)) + 0.5) * 5; const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } const Snapshot = struct { weight: u64, serial: u32, panes: [pardes.MAX_PANES]usize = undefined, count: usize, tag: []const u8, }; fn expectOrder(p: *Pardes, before: []const Snapshot, order: []const usize) !void { try std.testing.expectEqual(order.len, p.ncol); for (order, 0..) |original, column| { const saved = before[original]; try std.testing.expectEqual(saved.weight, p.col_weight[column]); try std.testing.expectEqual(saved.serial, p.col_serial[column]); try std.testing.expectEqual(saved.count, p.col_n[column]); try std.testing.expectEqualSlices(usize, saved.panes[0..saved.count], p.col_panes[column][0..p.col_n[column]]); try std.testing.expectEqualStrings(saved.tag, pardes.tagline.columnTag(p, column)); } } test "column grip drag moves only its interval and keeps tag caret with the column" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 180, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; for (0..3) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); const id = p.active; try std.testing.expect(layout.splitColumn(p, id, id, false)); } p.active = p.col_panes[1][0]; try std.testing.expect(p.executeBuiltinLine(p.active, "New")); const active = p.active; try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); for ([_]u64{ 2, 3, 5, 7 }, 0..) |weight, column| { p.col_weight[column] = weight; p.col_tags[column].own = try p.gpa.dupe(u8, ([_][]const u8{ "zero column header", "one column header", "two column header", "three column header" })[column]); } p.update(.tick); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); var before: [4]Snapshot = undefined; for (&before, 0..) |*saved, column| { saved.* = .{ .weight = p.col_weight[column], .serial = p.col_serial[column], .count = p.col_n[column], .tag = pardes.tagline.columnTag(p, column) }; @memcpy(saved.panes[0..saved.count], p.col_panes[column][0..saved.count]); } const layer = try tagLayer(p, 1); const stale = headerMouse(layer, 4, .press); p.update(.{ .mouse = headerMouse(layer, 10, .press) }); p.update(.{ .mouse = headerMouse(layer, 10, .release) }); try std.testing.expectEqual(@as(?usize, 1), p.header_column); const caret = pardes.tagline.focusedHeader(p).?.cur_col; const anchor = pardes.tagline.focusedHeader(p).?.vsel; const scroll = p.header_scroll; const target = p.col_x[3] + p.col_w[3] - 2; const last_rect = p.rects[p.col_panes[3][0]]; const last_columns = (try tagLayer(p, 3)).cols; mouse(p, .press, p.col_x[1]); try std.testing.expect(p.drag == .column_move); mouse(p, .drag, target); _ = try p.render(frame.allocator()); try std.testing.expectEqual(p.col_w[3] - pardes.TAG_TEXT_INSET - 1, (try tagLayer(p, 3)).viewport.w); try std.testing.expectEqual(last_rect, p.rects[p.col_panes[3][0]]); try std.testing.expectEqual(last_columns, (try tagLayer(p, 3)).cols); try expectOrder(p, &before, &.{ 0, 1, 2, 3 }); mouse(p, .release, target); try expectOrder(p, &before, &.{ 0, 2, 3, 1 }); try std.testing.expectEqual(active, p.active); try std.testing.expectEqual(@as(?usize, 3), p.header_column); try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(anchor, pardes.tagline.focusedHeader(p).?.vsel); try std.testing.expectEqual(scroll, p.header_scroll); // The old tag envelope must not edit the new occupant of its old index, // including the interval before hosts request another rendered frame. p.update(.{ .mouse = stale }); try std.testing.expectEqual(@as(?usize, 3), p.header_column); try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); _ = try p.render(frame.allocator()); try std.testing.expectEqual(before[1].serial, (try tagLayer(p, 3)).serial); try std.testing.expectEqual(p.col_w[3] - pardes.TAG_TEXT_INSET, (try tagLayer(p, 3)).viewport.w); mouse(p, .press, p.col_x[3]); const back = p.col_x[1] + config.GUTTER; mouse(p, .drag, back); mouse(p, .release, back); try expectOrder(p, &before, &.{ 0, 1, 2, 3 }); try std.testing.expectEqual(@as(?usize, 1), p.header_column); try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(active, p.active); // A structural change between press and release must not retarget the // in-flight drag to whichever column now occupies the original index. mouse(p, .press, p.col_x[1]); layout.reorderColumn(p, 1, 2); mouse(p, .release, p.col_x[0] + config.GUTTER); try expectOrder(p, &before, &.{ 1, 0, 2, 3 }); try std.testing.expectEqual(@as(?usize, 0), p.header_column); try std.testing.expect(p.drag == .none); } test "column grip is separate from compact text and the resize seam" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); p.update(.tick); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const layer = try tagLayer(p, 1); try std.testing.expectEqual(p.col_x[1] + pardes.TAG_TEXT_INSET, layer.viewport.x); const surface = &p.surface; for (0..p.ncol) |column| { const pane_id = p.col_panes[column][0]; const rect = p.rects[pane_id]; try std.testing.expectEqual(p.col_x[column], rect.x); try std.testing.expectEqual(rect.x + pardes.TAG_TEXT_INSET, (try tagLayer(p, column)).viewport.x); for (p.surface.tagLayers()) |pane_layer| { if (pane_layer.rows > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) try std.testing.expectEqual((try tagLayer(p, column)).viewport.x, pane_layer.viewport.x); } const pane_tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; for (0..config.GUTTER) |offset| { const x = rect.x + @as(u16, @intCast(offset)); try std.testing.expectEqual(surface.at(x, pane_tag_y).style.font_role, surface.at(x, pardes.TOPBAR_H).style.font_role); } try std.testing.expectEqual(pardes.FontRole.tagline, surface.at(rect.x + pardes.TAG_TEXT_INSET, pardes.TOPBAR_H).style.font_role); try std.testing.expectEqual(pardes.FontRole.tagline, surface.at(rect.x + pardes.TAG_TEXT_INSET, pane_tag_y).style.font_role); } const gap_x = p.col_x[1] + config.GUTTER; try std.testing.expectEqualStrings(" ", p.surface.at(gap_x, pardes.TOPBAR_H).grapheme()); mouse(p, .press, gap_x); try std.testing.expect(p.drag != .column_move); try std.testing.expect(!p.header_drag); mouse(p, .release, gap_x); p.update(.{ .mouse = headerMouse(layer, 0, .press) }); try std.testing.expect(p.drag != .column_move); try std.testing.expect(p.header_drag); p.update(.{ .mouse = headerMouse(layer, 0, .release) }); try std.testing.expectEqual(@as(i32, 0), pardes.tagline.focusedHeader(p).?.cur_col); mouse(p, .press, p.col_x[1] + config.GUTTER - 1); try std.testing.expect(p.drag == .column_move); mouse(p, .release, p.col_x[1] + config.GUTTER - 1); const seam = p.col_x[0] + p.col_w[0] - 1; const body_row = p.rects[p.col_panes[0][0]].y + pardes.BOX_H + 1; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = seam, .row = body_row } }); try std.testing.expect(p.drag == .border_v); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = seam, .row = body_row } }); } test "column grips stay blank, and muted across focus and hover until grabbed" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 20 }); defer p.deinit(); _ = try p.setTestFile("left\n"); p.presentation.enabled = false; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); for ([_]bool{ false, true }) |focus_tint| { p.settings.focus_tint = focus_tint; for (0..p.ncol) |active_column| { p.active = p.col_panes[active_column][0]; const x = p.col_x[active_column]; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x, .row = pardes.TOPBAR_H } }); for ([_]bool{ false, true }) |dragging| { if (dragging) mouse(p, .press, x); _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); const muted: pardes.Color = .{ .rgb = p.chromeTheme().column_box_dim }; const lit: pardes.Color = .{ .rgb = p.chromeTheme().column_box }; for (0..p.ncol) |column| { for (0..config.GUTTER) |offset| { const cell = surface.at(p.col_x[column] + @as(u16, @intCast(offset)), pardes.TOPBAR_H); try std.testing.expectEqualStrings(" ", cell.grapheme()); try std.testing.expectEqual(if (dragging and column == active_column) lit else muted, cell.style.bg); try std.testing.expectEqual(pardes.FontRole.tagline, cell.style.font_role); } try std.testing.expectEqual(p.col_x[column] + pardes.TAG_TEXT_INSET, (try tagLayer(p, column)).viewport.x); } if (dragging) mouse(p, .release, x); } } } } test "column grip dropped in its own place moves the column's left edge" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); p.update(.tick); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const serials = .{ p.col_serial[0], p.col_serial[1] }; const edge = p.col_x[1]; const total = p.col_w[0] + p.col_w[1]; // A click in place, from either grip cell, changes nothing. for (0..config.GUTTER) |offset| { const x = edge + @as(u16, @intCast(offset)); mouse(p, .press, x); mouse(p, .release, x); _ = try p.render(frame.allocator()); try std.testing.expectEqual(edge, p.col_x[1]); } // Grabbed by its second cell and dropped 7 cells left: the edge follows. mouse(p, .press, edge + 1); mouse(p, .drag, edge + 1 - 7); _ = try p.render(frame.allocator()); mouse(p, .release, edge + 1 - 7); _ = try p.render(frame.allocator()); try std.testing.expectEqual(edge - 7, p.col_x[1]); try std.testing.expectEqual(total, p.col_w[0] + p.col_w[1]); try std.testing.expectEqual(serials[0], p.col_serial[0]); try std.testing.expectEqual(serials[1], p.col_serial[1]); // Past the neighbour's middle it is still a reorder, not a resize. mouse(p, .press, p.col_x[1]); mouse(p, .release, p.col_x[0] + 1); try std.testing.expectEqual(serials[1], p.col_serial[0]); try std.testing.expectEqual(serials[0], p.col_serial[1]); } /// The column of the drag preview's rail, checked to run the full height /// below the workspace tag in the grabbed grip's color, except through the /// grip and pane boxes at the window's left edge. fn previewRail(p: *Pardes, frame: *std.heap.ArenaAllocator) !?u16 { _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); const lit: pardes.Color = .{ .rgb = p.chromeTheme().column_box }; var found: ?u16 = null; for (0..surface.cols) |x| { if (!std.mem.eql(u8, surface.at(@intCast(x), surface.rows - 1).grapheme(), "╎")) continue; try std.testing.expect(found == null); found = @intCast(x); for (p.topBarHeight()..surface.rows) |row| { const cell = surface.at(@intCast(x), @intCast(row)); if (x < config.GUTTER and cell.style.font_role == .tagline) { try std.testing.expect(!std.mem.eql(u8, cell.grapheme(), "╎")); continue; } try std.testing.expectEqualStrings("╎", cell.grapheme()); try std.testing.expectEqual(lit, cell.style.fg); } } return found; } test "column grip drag previews where the column lands, in the seam beside its rule" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; for (0..2) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); } try std.testing.expectEqual(@as(usize, 3), p.ncol); p.update(.tick); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const serials = .{ p.col_serial[0], p.col_serial[1], p.col_serial[2] }; // Held on its own grip it lands nowhere new: no rail. mouse(p, .press, p.col_x[1]); mouse(p, .drag, p.col_x[1] + 1); try std.testing.expectEqual(@as(?u16, null), try previewRail(p, &frame)); // Over the first column's left half it goes first. The window edge has // no seam, and the gutter there is the GUI's scroll rail, so the rail // runs in the gutter's last cell. mouse(p, .drag, 3); try std.testing.expectEqual(@as(?u16, config.GUTTER - 1), try previewRail(p, &frame)); // The pointer is over the first column's "New", but a carried column // aims at a place: no word lights under it. for (0..p.screen_w) |x| try std.testing.expect(!std.meta.eql(p.surface.at(@intCast(x), pardes.TOPBAR_H).style.bg, pardes.Color{ .rgb = p.theme().sel_bg })); mouse(p, .release, 3); try std.testing.expectEqual(serials[1], p.col_serial[0]); try std.testing.expectEqual(serials[0], p.col_serial[1]); // Past the middle column's middle it lands after it: the rail is in the // seam cell left of the last column's rule, where a border drag's is. mouse(p, .press, p.col_x[0]); const past = p.col_x[1] + p.col_w[1] - 2; mouse(p, .drag, past); try std.testing.expectEqual(@as(?u16, p.col_x[2] - 1), try previewRail(p, &frame)); // Past the last column's middle: the window's last cell. mouse(p, .drag, p.col_x[2] + p.col_w[2] - 1); try std.testing.expectEqual(@as(?u16, p.screen_w - 1), try previewRail(p, &frame)); mouse(p, .release, p.col_x[2] + p.col_w[2] - 1); try std.testing.expectEqual(serials[0], p.col_serial[0]); try std.testing.expectEqual(serials[2], p.col_serial[1]); try std.testing.expectEqual(serials[1], p.col_serial[2]); // Short of a neighbour's middle the left edge moves, and the preview // is the seam that edge will have: exactly where the release puts it. const edge = p.col_x[2]; mouse(p, .press, edge + 1); mouse(p, .drag, edge + 1 - 6); try std.testing.expectEqual(@as(?u16, edge - 6 - 1), try previewRail(p, &frame)); mouse(p, .release, edge + 1 - 6); _ = try p.render(frame.allocator()); try std.testing.expectEqual(edge - 6, p.col_x[2]); try std.testing.expectEqual(@as(?u16, null), try previewRail(p, &frame)); }