From 9ce11bfeff73b96704e521b41cffe4625b02f103 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 16:50:50 -0300 Subject: Reorder columns using aligned and distinctly colored tag grips --- test/column_drag.zig | 171 +++++++++++++++++++++++++++++++++++++++++++ test/panes.zig | 1 + test/web-snapshots/touch.mjs | 16 +++- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 test/column_drag.zig (limited to 'test') diff --git a/test/column_drag.zig b/test/column_drag.zig new file mode 100644 index 00000000..341c8b70 --- /dev/null +++ b/test/column_drag.zig @@ -0,0 +1,171 @@ +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.TagLayer { + for (&p.surface.tag_layers) |*layer| { + if (layer.cols > 0 and layer.kind == .column and layer.id == column) return layer; + } + return error.MissingColumnTagLayer; +} + +fn headerMouse(layer: *const pardes.TagLayer, 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.hitAt(x, y, 10, 20, 5, 10) }; +} + +const Snapshot = struct { + weight: u64, + serial: u32, + panes: [pardes.MAX_PANES]usize = undefined, + count: usize, + tag: pardes.TagLine, +}; + +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.slice(""), p.col_tags[column].slice("")); + } +} + +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.settings.column_tags = true; + 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; + try std.testing.expect(p.col_tags[column].set(([_][]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 = p.col_tags[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 = p.topbar_col; + const anchor = p.header_anchor; + 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] - config.GUTTER - 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, p.topbar_col); + try std.testing.expectEqual(anchor, p.header_anchor); + 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, p.topbar_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] - config.GUTTER, (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, p.topbar_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.settings.column_tags = true; + 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] + config.GUTTER, 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 + config.GUTTER, (try tagLayer(p, column)).viewport.x); + for (p.surface.tagLayers()) |pane_layer| { + if (pane_layer.cols > 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 + config.GUTTER, pardes.TOPBAR_H).style.font_role); + try std.testing.expectEqual(pardes.FontRole.tagline, surface.at(rect.x + config.GUTTER, pane_tag_y).style.font_role); + } + 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(?u16, 0), p.topbar_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 } }); +} diff --git a/test/panes.zig b/test/panes.zig index 71127ea3..ed48db82 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -3928,6 +3928,7 @@ test { _ = ClickCursorTests; _ = JumpSelectionTests; _ = @import("output.zig"); + _ = @import("column_drag.zig"); _ = @import("pdf.zig"); _ = @import("fs_namespace.zig"); _ = @import("hxdiff.zig"); diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs index e65fdb80..3820117e 100644 --- a/test/web-snapshots/touch.mjs +++ b/test/web-snapshots/touch.mjs @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; function tags(frame) { - // Pane tags have a gutter; the workspace and column bars do not. - return frame.styleRows.flatMap((cells, y) => /^\s{2}\S/.test(frame.lines[y]) && cells.some((cell) => cell[2] & 128) ? [y] : []); + // This fixture has workspace and column bars in its first two rows. + return frame.styleRows.flatMap((cells, y) => y > 1 && /^\s{2}\S/.test(frame.lines[y]) && cells.some((cell) => cell[2] & 128) ? [y] : []); } function pane(frame, name) { @@ -95,6 +95,15 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou tagValues[10]=0x556677; renderer.render(); result.tag.bgInvalidates=renderer.tagRoot.firstChild!==tag; + tagValues[1]=1;tagValues[4]=2;tagValues[5]=0; + bytes[0]=32;data.setUint32(8,0x778899,true); + renderer.render(); + const columnGrip=renderer.tagRoot.querySelector('[data-tag-grip]'); + const marks=node=>[...node.children].map(mark=>[parseFloat(mark.style.left),parseFloat(mark.style.top),parseFloat(mark.style.width)]); + result.columnMarks=marks(columnGrip); + tagValues[1]=2;tagValues[5]=1;bytes[40]=32;data.setUint32(48,0x778899,true); + renderer.render(); + result.paneMarks=marks(renderer.tagRoot.querySelector('[data-tag-grip]')); root.remove();return result; })()`); assert.deepEqual(compactGeometry.bands, [[0,10],[10,20],[30,20]], "compact headers release real height to additional body rows"); @@ -110,6 +119,9 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou gripVisible:"",covered:"hidden",background:"rgb(51, 68, 85)",cached:true,bgInvalidates:true}, "tag text uses its own columns after a fixed body-width grip, with cursor and cached base background"); + assert.deepEqual(compactGeometry.columnMarks,[[7,13,6],[7,16,6]]); + assert.deepEqual(compactGeometry.paneMarks,[[7,33,6],[7,36,6]],"pane and column handles share physical horizontal mark geometry"); + let frame = await grid(); assert.equal(tags(frame).length, 1); styled(frame); -- cgit v1.3