From 15ddd805905f785f4dde463d4d6b91e1d2c41e26 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 00:17:45 -0300 Subject: A pane's last row of text is no resize handle; the grip is, and the GUI's rule A press on a pane's last row began a seam drag, so the row could not be clicked or swept like the rest of the text (rsearch dragged the handle out of the way first). Now no row of text is a handle: in the GUI a press on the 2 px rule between two panes drags their seam, and anywhere the grip does, as acme's button moves a window -- dragged up or down its own column it moves the pane's top, either pane down to its tag alone (acme's coldragwin), dragged down inside itself too. The hover hint over a last row is gone; column edges resize as they did. Scripts that dragged a seam drag a grip instead and match their goldens; six goldens lost the hint or moved a cursor to the clicked text, re-recorded by name. Co-Authored-By: Claude Opus 5.5 --- src/draw.zig | 11 ++--------- src/gui/gui.zig | 9 ++++++++- src/layout.zig | 17 ++++++++++++++--- src/mouse.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++-------- src/pardes.zig | 5 +++++ src/tutor.txt | 9 +++++++++ 6 files changed, 89 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/draw.zig b/src/draw.zig index fb770b60..4ad1a1fe 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -504,15 +504,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } } - for (0..p.ncol) |cc| { - if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; - // the same seam the h-handle hit test picks (see there) - if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { - var col = p.col_x[cc]; - while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); - s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } }); - } - } + // No hint over a pane's last row: a row of text is no handle; two + // panes resize by the rule between them or by the grip. for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 141a802a..ab281cb6 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1972,7 +1972,11 @@ fn glyphForCodepoint(g: *const Gui, cp: u32) FaceGlyph { } const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; -const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null }; +const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null, rule_above: bool = false }; + +/// How near a row's top edge a press is on the rule drawn there between two +/// panes: the rule's 2 px and a pixel either side. +const rule_grab_px: f32 = 3; const WindowGeometry = struct { window_w: f32, window_h: f32, @@ -3420,6 +3424,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit, + .rule_above = mc.rule_above, .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); @@ -3720,6 +3725,8 @@ fn gridCellAtDimensions( ) MouseCell { const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; + // On the rule over this row, which is where two panes meet if they do. + point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < rule_grab_px; if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| { for (p.surface.tagLayers()) |*layer| { if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { diff --git a/src/layout.zig b/src/layout.zig index 6de7a912..25c95d30 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -162,8 +162,15 @@ pub fn targetColumn(p: *Pardes, cur_x: u16) usize { } pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { + return splitRowFor(y, h, cur_y, null); +} + +/// As `splitRowForExtent`, each pane keeping at least `least` rows when +/// given: a pane dragged by its grip within its own column is how a +/// terminal resizes, and acme lets either window keep only its tag. +fn splitRowFor(y: u16, h: u16, cur_y: u16, least: ?u16) ?u16 { if (h < 2) return null; - const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; + const min_each: u16 = least orelse if (h >= config.MINH * 2) config.MINH else 1; const lo = y +| min_each; const hi = y + h - min_each; if (lo > hi) return y + h / 2; @@ -176,7 +183,11 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme if (tc == src.col and p.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = p.rects[id]; - if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; + // Dropped on itself it stays, unless dragged down from its top with + // a pane above: its top moves down there, the pane above taking the + // rows, as acme's window dragged by its button does. With no rule + // between panes, that is how a terminal resizes them. + if (cur_y >= sr.y and cur_y < sr.y + sr.h and (src.idx == 0 or cur_y <= sr.y)) return null; } var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { @@ -196,7 +207,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme const pid = p.col_panes[tc][k]; if (pid == id) continue; const h = heights[pid]; - const row = splitRowForExtent(y, h, cur_y) orelse { + const row = splitRowFor(y, h, cur_y, if (tc == src.col) BOX_H else null) orelse { y +|= h; continue; }; diff --git a/src/mouse.zig b/src/mouse.zig index 36786612..1ca53017 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -97,13 +97,50 @@ fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { return p; } +test "a pane's last row of text is no resize handle; the rule over the next pane is" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + const seam = seamRowOf(p, 1, 0); + const inside = p.col_x[1] + p.col_w[1] / 2; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = seam } }); + try std.testing.expect(p.drag != .border_h); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = seam } }); + try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2); +} + +test "a pane's grip dragged up or down its own column moves its top, down to a tag alone above" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + const upper = p.col_panes[1][0]; + const lower = p.col_panes[1][1]; + const grip_x = p.rects[lower].x; + // Up, past the pane above's first row: it keeps one row, its tag. + const top = p.rects[upper].y; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } }); + p.sync(); + try std.testing.expectEqual(@as(u16, 1), p.rects[upper].h); + try std.testing.expectEqual(lower, p.col_panes[1][1]); + // Down, inside itself: its top follows, the pane above taking the rows. + const to = p.rects[lower].y + 6; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = to } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = to } }); + p.sync(); + try std.testing.expectEqual(to, p.rects[lower].y); +} + fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { const from = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; const to: u16 = @intCast(@as(i32, from) + delta); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } }); + // On the rule a pixel frontend draws over the lower pane's first row. + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from + 1, .rule_above = true } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to + 1 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to + 1 } }); p.sync(); return seamRowOf(p, 1, 0); } @@ -437,13 +474,17 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { return; } } - for (0..p.ncol) |cc| { + // Two panes are resized on the rule between them, never on a + // row of text; with no rule, by the grip, as acme moves a + // window by its button. + if (m.rule_above and mrow > 0) for (0..p.ncol) |cc| { if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue; - if (seamIdxAt(p, cc, mrow)) |k| { - p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; + const seam = if (p.settings.tag_bottom) mrow else mrow - 1; + if (seamIdxAt(p, cc, seam)) |k| { + p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = seam } }; return; } - } + }; const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; @@ -597,7 +638,9 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo if (k + 1 < p.col_n[cc]) { const a = p.rects[p.col_panes[cc][k]]; const b = p.rects[p.col_panes[cc][k + 1]]; - d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); + // Held by the rule over the lower pane: the seam is the row above. + const seam = if (p.settings.tag_bottom) mrow else mrow -| 1; + d.cur_y = clampBorderRow(a.y, a.h, b.h, seam, p.settings.tag_bottom); } else d.cur_y = mrow; }, .column_move => |*d| d.cur_x = mcol, diff --git a/src/pardes.zig b/src/pardes.zig index ea03b3eb..8dbe6744 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3483,6 +3483,11 @@ pub const Mouse = struct { ctrl: bool = false, body_hit: ?BodyHit = null, tag_hit: ?TagHit = null, + /// A pixel frontend's press on the rule it draws at the top of this row, + /// which is where two panes meet if they do: the one grab that resizes + /// them there. A row of text never is one; a terminal, with no rule, + /// resizes by the grip. + rule_above: bool = false, }; pub const Key = struct { diff --git a/src/tutor.txt b/src/tutor.txt index 0893415e..c59deeae 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -132,6 +132,15 @@ DelAbove close it, giving the space to the pane above (Del k) DelBelow close it, giving the space to the pane below (Del j) + RESIZE, AS ACME MOVES A WINDOW + the grip (the box left of a pane's tag) drag it: drop it in another + column to move the pane there, or up or down its own + column to move its top, the pane above taking or giving + the rows -- down to its tag alone + the rule in the GUI, the line between two panes drags their seam + a column's right edge drags the column's width + A pane's last row is text like any other, never a handle. + A new file opens in a new COLUMN only if both panes would still get 100 columns of width. Otherwise it stacks in the one you are in. -- cgit v1.3