summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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