summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 23:21:17 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commitbc6abf1716a4824513c325204302f2fd229c2974 (patch)
tree12d55d882ce32c86f67163894203913fe69e19c5
parentf4ac5ee26851ced516b826953fa340008fa7069c (diff)
downloadpardes-bc6abf1716a4824513c325204302f2fd229c2974.tar.gz
pardes-bc6abf1716a4824513c325204302f2fd229c2974.zip
a corner grab drags the column edge and the pane edge at once
-rw-r--r--next-steps.txt13
-rw-r--r--src/config.zig2
-rw-r--r--src/pardes.zig188
-rw-r--r--test/snapshots/cornerdrag.golden248
-rw-r--r--test/snapshots/cornerdrag.snap51
5 files changed, 457 insertions, 45 deletions
diff --git a/next-steps.txt b/next-steps.txt
deleted file mode 100644
index 0dd68823..00000000
--- a/next-steps.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-+ All syntatic choices must be centralized in single .zig file: keybindings, syntax for Look, etc; the motivation for this is that those can easilly be changed and tweaked if they're centralized like this and in the process we may cut down some repetition of logic.
-+ Carve out and isolate the Output pane as its own semantic entity, right now we are doing specific stuff on the output pane based on the command that opened it, let's make this part of the data structure that represents it, the heuristics for special handling of the output panes will be done by reading the Command that originated it field from itself. Make this centralized on the code so its clear to see and tweak those heuristics.
-+ File panes need to split the latest column which a file pane was being used.
-+ Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch.
-+ Add a new builtin that will list all builtins and their respective shortcuts if they have any (note that shortcuts may be spc+... or just keyboard or mouse chords/movements)
-+ Scrolling on a touchpad moves horizontally accidentally way too much, add some heuristics to prioritize vertical scrolling and make horizontal scrolling work only when the user is a bit more explicit about it - for example by measuring if there was recent high-enough vertical movement, etc.
-+ Add a new builtin to list the themes and create an output window to select those themes; each theme now must live on its own .zig file, when moving with n/N on out ThemeSel output window it will select that theme so it's a interactive way to select the themes. We're being clever now with how these output windows will work, this feature depends on the new semantics for output windows but basically now the ThemeSel output panes will set the Command flag that will basically tell that when doing n/N movement on the output pane instead of Look-ing it will Execute the line, so the ThemeSel output pane will be like: (Theme acme, Theme a, Theme b) seperated by lines so the n/N will automatically consider selecting the Theme + argument (this is not output pane specific, it should be generalized to work on every pane kind), and will Exec it. Add new themes that will be automatically exported from helix and zed, a new binary will execute that will generate .zig files, this binary will be added as a execute step on the build.zig so when building it will regenarate those .zig that will be then included on the runtime as themes.
-+ Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update.
-+ There's some bug when rendering from a ssh session - the screen gets dirty with old chars when scrolling.
-+ There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance.
-+ The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!)
-+ We'll make the focus history a meaningful and strong semantic entity: instead of being a stack of Panes, it'll be a stack of locations. This will allow us to implement vim's ctrl-i and ctrl-o that will just move the current focus up or down on that stack. there'll be also a new builtin that will toggle the focus of the latest panes, basically moving the current focus by changing the current pointer to the location on the stack. The Jumplist (new builtin) will be an Output pane with each line being a location that can be Looked, n/N should work.
-+ The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src).
diff --git a/src/config.zig b/src/config.zig
index 46bd261e..714ec177 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -310,7 +310,7 @@ pub const file_pane_builtins_str = "Save Del";
/// here would be pushed into one cell as a single grapheme and come out wrong.
/// A font missing the glyph draws a blank box, which is exactly what the box
/// drew before there was anything in it.
-pub const tag_normal = "•";
+pub const tag_normal = " ";
pub const tag_insert = "^";
pub const tag_tty = "$";
/// ...and `img` stays a WORD at the head of an image pane's tagline, because
diff --git a/src/pardes.zig b/src/pardes.zig
index b303acbb..6245d1f4 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1012,7 +1012,25 @@ pub const Pane = struct {
const Drag = union(enum) {
none,
- border_v: struct { left_col: usize, cur_x: u16 },
+ /// `corner_idx` is what makes this a CORNER grab: the press landed on a
+ /// cell that is both this v-border and one of left_col's own h-borders,
+ /// and then the one drag moves both boundaries — cur_x the column pair,
+ /// cur_y left_col's pane pair at index corner_idx. null is a plain edge
+ /// drag and cur_y is only carried along for the preview.
+ ///
+ /// It is the LEFT column's h-splits a corner honours, and only those. The
+ /// v handle IS left_col's last cell, so left_col's horizontal hint is
+ /// drawn straight THROUGH it while the right column's spans start one cell
+ /// further right — the corners this finds are exactly the cells where the
+ /// user can see the two lines cross. Honouring the right column too would
+ /// scatter corners across rows with no visible crossing.
+ ///
+ /// ponytail: so a corner moves exactly TWO boundaries, never three — a
+ /// right-column h-split that happens to sit at the same row is left alone,
+ /// even though the eye may read the whole row as one line. Add a second
+ /// optional index (the right column's k) here and a second applyRowSplit
+ /// call if that ever reads as a bug rather than as restraint.
+ border_v: struct { left_col: usize, cur_x: u16, corner_idx: ?usize = null, cur_y: u16 = 0 },
border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
move: struct { id: usize, cur_x: u16, cur_y: u16 },
/// a left sweep along a pane's TAG row: it drives the tag's own cursor and
@@ -1024,6 +1042,73 @@ const Drag = union(enum) {
select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false },
};
+// The two border clamps, pulled out as plain arithmetic on plain numbers for
+// one reason: a CORNER drag runs both of them off the same mouse position, and
+// the thing that has to hold is that each one only ever looks at its own axis.
+// A clamp that consulted the other axis — or a single "is this point legal"
+// test over the pair — would freeze the whole gesture the moment either edge
+// hit its stop, when what the hand expects is the free axis to keep tracking
+// and the pinned one to sit at the wall. Being pure, they are also the piece
+// worth a test; see below.
+
+/// Where a vertical border drag settles: `mcol` clamped so neither column of
+/// the pair falls under MINW. `lx`/`lw` are the left column's x and width,
+/// `rw` the right column's. Degenerate pairs (a window too narrow to hold two
+/// minimums at all) pass the mouse through rather than snapping to a lie.
+fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 {
+ const lo = lx + config.MINW;
+ const hi = lx +| lw +| rw -| config.MINW;
+ return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+}
+
+/// Where a horizontal border drag settles: `mrow` clamped so either pane may
+/// shrink to just its tag row (BOX_H) but no further. `ay`/`ah` are the upper
+/// pane's y and height, `bh` the lower pane's.
+fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16) u16 {
+ const lo = ay + BOX_H - 1;
+ const hi = ay +| ah +| bh -| (BOX_H + 1);
+ return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+}
+
+test "a corner drag's two axes clamp independently" {
+ // a 100-wide window split 50/50, and a 30-row column split 15/14 under the
+ // topbar: the shape windowops.snap drives
+ const lx: u16 = 0;
+ const lw: u16 = 50;
+ const rw: u16 = 50;
+ const ay: u16 = TOPBAR_H;
+ const ah: u16 = 15;
+ const bh: u16 = 14;
+ // the walls, spelled out: the handle is the upper pane's LAST row, so the
+ // upper pane bottoms out with its tag row alone at ay, and the lower pane
+ // does the same one row above the pair's end
+ const row_lo: u16 = ay + BOX_H - 1;
+ const row_hi: u16 = ay + ah + bh - (BOX_H + 1);
+
+ // each axis alone, at both walls
+ try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
+ try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
+ try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999));
+
+ // THE corner property: drag off the right edge while staying mid-height —
+ // x sits at its wall, y still tracks the mouse row exactly
+ try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999));
+ try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12));
+ // and the mirror: pinned at the bottom, x still free
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999));
+ try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
+ // both pinned at once is just both walls, not a stuck gesture
+ try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999));
+
+ // degenerate pairs pass through untouched, each on its own terms: a pane
+ // pair with no room stays draggable even when the column pair has room
+ try std.testing.expectEqual(@as(u16, 7), clampBorderCol(0, 5, 5, 7));
+ try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40));
+ try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9));
+}
+
pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
pub const Options = struct {
@@ -5412,10 +5497,23 @@ pub const Pardes = struct {
}
}
// resize handles next: a pane's own trailing edge (v: the
- // left column's last col; h: the upper pane's last row)
+ // left column's last col; h: the upper pane's last row).
+ // The v test still wins outright, but it now also asks
+ // whether this same cell is one of ITS OWN column's h
+ // handles — that cell is the corner where the two lines
+ // cross, and grabbing it drags both boundaries at once
+ // (see Drag.border_v for why only the left column counts).
for (0..p.ncol -| 1) |c| {
if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
- p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ var corner: ?usize = null;
+ for (0..p.col_n[c] -| 1) |k| {
+ const r = p.rects[p.col_terms[c][k]];
+ if (mrow == r.y + r.h -| 1) {
+ corner = k;
+ break;
+ }
+ }
+ p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner_idx = corner, .cur_y = mrow } };
return;
}
}
@@ -5520,11 +5618,22 @@ pub const Pardes = struct {
switch (p.drag) {
.border_v => |*d| {
const c = d.left_col;
- if (c + 1 < p.ncol) {
- const lo = p.col_x[c] + config.MINW;
- const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| config.MINW;
- d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
- } else d.cur_x = mcol;
+ d.cur_x = if (c + 1 < p.ncol)
+ clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol)
+ else
+ mcol;
+ // a corner also drives left_col's pane pair, off the SAME mouse
+ // position but through its own clamp — the geometry a clamp
+ // reads (widths for x, heights for y) is frozen for the whole
+ // drag and never crosses axes, so one edge parked at its stop
+ // leaves the other tracking the mouse
+ if (d.corner_idx) |k| if (k + 1 < p.col_n[c]) {
+ const a = p.rects[p.col_terms[c][k]];
+ const b = p.rects[p.col_terms[c][k + 1]];
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow);
+ } else {
+ d.cur_y = mrow;
+ };
},
.border_h => |*d| {
const cc = d.col;
@@ -5532,10 +5641,7 @@ pub const Pardes = struct {
if (k + 1 < p.col_n[cc]) {
const a = p.rects[p.col_terms[cc][k]];
const b = p.rects[p.col_terms[cc][k + 1]];
- // either pane may shrink to just its tag row (BOX_H)
- const lo = a.y + BOX_H - 1;
- const hi = a.y +| a.h +| b.h -| (BOX_H + 1);
- d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow);
} else d.cur_y = mrow;
},
.move => |*d| {
@@ -5582,23 +5688,13 @@ pub const Pardes = struct {
p.col_weight[c] = pair * (nl / combined);
p.col_weight[c + 1] = pair - p.col_weight[c];
}
+ // and the corner's other half. The two commits are independent
+ // — column weights are widths, pane vweights are heights, and
+ // neither reads the other — so the order here does not matter
+ // and a failed one cannot lose the other.
+ if (d.corner_idx) |k| p.applyRowSplit(c, k, d.cur_y);
},
- .border_h => |d| {
- const cc = d.col;
- const k = d.top_idx;
- if (k + 1 < p.col_n[cc]) {
- const a = p.panes[p.col_terms[cc][k]] orelse return;
- const b = p.panes[p.col_terms[cc][k + 1]] orelse return;
- const ra = p.rects[p.col_terms[cc][k]];
- const rb = p.rects[p.col_terms[cc][k + 1]];
- const combined: f32 = @floatFromInt(ra.h + rb.h);
- var nt: f32 = @floatFromInt((d.cur_y + 1) -| ra.y);
- nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
- const pair = a.vweight + b.vweight;
- a.vweight = pair * (nt / combined);
- b.vweight = pair - a.vweight;
- }
- },
+ .border_h => |d| p.applyRowSplit(d.col, d.top_idx, d.cur_y),
.move => |d| {
p.moveTerm(d.id, d.cur_x, d.cur_y);
// a file moved into the left column evicts a lone unused shell
@@ -5662,6 +5758,25 @@ pub const Pardes = struct {
}
}
+ /// Commit a horizontal border to the pane weights: in column `cc`, the pane
+ /// at index `k` and the one under it split their combined height at row
+ /// `cur_y`. Shared by the plain h-drag and the h half of a corner drag —
+ /// the same gesture landing on the same boundary must settle identically
+ /// whether or not a column edge came along for the ride.
+ fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
+ if (k + 1 >= p.col_n[cc]) return;
+ const a = p.panes[p.col_terms[cc][k]] orelse return;
+ const b = p.panes[p.col_terms[cc][k + 1]] orelse return;
+ const ra = p.rects[p.col_terms[cc][k]];
+ const rb = p.rects[p.col_terms[cc][k + 1]];
+ const combined: f32 = @floatFromInt(ra.h + rb.h);
+ var nt: f32 = @floatFromInt((cur_y + 1) -| ra.y);
+ nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
+ const pair = a.vweight + b.vweight;
+ a.vweight = pair * (nt / combined);
+ b.vweight = pair - a.vweight;
+ }
+
/// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left
/// button holds a selection drag. The first tap converts the mouse
/// selection into the modal one (vsel anchored at the drag start, cursor
@@ -6905,9 +7020,20 @@ pub const Pardes = struct {
// mid-drag (tiling WM, font-size change) can leave them off the new
// surface — every arm below checks before it draws.
switch (p.drag) {
- .border_v => |d| if (d.cur_x < s.cols) {
- var row: u16 = TOPBAR_H;
- while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ .border_v => |d| {
+ if (d.cur_x < s.cols) {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ }
+ // a corner lights BOTH splits, which is the whole tell that you
+ // grabbed the crossing and not an edge. The horizontal half
+ // stops at the vertical preview rather than at the column's
+ // current right edge, so the two dashes stay joined at the cell
+ // under the mouse and together show the shape being committed.
+ if (d.corner_idx != null and d.cur_y < s.rows) {
+ var col = p.col_x[d.left_col];
+ while (col <= d.cur_x and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ }
},
.border_h => |d| if (d.cur_y < s.rows) {
var col = p.col_x[d.col];
diff --git a/test/snapshots/cornerdrag.golden b/test/snapshots/cornerdrag.golden
new file mode 100644
index 00000000..30ebaee1
--- /dev/null
+++ b/test/snapshots/cornerdrag.golden
@@ -0,0 +1,248 @@
+== snap hover-corner grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/cwd Del ╎• /tmp/pardes-snap/cornerdrag/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+|• /tmp/pardes-snap/cornerdrag/cwd Del ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap edge-drag grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag╎cwd Del • /tmp/pardes-snap/cornerdrag/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|• /tmp/pardes-snap/cornerdrag╎cwd Del
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap edge-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/• /tmp/pardes-snap/cornerdrag/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|• /tmp/pardes-snap/cornerdrag/
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap corner-drag grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/• /tmp/pardes-snap/cornerdrag╎cwd Del
+| ╎
+| w.txt w.txt ╎
+| ╎
+| ╎
+| ╎
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|• /tmp/pardes-snap/cornerdrag/ ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap corner-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/cwd Del • /tmp/pardes-snap/cornerdrag/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|• /tmp/pardes-snap/cornerdrag/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap clamp-x grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/cwd Del • /tmp/pardes-snap/cornerdrag/╎wd Del
+| ╎
+| w.txt w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|• /tmp/pardes-snap/cornerdrag/cwd Del ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap clamp-y grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/cwd Del ╎• /tmp/pardes-snap/cornerdrag/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+|• /tmp/pardes-snap/cornerdrag/cwd Del ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| ╎
+== snap clamp-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+|• /tmp/pardes-snap/cornerdrag/cwd Del • /tmp/pardes-snap/cornerdrag/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|• /tmp/pardes-snap/cornerdrag/cwd Del
diff --git a/test/snapshots/cornerdrag.snap b/test/snapshots/cornerdrag.snap
new file mode 100644
index 00000000..1ad5d5ae
--- /dev/null
+++ b/test/snapshots/cornerdrag.snap
@@ -0,0 +1,51 @@
+# corner grabs: where a column edge crosses a pane edge, ONE drag moves both.
+# Only the left column's h-splits make a corner — the v handle IS that column's
+# last cell, so that is the only pane pair whose horizontal hint runs through
+# it, and a corner therefore sits exactly where the two lines visibly cross.
+# The layout is windowops': col 0 (x 0, w 50) holds panes 0 (rows 1-15) and 1
+# (rows 16-29), col 1 holds one pane. The corner is col 49, row 15.
+file w.txt x
+start 30 100 -n 3
+wait 8000 w.txt
+stable 700 20000
+# hover the corner: BOTH hints light, before anything is grabbed
+motion 50 16
+stable 400 5000
+snap hover-corner
+# CONTROL: the same vertical edge ten rows off the crossing is a plain edge.
+# The identical diagonal drag moves the column and nothing else — pane 0 still
+# ends on row 15, and the preview is one dash, not two
+press left 50 6
+drag left 30 24
+stable 400 5000
+snap edge-drag
+release left 30 24
+motion 45 25
+stable 500 8000
+snap edge-done
+# the corner itself, now at col 0's new last col (29) and still row 15: drag it
+# up and to the right and both boundaries follow the one mouse
+press left 30 16
+drag left 60 8
+stable 400 5000
+snap corner-drag
+release left 60 8
+motion 45 25
+stable 500 8000
+snap corner-done
+# clamp interaction, half one: shove the corner off the right edge while
+# staying mid-height. The column preview parks at its MINW stop; the row
+# preview keeps tracking the mouse exactly
+press left 60 8
+drag left 99 20
+stable 400 5000
+snap clamp-x
+# half two, the mirror: below the bottom at mid-width. Now the ROW preview
+# parks (the lower pane keeps its tag row) and the COLUMN keeps tracking
+drag left 60 30
+stable 400 5000
+snap clamp-y
+release left 60 30
+motion 45 25
+stable 500 8000
+snap clamp-done