summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/builtins.zig12
-rw-r--r--src/config.zig1
-rw-r--r--src/file_pane.zig7
-rw-r--r--src/gui/gui.zig31
-rw-r--r--src/pardes.zig199
-rw-r--r--src/term_pane.zig3
-rw-r--r--src/web.zig19
-rw-r--r--test/snapshots/builtins.golden84
-rw-r--r--test/snapshots/leader.golden26
-rw-r--r--test/snapshots/leader.snap7
-rw-r--r--test/snapshots/tagbottom.golden549
-rw-r--r--test/snapshots/tagbottom.snap154
12 files changed, 961 insertions, 131 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index b1380203..934b3996 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -191,6 +191,18 @@ pub const Wrap = struct {
}
};
+/// Taglines at the BOTTOM of every pane instead of the top. Global like Wrap
+/// and for the same reason — a layout where some panes label themselves above
+/// and others below is unreadable, so it is one switch for the whole screen.
+/// The transient message row moves the other way to keep out of its way (see
+/// render), which puts the two strips of chrome at the ends they always were,
+/// just swapped.
+pub const Tagbottom = struct {
+ pub fn run(c: Ctx) void {
+ c.p.tag_bottom = !c.p.tag_bottom;
+ }
+};
+
/// One step along the ring. With 228 themes in it this is no longer a way to
/// REACH a theme — ThemeSel is — but it is still the way to browse one, and the
/// browse got better rather than worse: the generated half is sorted by name, so
diff --git a/src/config.zig b/src/config.zig
index 93395406..76d63727 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -121,6 +121,7 @@ pub const leader_path = paths: {
.Debug = "td",
.Colors = "tc",
.Wrap = "tw",
+ .Tagbottom = "tb",
.NextColor = "tn",
.Crt = "tr",
// the theme picker joins the toggles it belongs with; `Theme` itself takes
diff --git a/src/file_pane.zig b/src/file_pane.zig
index 98a72ac4..b06599ae 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -277,6 +277,8 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
// the number sits on the first of them, so the whole run lights up — the
// gutter is naming the line you are on, and that is still one line
const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
+ // the body's first row, the way renderPane derives it (Tagbottom)
+ const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
const row_line: i32 = if (pane.wrap_n == 0)
@@ -285,7 +287,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
const on_cursor = row_line == cur_line;
var c: u16 = 0;
while (c < config.PREFIX_W and c < tw) : (c += 1) {
- const cell = s.at(tx + c, r.y + pardes.BOX_H + vr);
+ const cell = s.at(tx + c, body_y + vr);
cell.default = false; // paints blank gutter rows too
if (on_cursor) {
cell.style.fg = .{ .rgb = ch.tag_fg };
@@ -317,6 +319,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
// indexed start, same as bodyText — an empty tail simply paints nothing
var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const total = nlines(p.gpa, f);
+ const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
// A colour has to land on the byte it belongs to, so this walk reads
@@ -352,7 +355,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
const hidx = idx - f.highlight_start;
if (hidx >= f.highlights.len) continue;
const ss = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue;
- const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + c)), r.y + pardes.BOX_H + vr);
+ const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + c)), body_y + vr);
if (cell.default) continue;
cell.style.fg = .{ .rgb = ss.fg };
cell.style.bold = ss.bold;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index e1d5c86d..1f7812e4 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -500,6 +500,12 @@ const WebChromeTarget = struct { col: u16, row: u16 };
/// Fat-finger hit test for web layout chrome. A gesture is classified once at
/// finger-down, then never changes into scrolling. Handle coordinates are
/// snapped onto the core's one-cell resize edge; body rows remain untouched.
+///
+/// The tag row and the stacked-pair seam come from the core's own rule:
+/// Tagbottom puts a pane's tag on its LAST row, which makes its FIRST row an
+/// ordinary scrollable body row and moves the h-handle down to the lower
+/// pane's first. Kept a line-for-line clone of web.zig's chromeTarget — the
+/// two must classify a finger identically.
fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget {
if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
@@ -508,8 +514,9 @@ fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTar
for (core.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = core.rects[id];
- if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
- return .{ .col = col, .row = rect.y };
+ const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
+ return .{ .col = col, .row = tag };
}
for (0..core.ncol -| 1) |column| {
@@ -520,7 +527,7 @@ fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTar
if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
for (0..core.col_n[column] -| 1) |index| {
const rect = core.rects[core.col_terms[column][index]];
- const handle = rect.y + rect.h -| 1;
+ const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (row == handle) return .{ .col = col, .row = handle };
}
}
@@ -529,8 +536,9 @@ fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTar
for (core.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = core.rects[id];
- if (row == rect.y and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = rect.y };
+ const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = tag };
}
// One-cell tolerance around thin separators, after exact tag rows have had
@@ -543,7 +551,7 @@ fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTar
if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
for (0..core.col_n[column] -| 1) |index| {
const rect = core.rects[core.col_terms[column][index]];
- const handle = rect.y + rect.h -| 1;
+ const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle };
}
}
@@ -984,6 +992,7 @@ const Gui = struct {
// scroll_delta the way the wheel arm of dispatch does.
scroll_pane: ?usize = null,
scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
+ scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it)
scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered
scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since
scroll_delta: f32 = 0, // finite raw distance waiting for the render batch
@@ -2884,6 +2893,10 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
g.scroll_lag = st.lag;
g.scroll_rect = r;
+ // the body origin travels with the rect: the two draw sites below have no
+ // core to ask, and with Tagbottom the body starts at r.y, not r.y + BOX_H
+ const body_y = if (core.tag_bottom) r.y else r.y + pardes.BOX_H;
+ g.scroll_body_y = body_y;
if (st.rows != 0) {
// The offset opens a gap at the trailing edge of the travel, and what
// belongs in it is the row that just left the pane: already gone from
@@ -2896,7 +2909,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const bw = r.w - config.GUTTER;
const bh = r.h - pardes.BOX_H;
const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh)));
- const srow = r.y + pardes.BOX_H + (if (st.rows > 0) k - 1 else bh - k);
+ const srow = body_y + (if (st.rows > 0) k - 1 else bh - k);
if (srow < s.rows) {
if (g.scroll_edge.len < bw) {
gpa.free(g.scroll_edge);
@@ -2923,7 +2936,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const r = g.scroll_rect;
if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0;
const x0 = r.x + config.GUTTER;
- const y0 = r.y + pardes.BOX_H;
+ const y0 = g.scroll_body_y;
const bw = r.w - config.GUTTER;
const bh = r.h - pardes.BOX_H;
if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us
@@ -2960,7 +2973,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect {
const r = g.scroll_rect;
const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w;
- const py = layout.y_off + @as(f32, @floatFromInt(r.y + pardes.BOX_H)) * layout.h;
+ const py = layout.y_off + @as(f32, @floatFromInt(g.scroll_body_y)) * layout.h;
const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w;
const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h;
const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw)));
diff --git a/src/pardes.zig b/src/pardes.zig
index aa753277..ba80ee22 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3943,9 +3943,15 @@ fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 {
/// 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);
+///
+/// The handle is the seam row that is a BODY row, which is the upper pane's
+/// LAST row normally and — with Tagbottom, where that row is the upper pane's
+/// tag — the lower pane's FIRST. That is the same seam one row further down,
+/// so both walls simply move with it.
+fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 {
+ const d: u16 = if (tag_bottom) 1 else 0;
+ const lo = ay + BOX_H - 1 + d;
+ const hi = (ay +| ah +| bh -| (BOX_H + 1)) +| d;
return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
}
@@ -3967,25 +3973,32 @@ test "a corner drag's two axes clamp independently" {
// 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));
+ try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0, false));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
// 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));
+ try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, false));
// 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(row_hi, clampBorderRow(ay, ah, bh, 999, false));
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));
+ try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false));
+
+ // Tagbottom moves the handle to the LOWER pane's first row, one further
+ // down, and both walls travel with it — either pane still bottoms out at
+ // its bare tag row and neither can be squeezed away
+ try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true));
+ try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true));
+ try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true));
// 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));
+ try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false));
}
pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
@@ -4104,6 +4117,12 @@ pub const Pardes = struct {
/// something. Turned off, every row math in here is the one-row-per-line
/// arithmetic it always was and hscroll comes back.
wrap_on: bool = true,
+ /// every pane's tagline on its LAST row instead of its first (the
+ /// Tagbottom builtin). Render and hit-testing derive two locals from it —
+ /// the tag row and the first body row — and nothing else in here knows;
+ /// the Sel coordinate space is untouched, so row 0 is still the tag and
+ /// the body still starts at BOX_H whichever end they are drawn at.
+ tag_bottom: bool = false,
show_debug: bool = false,
/// the Crt builtin's CRT post-process, rendered by the gui shell only
/// (the tty and web shells never read it)
@@ -8994,13 +9013,23 @@ pub const Pardes = struct {
// pair's h-handle, and the box must stay grabbable to move
if (hovered) |mid| {
const mr = p.rects[mid];
- if (mcol < mr.x + config.GUTTER and mrow < mr.y + BOX_H) {
+ const mtag = if (p.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
+ if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
return;
}
}
// 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 seam row between a stacked
+ // pair that is a BODY row — the upper pane's last, or with
+ // Tagbottom, where that one is the upper pane's tag, the
+ // lower pane's first).
+ // Either way the handle EATS one body row of one of the two
+ // panes — a press there resizes instead of placing a cursor
+ // — and it always has. Tagbottom does not change how many
+ // rows that costs, only WHICH pane pays: the lower pane's
+ // first row rather than the upper pane's last. A no-drag
+ // click on it is a no-op in both orientations.
// 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
@@ -9011,7 +9040,8 @@ pub const Pardes = struct {
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) {
+ const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+ if (mrow == seam) {
corner = k;
break;
}
@@ -9024,7 +9054,8 @@ pub const Pardes = struct {
if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
for (0..p.col_n[cc] -| 1) |k| {
const r = p.rects[p.col_terms[cc][k]];
- if (mrow == r.y + r.h -| 1) {
+ const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+ if (mrow == seam) {
p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
return;
}
@@ -9033,11 +9064,18 @@ pub const Pardes = struct {
const id = hovered orelse return;
const r = p.rects[id];
const pane = p.panes[id] orelse return;
+ // the same two rows renderPane painted through (see there):
+ // the tag's, and the body's first. A Sel row is still 0 for
+ // the tag and BOX_H upward for the body whichever end they
+ // are at, so the mapping happens HERE and everything
+ // downstream of a Sel is untouched.
+ const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
- pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
- } else if (mrow < r.y + BOX_H) {
+ pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y)));
+ } else if (mrow >= tag_y and mrow < tag_y + BOX_H) {
// left press on the tag row: focus the tag AT that
// column (a rendered-tag column — the mouse and the tag
// agree, no clamp into the tail) and anchor a sweep, so
@@ -9050,7 +9088,7 @@ pub const Pardes = struct {
p.active = id;
exitTagEdit(pane); // clicking the body leaves tag editing
const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false;
if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false);
@@ -9111,17 +9149,27 @@ pub const Pardes = struct {
const id = hovered orelse return;
const r = p.rects[id];
const pane = p.panes[id] orelse return;
- if (mcol < r.x + config.GUTTER and mrow >= r.y + BOX_H) {
+ // the tag row and the body's first, exactly as the select
+ // arm above derives them
+ const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
+ if (mcol < r.x + config.GUTTER and !on_tag) {
// gutter: right scrolls DOWN to here, mirroring left's up;
// middle matches left. Right focuses (look lands you
// there); middle does not.
- const local = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
pane.scrollBy(if (m.button == config.look_button) local else -local);
} else if (mcol >= r.x + config.GUTTER) {
if (m.button == config.look_button) p.active = id;
const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ // unlike the select button, execute and look sweep the
+ // TAG through this same Sel — that is how middle-clicking
+ // Del on a tagline runs it — so the tag row is row 0 and
+ // everything else counts from BOX_H, wherever the tag is
+ // physically drawn.
+ const v: i32 = if (on_tag) 0 else @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
// Once a body gesture starts over a native PDF, keep
// it native even when the point misses a letterbox or
@@ -9129,7 +9177,7 @@ pub const Pardes = struct {
// inert instead of executing hidden projected text.
// Tag clicks remain ordinary text/builtin gestures.
const pdf_native = if (comptime pdf_enabled)
- mrow >= r.y + BOX_H and p.pdfNativeReady(pane)
+ !on_tag and p.pdfNativeReady(pane)
else
false;
if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true);
@@ -9163,7 +9211,7 @@ pub const Pardes = struct {
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);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom);
} else {
d.cur_y = mrow;
};
@@ -9174,7 +9222,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]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom);
} else d.cur_y = mrow;
},
.move => |*d| {
@@ -9186,7 +9234,29 @@ pub const Pardes = struct {
const r = p.rects[s.id];
const b = @intFromEnum(s.button);
pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
+ // A Tagbottom drag STAYS IN THE REGION ITS ANCHOR STARTED
+ // IN, which is the one place the two coordinate spaces
+ // genuinely disagree: Sel row 0 is the tag and the body
+ // counts up from BOX_H, but on screen the tag is now BELOW
+ // the body. Sweeping down past the last line onto the
+ // tagline — the ordinary "select to the end" gesture — would
+ // therefore drive r1 to the TOP of Sel space and invert the
+ // selection: the highlight jumps above the anchor, and a 1-2
+ // Cut on it deletes lines nobody swept. So a body-anchored
+ // drag is clamped to the body rows and a tag-anchored one
+ // (execute/look only; the left button's tag sweep is its own
+ // .tag drag) stays on the tag, which is one line anyway.
+ // The cost is that a body sweep can no longer be extended
+ // onto the bottom tagline to pick up the tag text.
+ // With the tag on TOP, Sel order IS screen order and the
+ // gesture is contiguous and correct, so nothing is clamped.
+ if (p.tag_bottom) {
+ const body_h = r.h -| BOX_H;
+ pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
+ 0
+ else
+ @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H);
+ } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
if (comptime pdf_enabled) {
if (s.pdf.native) {
if (p.updatePdfSelection(pane, mcol, mrow, false))
@@ -9406,7 +9476,11 @@ pub const Pardes = struct {
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);
+ // the handle is the upper pane's LAST row (inclusive, hence the +1), or
+ // with Tagbottom the lower pane's FIRST — which is that row's height
+ // already, with nothing to add. Either way a no-drag click hands back
+ // exactly ra.h and nothing moves.
+ var nt: f32 = @floatFromInt(if (p.tag_bottom) cur_y -| ra.y else (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);
@@ -10494,9 +10568,10 @@ pub const Pardes = struct {
const r = for (p.panes, 0..) |slot, i| {
if (slot == pane) break p.rects[i];
} else return null;
- if (mrow < r.y + BOX_H or mrow >= r.y + r.h) return null;
+ const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ if (mrow < body_y or mrow >= body_y + (r.h -| BOX_H)) return null;
const local_y = @as(f64, @floatFromInt(
- @as(u32, mrow - (r.y + BOX_H)) * p.cell_pixels.h + p.cell_pixels.h / 2,
+ @as(u32, mrow - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2,
));
const page = pdfPageAtOffset(&pv, pv.document_scroll_y + local_y);
const document_y = pv.document_scroll_y + local_y;
@@ -10528,7 +10603,7 @@ pub const Pardes = struct {
if (slot == pane) break p.rects[i];
} else return null;
const body_x = @as(i64, r.x + config.GUTTER);
- const body_y = @as(i64, r.y + BOX_H);
+ const body_y = @as(i64, if (p.tag_bottom) r.y else r.y + BOX_H);
var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2;
var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2;
if (clamp_to_page) {
@@ -12049,7 +12124,10 @@ pub const Pardes = struct {
if (text.len == 0 and !leader_here) continue;
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
- const row = r.y + r.h - 1;
+ // ...at the OTHER end from the tagline, whichever end that is:
+ // Tagbottom moved the tag onto this row, so the twin moves to the
+ // pane's first. They stay a pair with the body between them.
+ const row = if (p.tag_bottom) r.y else r.y + r.h - 1;
// In the EDITOR's colours, not the tag bar's: this row is the one
// place the program talks back to you about the buffer you are in,
// and it reads as part of that buffer rather than as another strip
@@ -12153,7 +12231,9 @@ pub const Pardes = struct {
if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
for (0..p.col_n[cc] -| 1) |k| {
const r = p.rects[p.col_terms[cc][k]];
- if (p.hover_row == r.y + r.h -| 1) {
+ // the same seam the h-handle hit test picks (see there)
+ const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+ if (p.hover_row == seam) {
var col = p.col_x[cc];
while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
}
@@ -12227,6 +12307,12 @@ pub const Pardes = struct {
const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
const tw = r.w - config.GUTTER;
+ // The pane's two anchor rows, computed once: the tagline's, and the
+ // body's first. The Tagbottom builtin swaps which end each is at and
+ // NOTHING else in here reads r.y — that is the whole of the feature on
+ // the render side. r.h == 0 returned above, so the bottom row exists.
+ const tag_y = if (p.tag_bottom) r.y + r.h - 1 else r.y;
+ const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
// the pane's own background, for everything that has to read as "no
// chrome here": the body text, and the blank right half of the
// scrollbar's second column. `.default` means the host terminal's own
@@ -12272,9 +12358,9 @@ pub const Pardes = struct {
const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg } };
- s.fill(r.x, r.y, config.GUTTER, BOX_H, box_style);
+ s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style);
const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
- s.set(r.x, r.y, switch (box_mode) {
+ s.set(r.x, tag_y, switch (box_mode) {
.normal => config.tag_normal,
.insert => config.tag_insert,
.tty => config.tag_tty,
@@ -12282,7 +12368,7 @@ pub const Pardes = struct {
// tag: live prefix (cwd/path) + editable tail, on a dim bar
const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } };
- s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
+ s.fill(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
const tag = try p.tagText(arena, pane);
// An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split,
// `|` — is still TYPED INTO the tag tail: same buffer, same offsets,
@@ -12301,7 +12387,7 @@ pub const Pardes = struct {
@min(tag.len, tag.len - @min(tag.len, pane.tag_tail.items.len) + at)
else
tag.len;
- _ = s.print(tx, r.y, tw, tag[0..tag_cut], tag_style);
+ _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style);
// tag char selection highlight (helix v/x, or a tagline sweep),
// inclusive [lo, hi] — rendered-tag columns, so no conversion.
//
@@ -12315,7 +12401,7 @@ pub const Pardes = struct {
var col: usize = b.lo;
const end: usize = b.hi;
while (col <= end and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), r.y);
+ const cell = s.at(tx + @as(u16, @intCast(col)), tag_y);
cell.default = false;
cell.style.bg = .{ .rgb = th.sel_bg };
cell.style.fg = .{ .rgb = th.sel_fg };
@@ -12324,7 +12410,7 @@ pub const Pardes = struct {
// cursor while editing the tag: a rendered-tag column, verbatim
if (active and pane.tag_edit) {
// bar while typing, block for `:` normal mode (same rule as a body)
- if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert };
+ if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = tag_y, .bar = pane.mode == .insert };
}
// A native PDF page uses the same backend-neutral pixel attachment as
@@ -12332,8 +12418,8 @@ pub const Pardes = struct {
// extracted text projection becomes an ordinary readable body.
if (comptime pdf_enabled) {
if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) {
- s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg });
+ s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg });
if (r.h > BOX_H) {
const pv = &pane.pdf.?;
const track_h: usize = r.h - BOX_H;
@@ -12353,7 +12439,7 @@ pub const Pardes = struct {
));
var sy = pos;
while (sy < track_h and sy < pos + len) : (sy += 1)
- s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
}
return;
}
@@ -12366,8 +12452,8 @@ pub const Pardes = struct {
p.drawImage(pane, r, tx, tw);
// thumbless, but the same one column as the real scrollbar below —
// that is the whole point of drawing it
- s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg });
+ s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg });
return;
}
@@ -12384,7 +12470,7 @@ pub const Pardes = struct {
var i: u16 = 0;
while (it.next()) |line| : (i += 1) {
if (i >= body_h) break;
- _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style);
+ _ = s.print(tx, body_y + i, tw, line, body_style);
}
// the two file-only passes and the terminal-only one: each is the
@@ -12409,7 +12495,7 @@ pub const Pardes = struct {
var wr: u16 = 0;
while (wr + 1 < pane.wrap_n and wr + 1 < body_h) : (wr += 1) {
if (pane.wrap_line[wr + 1] != pane.wrap_line[wr]) continue;
- s.set(tx + tw - 1, r.y + BOX_H + wr, config.wrap_marker, .{
+ s.set(tx + tw - 1, body_y + wr, config.wrap_marker, .{
.fg = .{ .rgb = chrome.lineno },
.bg = pane_bg,
});
@@ -12454,9 +12540,12 @@ pub const Pardes = struct {
var row: u16 = 0;
while (row < r.h) : (row += 1) {
if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
+ // a Sel row, so 0 is the tag and BOX_H upward is the body —
+ // the one place the two coordinate spaces meet on the render side
+ const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
var col: i32 = if (pane.file != null) @max(c0, @as(i32, config.PREFIX_W)) else c0;
while (col <= c1 and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row);
+ const cell = s.at(tx + @as(u16, @intCast(col)), sy);
cell.default = false;
cell.style.bg = .{ .rgb = sel_btn[b] };
cell.style.fg = .{ .rgb = th.sel_fg };
@@ -12476,7 +12565,7 @@ pub const Pardes = struct {
if (ar < lo or ar > hi) continue;
var col: u16 = if (pane.file != null) config.PREFIX_W else 0;
while (col < tw) : (col += 1) {
- const cell = s.at(tx + col, r.y + row);
+ const cell = s.at(tx + col, body_y + row - BOX_H);
cell.default = false;
cell.style.bg = .{ .rgb = th.sel_bg };
cell.style.fg = .{ .rgb = th.sel_fg };
@@ -12531,12 +12620,11 @@ pub const Pardes = struct {
while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) {
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
- const prow = vr + @as(i32, BOX_H);
const cstart: i32 = if (w.line == bnd.lo_row) bnd.lo_col - w.at + vpfx else vpfx;
const cend: i32 = if (w.line == bnd.hi_row) bnd.hi_col - w.at + vpfx else @as(i32, tw) - 1;
var col: i32 = @max(cstart, vpfx);
while (col <= cend and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
cell.style.fg = .{ .rgb = th.sel_fg };
@@ -12547,7 +12635,7 @@ pub const Pardes = struct {
const crow = cw.row + @as(i32, BOX_H);
const ccol = sr.col - cw.at + vpfx;
if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) {
- const cell = s.at(tx + @as(u16, @intCast(ccol)), r.y + @as(u16, @intCast(crow)));
+ const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H)));
cell.default = false;
cell.style.bg = .{ .rgb = th.sel_fg };
cell.style.fg = .{ .rgb = th.sel_bg };
@@ -12574,9 +12662,9 @@ pub const Pardes = struct {
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)
- s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y + @as(u16, @intCast(prow)), .bar = pane.mode == .insert };
+ s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert };
} else if (cur.y + BOX_H < r.h and cur.x < tw) {
- s.cursor = .{ .x = tx + cur.x, .y = r.y + cur.y + BOX_H, .bar = pane.mode == .insert };
+ s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y, .bar = pane.mode == .insert };
}
}
@@ -12600,8 +12688,8 @@ pub const Pardes = struct {
// config.GUTTER columns and the click handlers still scroll on any of
// them, so the blank column is still live. Only the ink narrowed.
if (r.h > BOX_H) {
- s.fill(r.x, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = pane_bg });
+ s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg });
const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
.total = file_pane.nlines(p.gpa, f),
.offset = f.scroll,
@@ -12620,7 +12708,7 @@ pub const Pardes = struct {
const pos = (track_h * sb.offset) / total;
var sy: usize = pos;
while (sy < track_h and sy < pos + len) : (sy += 1) {
- s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
+ s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
}
}
}
@@ -12757,7 +12845,7 @@ pub const Pardes = struct {
.pixel_offset_y = placed.pixel_offset_y,
},
.x = tx,
- .y = r.y + BOX_H,
+ .y = if (p.tag_bottom) r.y else r.y + BOX_H,
.w = tw,
.h = r.h - BOX_H,
.rgba = slot.rgba,
@@ -12787,6 +12875,7 @@ pub const Pardes = struct {
if (iv.rgba.len == 0 or r.h <= BOX_H) return;
const body_cols = tw;
const body_rows = r.h - BOX_H;
+ const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
const use_petscii = iv.petscii or !p.native_images;
if (!use_petscii) {
if (!p.appendImagePlace(.{
@@ -12795,7 +12884,7 @@ pub const Pardes = struct {
} else 0),
.serial = pane.serial,
.x = tx,
- .y = r.y + BOX_H,
+ .y = body_y,
.w = body_cols,
.h = body_rows,
.rgba = iv.rgba,
@@ -12839,7 +12928,7 @@ pub const Pardes = struct {
.terminal => .{ .index = c.bg },
};
const sx = tx + @as(u16, @intCast(offx + cx));
- const sy = r.y + BOX_H + @as(u16, @intCast(offy + cy));
+ const sy = body_y + @as(u16, @intCast(offy + cy));
if (sx < s.cols and sy < s.rows)
s.set(sx, sy, c.glyph[0..c.glen], .{ .fg = fg, .bg = bg });
}
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 1c03afa7..47123ceb 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -99,6 +99,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void {
const s = &p.surface;
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h and vr < pane.rows) : (vr += 1) {
const grid = pane.gridRow(off + @as(i32, vr)) - goff;
@@ -110,7 +111,7 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
while (c < tw) : (c += 1) {
const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue;
if (ci.cell.wide == .spacer_tail) continue;
- const cell = s.at(tx + c, r.y + pardes.BOX_H + vr);
+ const cell = s.at(tx + c, body_y + vr);
if (cell.default) continue;
cell.style = cellStyle(p, ci);
}
diff --git a/src/web.zig b/src/web.zig
index 42359329..b8abf27e 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -489,13 +489,19 @@ fn putU32(s: *State, value: u32) void {
const Target = struct { col: u16, row: u16 };
+/// The tag row and the stacked-pair seam, the way the core's own hit test
+/// derives them: Tagbottom puts a pane's tag on its LAST row, which makes its
+/// FIRST row an ordinary scrollable body row and moves the h-handle down to
+/// the lower pane's first. Kept a line-for-line clone of gui.zig's
+/// webChromeTarget — the two must classify a finger identically.
fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
for (core.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = core.rects[id];
- if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.config.GUTTER)
- return .{ .col = col, .row = rect.y };
+ const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.config.GUTTER)
+ return .{ .col = col, .row = tag };
}
for (0..core.ncol -| 1) |column| {
const handle = core.col_x[column] + core.col_w[column] -| 1;
@@ -505,15 +511,16 @@ fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
for (0..core.col_n[column] -| 1) |index| {
const rect = core.rects[core.col_terms[column][index]];
- const handle = rect.y + rect.h -| 1;
+ const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (row == handle) return .{ .col = col, .row = handle };
}
}
for (core.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = core.rects[id];
- if (row == rect.y and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = rect.y };
+ const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = tag };
}
for (0..core.ncol -| 1) |column| {
const handle = core.col_x[column] + core.col_w[column] -| 1;
@@ -523,7 +530,7 @@ fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
for (0..core.col_n[column] -| 1) |index| {
const rect = core.rects[core.col_terms[column][index]];
- const handle = rect.y + rect.h -| 1;
+ const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (distance(row, handle) == 1) return .{ .col = col, .row = handle };
}
}
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index afee3f3f..7e4b3366 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -153,7 +153,6 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 24 SPC l i Lspinfo
| 25 SPC l k Hover
| 26 SPC l r Rename
| 27 SPC l s Symbols
@@ -161,26 +160,27 @@
| 29 SPC s d Dump topbar
| 30 SPC s r Restore
| 31 SPC t a Ascii
-| 32 SPC t c Colors
-| 33 SPC t d Debug topbar
-| 34 SPC t i PdfTint
-| 35 SPC t l Palette
-| 36 SPC t n NextColor topbar
-| 37 SPC t p Petscii
-| 38 SPC t r Crt
-| 39 SPC t s PdfSections
-| 40 SPC t t ThemeSel
-| 41 SPC t w Wrap
-| 42 SPC t z PdfFit
-| 43 SPC w h Left C-w h, C-w left
-| 44 SPC w j Down C-w j, C-w down
-| 45 SPC w k Up C-w k, C-w up
-| 46 SPC w l Right C-w l, C-w right
-| 47 Look enter, right-click
-| 48 Exec tab, middle-click
-| 49 Theme
-| 50 Shell
-| 51
+| 32 SPC t b Tagbottom
+| 33 SPC t c Colors
+| 34 SPC t d Debug topbar
+| 35 SPC t i PdfTint
+| 36 SPC t l Palette
+| 37 SPC t n NextColor topbar
+| 38 SPC t p Petscii
+| 39 SPC t r Crt
+| 40 SPC t s PdfSections
+| 41 SPC t t ThemeSel
+| 42 SPC t w Wrap
+| 43 SPC t z PdfFit
+| 44 SPC w h Left C-w h, C-w left
+| 45 SPC w j Down C-w j, C-w down
+| 46 SPC w k Up C-w k, C-w up
+| 47 SPC w l Right C-w l, C-w right
+| 48 Look enter, right-click
+| 49 Exec tab, middle-click
+| 50 Theme
+| 51 Shell
+| 52
== snap not-a-picker grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -214,7 +214,6 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 24 SPC l i Lspinfo
| 25 SPC l k Hover
| 26 SPC l r Rename
| 27 SPC l s Symbols
@@ -222,26 +221,27 @@
| 29 SPC s d Dump topbar
| 30 SPC s r Restore
| 31 SPC t a Ascii
-| 32 SPC t c Colors
-| 33 SPC t d Debug topbar
-| 34 SPC t i PdfTint
-| 35 SPC t l Palette
-| 36 SPC t n NextColor topbar
-| 37 SPC t p Petscii
-| 38 SPC t r Crt
-| 39 SPC t s PdfSections
-| 40 SPC t t ThemeSel
-| 41 SPC t w Wrap
-| 42 SPC t z PdfFit
-| 43 SPC w h Left C-w h, C-w left
-| 44 SPC w j Down C-w j, C-w down
-| 45 SPC w k Up C-w k, C-w up
-| 46 SPC w l Right C-w l, C-w right
-| 47 Look enter, right-click
-| 48 Exec tab, middle-click
-| 49 Theme
-| 50 Shell
-| 51
+| 32 SPC t b Tagbottom
+| 33 SPC t c Colors
+| 34 SPC t d Debug topbar
+| 35 SPC t i PdfTint
+| 36 SPC t l Palette
+| 37 SPC t n NextColor topbar
+| 38 SPC t p Petscii
+| 39 SPC t r Crt
+| 40 SPC t s PdfSections
+| 41 SPC t t ThemeSel
+| 42 SPC t w Wrap
+| 43 SPC t z PdfFit
+| 44 SPC w h Left C-w h, C-w left
+| 45 SPC w j Down C-w j, C-w down
+| 46 SPC w k Up C-w k, C-w up
+| 47 SPC w l Right C-w l, C-w right
+| 48 Look enter, right-click
+| 49 Exec tab, middle-click
+| 50 Theme
+| 51 Shell
+| 52
== snap window-group grid=120x60 cursor=7,32
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 9398b158..74719f13 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -393,19 +393,19 @@
| 1 pardes builtins under SPC t
| 2
| 3 SPC t a Ascii
-| 4 SPC t c Colors
-| 5 SPC t d Debug topbar
-| 6 SPC t i PdfTint
-| 7 SPC t l Palette
-| 8 SPC t n NextColor topbar
-| 9 SPC t p Petscii
-| 10 SPC t r Crt
-| 11 SPC t s PdfSections
-| 12 SPC t t ThemeSel
-| 13 SPC t w Wrap
-| 14 SPC t z PdfFit
-| 15
-|
+| 4 SPC t b Tagbottom
+| 5 SPC t c Colors
+| 6 SPC t d Debug topbar
+| 7 SPC t i PdfTint
+| 8 SPC t l Palette
+| 9 SPC t n NextColor topbar
+| 10 SPC t p Petscii
+| 11 SPC t r Crt
+| 12 SPC t s PdfSections
+| 13 SPC t t ThemeSel
+| 14 SPC t w Wrap
+| 15 SPC t z PdfFit
+| 16
|
|
== snap help-window grid=100x40 cursor=7,22
diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap
index c71aaee6..db212631 100644
--- a/test/snapshots/leader.snap
+++ b/test/snapshots/leader.snap
@@ -66,9 +66,10 @@ key ?
wait 10000 SPC c n
stable 700 15000
snap help-all
-# `SPC t ?`: the toggle group, seven of them now that the image renderer
-# switches are builtins instead of words in the image tag — Crt sits past this
-# pane's last row in the full listing above, and only a group filter shows it
+# `SPC t ?`: the toggle group, twelve of them now that the image renderer
+# switches and the PDF ones are builtins instead of words in the image tag —
+# Crt sits past this pane's last row in the full listing above, and only a
+# group filter shows it
key space
key t
key ?
diff --git a/test/snapshots/tagbottom.golden b/test/snapshots/tagbottom.golden
new file mode 100644
index 00000000..cc402fd1
--- /dev/null
+++ b/test/snapshots/tagbottom.golden
@@ -0,0 +1,549 @@
+== snap boot grid=100x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagbottom/cwd/top.txt Save New Del
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| 15 ooo
+| 16 ppp
+| 17 qqq
+| 18 rrr
+| 19 sss
+| 20 ttt
+| 21 uuu
+| 22 vvv
+| 23 www
+| 24 xxx
+| 25 yyy
+| 26 zzz
+|
+|
+== snap on grid=100x30 cursor=7,1
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| 15 ooo
+| 16 ppp
+| 17 qqq
+| 18 rrr
+| 19 sss
+| 20 ttt
+| 21 uuu
+| 22 vvv
+| 23 www
+| 24 xxx
+| 25 yyy
+| 26 zzz
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/top.txt Save New Del
+== snap off grid=100x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagbottom/cwd/top.txt Save New Del
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| 15 ooo
+| 16 ppp
+| 17 qqq
+| 18 rrr
+| 19 sss
+| 20 ttt
+| 21 uuu
+| 22 vvv
+| 23 www
+| 24 xxx
+| 25 yyy
+| 26 zzz
+|
+|
+== snap tag-typed grid=100x30 cursor=60,29
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| 15 ooo
+| 16 ppp
+| 17 qqq
+| 18 rrr
+| 19 sss
+| 20 ttt
+| 21 uuu
+| 22 vvv
+| 23 www
+| 24 xxx
+| 25 yyy
+| 26 zzz
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+== snap look-bottom-tag grid=100x30 cursor=7,16
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap body-cursor grid=100x30 cursor=9,5
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap seam-noop grid=100x30 cursor=9,5
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| 14 nnn
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap seam-dragged grid=100x30 cursor=9,5
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap wheel-body grid=100x30 cursor=9,1
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| 13 mmm
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap wheel-gutter grid=100x30 cursor=9,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| 11 kkk
+| 12 lll
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap gutter-click grid=100x30 cursor=9,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap search-prompt grid=100x30 cursor=6,11
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| /ZZ
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== snap leader grid=100x30 cursor=9,13
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 2 bbb
+| 3 ccc
+| 4 ddd
+| 5 eee
+| 6 fff
+| 7 ggg
+| 8 hhh
+| 9 iii
+| 10 jjj
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| SPC
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== style sweep-down
+|0: 0-56 #949494,#121212, 57-99 d,#121212,
+|1: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-99 d,#080808,
+|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-99 d,#080808,
+|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #949494,#121212, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
+|10: 0-1 #ffffff,#626262, 2-90 #949494,#121212, 91-99 d,#121212,
+|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-11 #bdbdbd,#080808, 12-99 d,#080808,
+|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-11 #bdbdbd,#080808, 12-99 d,#080808,
+|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
+|29: 0-1 #ffffff,#262626, 2-79 #949494,#121212, 80-99 d,#121212,
+== snap cut-down grid=100x30 cursor=7,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 jj
+| 5 kkk
+| 6 lll
+| 7 mmm
+| 8 nnn
+| 9 ooo
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 ZZONE
+| 2 ZZTWO
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
+== file cut-buffer
+aaa
+bbb
+ccc
+jj
+kkk
+lll
+mmm
+nnn
+ooo
+ppp
+qqq
+rrr
+sss
+ttt
+uuu
+vvv
+www
+xxx
+yyy
+zzz
+== end file
+== snap del-bottom-tag grid=100x30 cursor=7,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 jj
+| 5 kkk
+| 6 lll
+| 7 mmm
+| 8 nnn
+| 9 ooo
+| 10 ppp
+| 11 qqq
+| 12 rrr
+| 13 sss
+| 14 ttt
+| 15 uuu
+| 16 vvv
+| 17 www
+| 18 xxx
+| 19 yyy
+| 20 zzz
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+== snap off-again grid=100x30 cursor=7,5
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
+| 1 aaa
+| 2 bbb
+| 3 ccc
+| 4 jj
+| 5 kkk
+| 6 lll
+| 7 mmm
+| 8 nnn
+| 9 ooo
+| 10 ppp
+| 11 qqq
+| 12 rrr
+| 13 sss
+| 14 ttt
+| 15 uuu
+| 16 vvv
+| 17 www
+| 18 xxx
+| 19 yyy
+| 20 zzz
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tagbottom.snap b/test/snapshots/tagbottom.snap
new file mode 100644
index 00000000..801df1a8
--- /dev/null
+++ b/test/snapshots/tagbottom.snap
@@ -0,0 +1,154 @@
+# Tagbottom: every pane's tagline on its LAST row instead of its first, and
+# the transient message row (prompts, messages, the SPC indicator) at the other
+# end with it. One global bool, so the whole screen flips at once.
+#
+# The two things worth pinning here are not pixels. The first is that a click
+# still lands where it looks like it lands: the Sel coordinate space is
+# UNCHANGED (row 0 is the tag, the body starts at BOX_H) and the mouse maps a
+# screen row into it, so a look or an execute on a bottom tagline acts on the
+# word under the pointer instead of silently sweeping whatever body row shares
+# that number. Every mouse assertion below is therefore a CONSEQUENCE — a pane
+# appearing, a pane going away, a view scrolling by a counted number of rows —
+# and not a picture of a highlight. The tagline-execute pair is what caught the
+# bug this script exists for: before the tag row got its own arm in the
+# execute/look press, both ran a blank body row and a golden recorded the
+# no-op as if it had passed.
+# The second is that turning it back off restores the top-tagline layout
+# exactly: `off` is byte-for-byte `boot`, cursor included.
+file top.txt aaa\nbbb\nccc\nddd\neee\nfff\nggg\nhhh\niii\njjj\nkkk\nlll\nmmm\nnnn\nooo\nppp\nqqq\nrrr\nsss\nttt\nuuu\nvvv\nwww\nxxx\nyyy\nzzz
+file bottom.txt ZZONE\nZZTWO
+start 30 100 top.txt
+wait 8000 New Newcol
+wait 8000 top.txt
+stable 700 20000
+snap boot
+# SPC t b. The tag leaves row 1 for row 29, the body slides up one row, and
+# the cursor goes with it (7,2 -> 7,1) because it is on the same file line.
+key space t b
+stable 700 15000
+snap on
+# ...and straight back. This grid must equal `boot`'s, to the cell.
+key space t b
+stable 700 15000
+snap off
+key space t b
+stable 700 15000
+# CLICK the bottom tagline (col 50 of row 30, the pane's last row) and type
+# into it. A clicked tag is insert mode, so the text lands as text, and the
+# cursor is down on the tagline with it. Col 50 is past the end of the path,
+# which is read-only — a click inside it parks the cursor there and the typing
+# goes nowhere.
+press left 50 30
+release left 50 30
+stable 400 5000
+send \x20bottom.txt
+stable 600 8000
+stable 400 5000
+snap tag-typed
+# ...and LOOK the word just typed, from that same bottom tagline: bottom.txt
+# opens as a second pane below. It only can if the press was mapped to the tag
+# — mapped as a body row it lands on blank line 29 and looks nothing at all.
+press right 55 30
+release right 55 30
+wait 10000 ZZONE
+stable 700 15000
+snap look-bottom-tag
+# a body click in the UPPER pane: the cursor lands on the row under the
+# pointer, counted from the pane's first row rather than from a tag that is no
+# longer there. Row 6 is grid row 5, the fifth body row, file line 5.
+press left 10 6
+release left 10 6
+stable 400 8000
+snap body-cursor
+# The h-resize seam. With top taglines it is the upper pane's LAST row; here
+# that row is the upper pane's TAG, so the handle is the lower pane's FIRST row
+# (grid 16, click row 17). A no-drag click on it must leave both sizes alone.
+press left 40 17
+release left 40 17
+motion 40 25
+stable 500 8000
+snap seam-noop
+# ...and the real drag, five rows up: the upper pane keeps 10 rows, the lower
+# grows to 19. Both taglines follow their own pane's bottom.
+press left 40 17
+drag left 40 12
+release left 40 12
+motion 40 25
+stable 500 8000
+snap seam-dragged
+# the wheel over the BODY of the upper pane (nine body rows now): four rows
+# down, so the view starts at line 5 and the pinned cursor rides up to the
+# pane's first row
+wheel down 40 4
+wheel down 40 4
+wheel down 40 4
+wheel down 40 4
+stable 400 8000
+snap wheel-body
+# ...and one row back up with the wheel over the GUTTER: the same pane, found
+# through its gutter column. View at line 4.
+wheel up 1 4
+stable 400 8000
+snap wheel-gutter
+# a left click in the gutter scrolls UP to the clicked row, and the row it
+# counts from is the BODY's first, not r.y + BOX_H. Row 4 is grid row 3, the
+# third body row: two rows up, to line 2. Counted from the wrong end it would
+# be one row up, to line 3.
+press left 1 4
+release left 1 4
+stable 400 8000
+snap gutter-click
+# `/` in the lower pane's body arms the search input. The prompt is CUT off the
+# tagline and drawn on the message row with the cursor — and with Tagbottom
+# those two are at opposite ends of the pane: the tail keeps its builtins on
+# row 29 while `/ZZ` and the cursor sit up on row 11, the lower pane's first.
+press left 10 14
+release left 10 14
+stable 400 5000
+key /
+send ZZ
+stable 600 8000
+stable 400 5000
+snap search-prompt
+key esc
+settle 100
+stable 400 5000
+# the SPC leader indicator lives on that same message row, right-aligned
+key space
+stable 400 5000
+snap leader
+key esc
+settle 100
+stable 400 5000
+# THE INVERSION. Sweeping DOWN past the last body row is the ordinary "select
+# to the end" gesture, and with the tag drawn BELOW the body it lands on the
+# tagline — Sel row 0. Unclamped that drives the selection's far end to the TOP
+# of Sel space: the highlight jumps ABOVE the anchor and a 1-2 Cut deletes lines
+# nobody swept. Anchored on grid row 3 (file line 4) and dragged onto the upper
+# pane's tag row, the sweep must cover lines 4..10 downward, not 2..4 upward.
+press left 8 4
+drag left 8 11
+stable 400 5000
+snapstyle sweep-down
+# ...and the proof is the BUFFER, not the highlight: 1-2 Cut the sweep, save,
+# and read the file back. aaa/bbb/ccc survive and kkk..zzz survive; unclamped
+# this file lost bbb/ccc/ddd instead and kept eee onward.
+press middle 8 11
+release middle 8 11
+release left 8 11
+stable 400 5000
+snap cut-down
+key space f s
+stable 700 15000
+snapfile cut-buffer /tmp/pardes-snap/tagbottom/cwd/top.txt
+# EXECUTE `Del` on the lower pane's bottom tagline: the pane goes away and the
+# upper one takes the whole column back. The middle button's half of the same
+# tag mapping the look used above.
+press middle 78 30
+release middle 78 30
+stable 700 15000
+snap del-bottom-tag
+# and off again, one pane, its tagline back on row 1 with the body under it
+key space t b
+stable 700 15000
+snap off-again