summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-24 09:45:50 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commita39fb20cb770c3adb911cec7d1f3d033669ed1bb (patch)
treee7bb88af15990e502b5f4deac5493ea2957e8b24
parent39cdba439c1ae47dbc7c3243a9273aa011c6affa (diff)
downloadpardes-a39fb20cb770c3adb911cec7d1f3d033669ed1bb.tar.gz
pardes-a39fb20cb770c3adb911cec7d1f3d033669ed1bb.zip
Light a held column grip, and rail its landing beside the rule where the GUI shows it
The column grip worked in the GUI but showed nothing anyone could see. Driving the real SDL window with pixel mouse events, press, drag and release all reach the core and reorder or resize the column; the feedback is what failed. The grip kept its muted color while held, and a reorder's insertion rail was drawn in the gutter cell at the destination edge. The GUI paints that cell as the column's scroll rail, and at the window's left edge, the common case of moving the right column first, the dashes were lost in it. From the leftmost column, a drag short of a neighbour's middle shows no rail, rightly, since nothing would move, but nothing said the grip was even held. A held grip now lights in column_box, the accent its muted color is mixed from, and the rail takes that color. The rail runs in the seam cell left of where the column's rule will land, where a border drag's rail runs, for a reorder and a left-edge move alike; at the window's left edge, which has no seam, it runs just past the scroll rail, leaving column 0's grip and pane boxes whole. A carried column's pointer aims at a place, not a word, so the column tags no longer light the word under it, and a release snaps the layout as a border drag's does rather than animating it. The preview and the release both ask layout.columnDrop where the column goes, so the rail stands where the release puts the edge, and the drag no longer carries its unused column index. The GUI test feed takes ESC]777;mouse;<down|up|motion>;<button>;<x>;<y> in window pixels and dispatches it as an SDL event. test/column_grip.py uses it to drive the grip through the same pixel-to-cell and tag hit path as a hand on the mouse, and checks the grip, the rail and the result against GPU captures and the 9P grid. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
-rw-r--r--docs/tags.md12
-rw-r--r--docs/themes.md4
-rw-r--r--src/gui/gui.zig41
-rw-r--r--src/layout.zig18
-rw-r--r--src/pardes.zig86
-rw-r--r--test/column_drag.zig89
-rw-r--r--test/column_grip.py132
-rw-r--r--test/snapshots/colgrip.golden170
-rw-r--r--test/snapshots/colgrip.snap35
9 files changed, 534 insertions, 53 deletions
diff --git a/docs/tags.md b/docs/tags.md
index c4e93bc6..8b1d3bf6 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -10,10 +10,14 @@ Pane and column command text leave a small gap after their aligned drag grips.
GUI pane mode symbols are centered by their visible ink; changing the font or
its size refreshes the cached measurements.
The column grip has a different
-color from a pane grip. It stays blank and muted even when its column is active.
-Drag it left or right to move the whole column; the
-insertion line previews its destination. Only columns between the old and new
-positions shift. Each column keeps its width, panes and command text.
+color from a pane grip. It stays blank and muted even when its column is active,
+and lights in its full accent while it is held.
+Drag it past a neighbour's middle to move the whole column there; dropped short
+of that, it moves the column's left edge instead, as a border drag would. While
+it is held, a dashed rail in the grip's accent stands where the column's left
+edge will land, beside the rule as a border drag's rail does. Only columns
+between the old and new positions shift. Each column keeps its width, panes and
+command text.
When Look opens the first document and its source column is too narrow for
a new column, it splits below the originating pane, just like `Tty`, instead
diff --git a/docs/themes.md b/docs/themes.md
index dac389f5..8cc4ff9b 100644
--- a/docs/themes.md
+++ b/docs/themes.md
@@ -105,8 +105,8 @@ so existing exported themes remain valid.
| `tag_active_bg`, `tag_active_fg` | Focused pane tag surface and text | `tag_bg`, `tag_fg` |
| `tag_name_fg` | Filename or terminal `Tty` command tint | The corresponding normal or active tag foreground |
| `tag_active_name_fg` | Filename or terminal `Tty` tint in active tags | `tag_name_fg`, then the active tag foreground |
-| `column_box` | Base accent for the column grip's fallback color | `num` |
-| `column_box_dim` | Column drag grip in every focus state | Equal mix of `column_box` and `tag_bg` |
+| `column_box` | Column grip while held, and its drag rail | `num` |
+| `column_box_dim` | Column drag grip at rest, in every focus state | Equal mix of `column_box` and `tag_bg` |
| `border` | Quiet separators | `scroll_track` |
| `lineno_active` | Restrained current line number foreground | `lineno` |
| `search_bg`, `search_fg` | Search matches, independent of selection | `sel_bg`, `sel_fg` |
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index a4cc7672..989f6b25 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -2876,6 +2876,43 @@ const StdinFeed = struct {
var seq_start: usize = 0;
while (seq_start < len) {
+ // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL
+ // A mouse event at window pixels, built as SDL would and run
+ // through dispatch, so a driver exercises the real pointer path
+ // (pixel to cell, tag and body hits). SGR mouse carries only cells.
+ const mouse_prefix = "\x1b]777;mouse;";
+ if (std.mem.startsWith(u8, f.buf[seq_start..len], mouse_prefix)) {
+ const body = f.buf[seq_start + mouse_prefix.len .. len];
+ const end = std.mem.indexOfScalar(u8, body, 0x07) orelse {
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ };
+ if (g) |gp| {
+ var parts = std.mem.splitScalar(u8, body[0..end], ';');
+ const kind = parts.next() orelse "";
+ const button = std.fmt.parseInt(u8, parts.next() orelse "", 10) catch 0;
+ const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
+ const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
+ var sev = std.mem.zeroes(c.SDL_Event);
+ if (std.mem.eql(u8, kind, "motion")) {
+ sev.type = c.SDL_EVENT_MOUSE_MOTION;
+ sev.motion.x = x;
+ sev.motion.y = y;
+ sev.motion.state = if (button == 0 or button > 5) 0 else @as(u32, 1) << @intCast(button - 1);
+ } else {
+ sev.type = if (std.mem.eql(u8, kind, "down")) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP;
+ sev.button.button = button;
+ sev.button.down = std.mem.eql(u8, kind, "down");
+ sev.button.x = x;
+ sev.button.y = y;
+ }
+ dispatch(gp, in, &sev);
+ out.n_events += 1;
+ }
+ seq_start += mouse_prefix.len + end + 1;
+ continue;
+ }
const prefix = "\x1b]777;finger;";
if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) {
const body = f.buf[seq_start + prefix.len .. len];
@@ -2909,7 +2946,9 @@ const StdinFeed = struct {
seq_start += prefix.len + end + 1;
continue;
}
- if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) {
+ if ((len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) or
+ (len - seq_start < mouse_prefix.len and std.mem.startsWith(u8, mouse_prefix, f.buf[seq_start..len])))
+ {
std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
f.fill = len - seq_start;
break;
diff --git a/src/layout.zig b/src/layout.zig
index 8db05a07..3637f30e 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -713,14 +713,26 @@ pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize {
return null;
}
-pub fn columnMoveTarget(p: *const Pardes, source: usize, x: u16) usize {
- if (source >= p.ncol) return source;
+/// 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;
}
- return insertion;
+ 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
diff --git a/src/pardes.zig b/src/pardes.zig
index f968511a..4786bd70 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5155,7 +5155,7 @@ const Drag = union(enum) {
move: struct { id: usize, cur_x: u16, cur_y: u16 },
/// `grab`: how far into the grip it was pressed, so the column's left
/// edge lands at `cur_x - grab`. A release still on the grip moves nothing.
- column_move: struct { column: usize, serial: u32, cur_x: u16, grab: u16 = 0 },
+ column_move: struct { serial: u32, cur_x: u16, grab: u16 = 0 },
tag: struct { id: usize },
/// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
/// the drag's own release is then inert
@@ -7793,7 +7793,7 @@ pub const Pardes = struct {
.key => |key| p.handleKey(key),
.mouse => |m| {
if (m.kind == .release) switch (p.drag) {
- .border_v, .border_h, .move => p.presentation.snap_once = true,
+ .border_v, .border_h, .move, .column_move => p.presentation.snap_once = true,
else => {},
};
p.handleMouse(m);
@@ -12017,7 +12017,7 @@ pub const Pardes = struct {
if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue;
if (m.button == config.select_button) {
p.header_drag = false;
- p.drag = .{ .column_move = .{ .column = column, .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } };
+ p.drag = .{ .column_move = .{ .serial = p.col_serial[column], .cur_x = mcol, .grab = mcol - p.col_x[column] } };
}
return;
}
@@ -12488,15 +12488,10 @@ pub const Pardes = struct {
},
.border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx))
layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y),
- .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| {
- const target = layout.columnMoveTarget(p, column, d.cur_x);
- if (target != column) {
- layout.reorderColumn(p, column, target);
- } else if (column > 0 and (d.cur_x < p.col_x[column] or d.cur_x >= p.col_x[column] + config.GUTTER)) {
- // Dropped short of another column's place: as in acme,
- // the column's left edge moves to where it was dropped.
- layout.setColumnPairWidth(p, column - 1, (d.cur_x -| d.grab) -| p.col_x[column - 1]);
- }
+ .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) {
+ .stay => {},
+ .reorder => |target| layout.reorderColumn(p, column, target),
+ .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]),
},
.move => |d| {
layout.movePane(p, d.id, d.cur_x, d.cur_y);
@@ -14091,7 +14086,10 @@ pub const Pardes = struct {
const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false;
const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg;
- const grip_bg = chrome.column_box_dim;
+ // A grip is muted until it is grabbed; then it lights, as a pane's
+ // box does for the active pane, so the drag shows what it holds.
+ const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c;
+ const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline };
@@ -14106,7 +14104,8 @@ pub const Pardes = struct {
.bg = .{ .rgb = bg },
.font_role = .tagline,
});
- if (p.pointer_inside and p.hover_row == p.topBarHeight() and p.hover_col >= x and p.hover_col < x + w) {
+ // A carried column's pointer is aiming at a place, not a word.
+ if (p.pointer_inside and p.drag != .column_move and p.hover_row == p.topBarHeight() and p.hover_col >= x and p.hover_col < x + w) {
const bar = p.columnTag(c);
if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| {
var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll;
@@ -14154,33 +14153,38 @@ pub const Pardes = struct {
var col = p.col_x[d.col];
while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
},
- .column_move => |d| {
- const source = layout.columnBySerial(p, d.serial) orelse d.column;
- const target = layout.columnMoveTarget(p, source, d.cur_x);
- if (source < p.ncol and target != source) {
- const boundary = if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target];
- const x = @min(boundary, s.cols -| 1);
- // Leave the outside insertion rail visible above compact
- // text layers. This clips presentation only, never reflows.
- if (boundary == s.cols) {
- for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
- layer.viewport.w -|= 1;
- };
- for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
- layer.viewport.w -|= 1;
- };
- }
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎");
- } else if (source > 0 and source < p.ncol and (d.cur_x < p.col_x[source] or d.cur_x >= p.col_x[source] + config.GUTTER) and
- p.col_w[source - 1] + p.col_w[source] >= 2 * config.MINW)
- {
- // Where the left edge would land: the border drag's rail.
- const lo = p.col_x[source - 1] + config.MINW;
- const hi = (p.col_x[source] + p.col_w[source]) -| config.MINW;
- const x = @min(std.math.clamp(d.cur_x -| d.grab, lo, @max(lo, hi)), s.cols -| 1);
- var row: u16 = p.topBarHeight();
- while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎");
+ .column_move => |d| rail: {
+ const source = layout.columnBySerial(p, d.serial) orelse break :rail;
+ // Where the column's left edge would land, reordered or not.
+ const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) {
+ .stay => break :rail,
+ .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target],
+ .edge => |x| x,
+ };
+ // Leave the outside insertion rail visible above compact
+ // text layers. This clips presentation only, never reflows.
+ if (edge == s.cols) {
+ for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) {
+ layer.viewport.w -|= 1;
+ };
+ for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) {
+ layer.viewport.w -|= 1;
+ };
+ }
+ // The rail runs in the seam cell left of the edge, where a
+ // border drag's does: the edge cell itself is a gutter, which
+ // the GUI paints as a scroll rail that swallowed the dashes.
+ // At the window's left edge there is no seam, so it runs just
+ // past that scroll rail instead. It takes the lit grip's
+ // color: this is where the grabbed column lands.
+ const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1);
+ var row: u16 = p.topBarHeight();
+ while (row < s.rows) : (row += 1) {
+ // Past the left edge's scroll rail the rail would cut
+ // through column 0's grip and pane boxes: leave them whole.
+ if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue;
+ s.overlayDash(x, row, "╎");
+ s.at(x, row).style.fg = .{ .rgb = chrome.column_box };
}
},
.move => |d| if (d.cur_x < s.cols) {
diff --git a/test/column_drag.zig b/test/column_drag.zig
index 85dca66b..b2b8f35d 100644
--- a/test/column_drag.zig
+++ b/test/column_drag.zig
@@ -176,7 +176,7 @@ test "column grip is separate from compact text and the resize seam" {
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = seam, .row = body_row } });
}
-test "column grips stay blank and muted across focus hover and drag" {
+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");
@@ -198,11 +198,12 @@ test "column grips stay blank and muted across focus hover and drag" {
_ = 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(muted, cell.style.bg);
+ 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);
@@ -252,3 +253,87 @@ test "column grip dropped in its own place moves the column's left edge" {
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;
+ p.settings.column_tags = true;
+ 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));
+}
diff --git a/test/column_grip.py b/test/column_grip.py
new file mode 100644
index 00000000..ac0341c3
--- /dev/null
+++ b/test/column_grip.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Drag a column grip in the real SDL window with pixel mouse events.
+
+WAYLAND_DISPLAY=/run/user/1000/wayland-1 \\
+ python3 -B test/column_grip.py zig-out/bin/pardes-gui /tmp/pardes-column-grip [--pad 7]
+
+The window is hidden (PARDES_TEST). Mouse events enter as ESC]777;mouse
+sequences on stdin, which the GUI builds into SDL events and dispatches like
+its own, so pixel-to-cell and tag hit testing run as they do under a hand.
+9P observes the grid; each step leaves a PNG of the GPU frame in the output
+directory: grip-rest, grip-resize-drag, grip-reorder-drag, grip-done.
+"""
+import argparse
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import time
+
+from fs import session
+from ui_review import require, wait_for
+
+
+def rows(client):
+ frame = client.screen()
+ cols = frame['cols']
+ return [''.join(cell[0] for cell in frame['cells'][at:at + cols]) for at in range(0, len(frame['cells']), cols)]
+
+
+def rail(client):
+ """The one column the drag preview's rail runs down, if any."""
+ bottom = rows(client)[-1]
+ found = [x for x, ch in enumerate(bottom) if ch == '╎']
+ require(len(found) <= 1, f'more than one rail: {found}')
+ return found[0] if found else None
+
+
+def grab(root, label):
+ time.sleep(.5)
+ def convert():
+ shutil.copyfile(root / 'latest.ppm', root / (label + '.ppm'))
+ return subprocess.run(['magick', str(root / (label + '.ppm')), str(root / (label + '.png'))],
+ stderr=subprocess.DEVNULL).returncode == 0
+ require(wait_for(convert, 8), 'GPU capture did not contain a complete image')
+ return root / (label + '.png')
+
+
+def pixel(path, x, y):
+ out = subprocess.run(['magick', str(path), '-format', f'%[pixel:p{{{x},{y}}}]', 'info:'],
+ capture_output=True, text=True, check=True)
+ return out.stdout.strip()
+
+
+def run(args):
+ root = args.output.resolve()
+ shutil.rmtree(root, ignore_errors=True)
+ root.mkdir(parents=True, mode=0o700)
+ (root / 'config/pardes').mkdir(parents=True)
+ (root / 'config/pardes/init').write_text('Shell /bin/sh\n')
+ (root / 'w.txt').write_text('alpha\nbeta\n')
+ name = 'column-grip-' + str(os.getpid())
+ launch = ['-c', 'stty cols 100 rows 30; exec "$@"', 'column-grip',
+ str(args.binary.resolve()), '--9p=' + name, str(root / 'w.txt')]
+ env = {'PARDES_TEST': '1', 'PARDES_TEST_CAPTURE_DIR': str(root), 'PARDES_TEST_COLS': '100',
+ 'PARDES_TEST_ROWS': '30', 'PARDES_TEST_PAD': str(args.pad)}
+ handles = {}
+ with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch,
+ inherited=env, terminal=handles) as (client, _):
+ def send(data):
+ os.write(handles['input_fd'], data.encode())
+
+ def mouse(kind, button, col, row):
+ # the middle of a grid cell, in window pixels
+ send(f'\x1b]777;mouse;{kind};{button};{(col + .5) * cw};{(row + .5) * ch}\x07')
+ time.sleep(.15)
+
+ require(wait_for(lambda: 'Newcol' in rows(client)[0]), 'workspace tag never drew')
+ send('\x1bn')
+ time.sleep(.3)
+ send('\x1bc')
+ require(wait_for(lambda: rows(client)[1].count('New Tty') == 2), 'no second column')
+ require(wait_for(lambda: (root / 'latest.ppm').exists(), 8), 'GPU capture missing')
+ time.sleep(.5)
+ with open(root / 'latest.ppm', 'rb') as ppm:
+ ppm.readline()
+ width, height = map(int, ppm.readline().split())
+ frame = client.screen()
+ cw = (width - args.pad) // frame['cols']
+ ch = (height - args.pad) // frame['rows']
+ edge = rows(client)[1].index('New Tty', 10) - 3
+
+ rest = grab(root, 'grip-rest')
+ mouse('down', 1, edge, 1)
+ held = grab(root, 'grip-held')
+ require(pixel(held, int((edge + 1) * cw), int(1.5 * ch)) != pixel(rest, int((edge + 1) * cw), int(1.5 * ch)),
+ 'a grabbed grip looks the same as a resting one')
+ require(rail(client) is None, 'a grip held in place previews a move')
+
+ # Short of the left column's middle: the left edge follows, and the
+ # rail is the seam cell left of where the rule will be.
+ for col in range(edge, edge - 11, -1):
+ mouse('motion', 1, col, 1)
+ require(rail(client) == edge - 11, f'resize rail at {rail(client)}, wanted {edge - 11}')
+ grab(root, 'grip-resize-drag')
+ mouse('up', 1, edge - 10, 1)
+ require(wait_for(lambda: rows(client)[1].index('New Tty', 10) - 3 == edge - 10), 'left edge did not follow the grip')
+ edge -= 10
+
+ # Over the left column's left half: it goes first, and the rail runs
+ # just past the left window edge's scroll rail.
+ mouse('down', 1, edge, 1)
+ for col in range(edge, 4, -5):
+ mouse('motion', 1, col, 10)
+ require(rail(client) == 1, f'reorder rail at {rail(client)}, wanted 1')
+ grab(root, 'grip-reorder-drag')
+ mouse('up', 1, 5, 10)
+ require(wait_for(lambda: 'alpha' in rows(client)[3][50:]), 'the column did not move first')
+ require(rail(client) is None, 'the rail outlived the drag')
+ grab(root, 'grip-done')
+ print('ok', root)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('binary', type=Path)
+ parser.add_argument('output', type=Path)
+ parser.add_argument('--pad', type=int, default=0, help='leftover pixels past the grid (PARDES_TEST_PAD)')
+ run(parser.parse_args())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/snapshots/colgrip.golden b/test/snapshots/colgrip.golden
new file mode 100644
index 00000000..e096a665
--- /dev/null
+++ b/test/snapshots/colgrip.golden
@@ -0,0 +1,170 @@
+== style rest grid=100x24
+|0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824,
+|1: 0-1 #ffffff,#887455, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-49 d,#1d2824, 50-51 #ffffff,#887455, 52-52 d,#26362d, 53-84 #becfc4,#26362d, 85-99 d,#26362d,
+|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-31 #b2c3b8,#1d2824, 32-36 #d3b0d9,#1d2824, 37-49 #b2c3b8,#1d2824, 50-51 #000000,#add38a, 52-52 d,#26362d, 53-81 #becfc4,#26362d, 82-84 #dfbce5,#26362d, 85-99 #becfc4,#26362d,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-7 #e5eee5,#0d1410, 8-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-99 d,#0d1410,
+|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|10: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|11: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|12: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|17: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|20: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|23: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+== style held grid=100x24
+|1: 0-1 #ffffff,#887455, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-49 d,#1d2824, 50-51 #000000,#f4c086, 52-52 d,#26362d, 53-84 #becfc4,#26362d, 85-99 d,#26362d,
+== snap resize-drag grid=100x24 cursor=54,5
+|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| New Tty Find Grep Joincol Delcol ╎ New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/colgrip/cwd/w.txt S╎ve Tty Col /tmp/pardes-snap/colgrip/cwd Tty Save Mode Filt
+| 1 x ╎ ls
+| ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== style resize-drag grid=100x24
+|1: 0-1 #ffffff,#887455, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-38 d,#1d2824, 39-39 #f4c086,#1d2824, 40-49 d,#1d2824, 50-51 #000000,#f4c086, 52-52 d,#26362d, 53-84 #becfc4,#26362d, 85-99 d,#26362d,
+|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-31 #b2c3b8,#1d2824, 32-36 #d3b0d9,#1d2824, 37-38 #b2c3b8,#1d2824, 39-39 #f4c086,#1d2824, 40-49 #b2c3b8,#1d2824, 50-51 #000000,#add38a, 52-52 d,#26362d, 53-81 #becfc4,#26362d, 82-84 #dfbce5,#26362d, 85-99 #becfc4,#26362d,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-7 #e5eee5,#0d1410, 8-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-99 d,#0d1410,
+|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|10: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|11: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|12: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|17: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|20: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+|23: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-38 d,#0d1410, 39-39 #f4c086,#0d1410, 40-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410,
+== snap resize-done grid=100x24 cursor=44,5
+|1: New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
+|2: /tmp/pardes-snap/colgrip/cwd/w.txt Sa /tmp/pardes-snap/colgrip/cwd Tty Save Mode Filter Collaps
+|3: 1 x ls
+|4: w.txt
+|5:
+|6:
+|7:
+|8:
+|9:
+|10:
+|11:
+|12:
+|13:
+|14:
+|15:
+|16:
+|17:
+|18:
+|19:
+|20:
+|21:
+|22:
+|23:
+== snap reorder-drag grid=100x24 cursor=44,5
+|3: ╎ 1 x ls
+|4: ╎ w.txt
+|5: ╎
+|6: ╎
+|7: ╎
+|8: ╎
+|9: ╎
+|10: ╎
+|11: ╎
+|12: ╎
+|13: ╎
+|14: ╎
+|15: ╎
+|16: ╎
+|17: ╎
+|18: ╎
+|19: ╎
+|20: ╎
+|21: ╎
+|22: ╎
+|23: ╎
+== style reorder-drag grid=100x24
+|1: 0-1 #ffffff,#887455, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-39 d,#1d2824, 40-41 #000000,#f4c086, 42-42 d,#26362d, 43-74 #becfc4,#26362d, 75-99 d,#26362d,
+|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-31 #b2c3b8,#1d2824, 32-36 #d3b0d9,#1d2824, 37-39 #b2c3b8,#1d2824, 40-41 #000000,#add38a, 42-42 d,#26362d, 43-71 #becfc4,#26362d, 72-74 #dfbce5,#26362d, 75-99 #becfc4,#26362d,
+|3: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-7 #e5eee5,#0d1410, 8-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|4: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|5: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-99 d,#0d1410,
+|6: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|7: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|8: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|9: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|10: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|11: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|12: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|13: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|14: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|15: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|16: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|17: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|18: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|19: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|20: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|21: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|22: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+|23: 0-0 d,#608269, 1-1 #f4c086,#0d1410, 2-6 #59675c,#0d1410, 7-39 d,#0d1410, 40-40 d,#608269, 41-41 d,#0d1410, 42-99 #e5eee5,#0d1410,
+== snap reorder-done grid=100x24 cursor=4,5
+|1: New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
+|2: /tmp/pardes-snap/colgrip/cwd Tty Save Mode Filter Collaps /tmp/pardes-snap/colgrip/cwd/w.txt Sa
+|3: ls 1 x
+|4: w.txt
+|5:
+|6:
+|7:
+|8:
+|9:
+|10:
+|11:
+|12:
+|13:
+|14:
+|15:
+|16:
+|17:
+|18:
+|19:
+|20:
+|21:
+|22:
+|23:
diff --git a/test/snapshots/colgrip.snap b/test/snapshots/colgrip.snap
new file mode 100644
index 00000000..c2b403ed
--- /dev/null
+++ b/test/snapshots/colgrip.snap
@@ -0,0 +1,35 @@
+# Column grips: the two cells left of a column tag carry the column. A grabbed
+# grip lights, and a rail in its color stands where the column will land.
+# The right column is at x 50; its grip is cells 51-52 (1-based) on row 2.
+file w.txt x
+start 24 100 w.txt
+wait 8000 Newcol Joincol
+wait 8000 w.txt
+stable 700 20000
+key a-n
+key a-c
+stable 700 15000
+snapstyle rest
+# held on its own grip: lit, and no rail, since it lands where it is
+press left 51 2
+stable 400 5000
+snapstyle held
+# dropped short of the left column's middle, the left edge moves: the rail is
+# in the seam cell left of where the column's rule will be
+drag left 41 2
+stable 400 5000
+snap resize-drag
+snapstyle resize-drag
+release left 41 2
+stable 700 15000
+snap resize-done
+# carried over the left column's left half it goes first; the window edge
+# has no seam, so the rail runs in the gutter's last cell
+press left 41 2
+drag left 10 2
+stable 400 5000
+snap reorder-drag
+snapstyle reorder-drag
+release left 10 2
+stable 700 15000
+snap reorder-done