summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 19:31:46 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitfb87946e09bf33c0fe663ccba6db14a63e7cfda4 (patch)
tree908506111171e2e7bc4bc7c176171449115d24f0
parent2580dd5ea85d86262c94363dfea9f990eaf56196 (diff)
downloadpardes-fb87946e09bf33c0fe663ccba6db14a63e7cfda4.tar.gz
pardes-fb87946e09bf33c0fe663ccba6db14a63e7cfda4.zip
A B1 sweep held past a pane's edge scrolls the body, as acme's frselect does
Dragging a selection beyond the top or bottom of a pane stopped at the last visible row. Now, while B1 is held past the body's top or bottom (at a pane that reaches the screen's bottom, its last row, since the pointer cannot leave the screen), the body scrolls a line a tick; the sweep's start keeps its place in the text, and a sweep that scrolled ends as the text's own selection. Also here: a ctrl-click is not half a double-click (lspdebug's golden stays as it was), and a test that clicks many points in a row resets the double-click between them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/selections.md7
-rw-r--r--src/mouse.zig59
-rw-r--r--src/pardes.zig1
-rw-r--r--test/panes.zig28
4 files changed, 93 insertions, 2 deletions
diff --git a/docs/selections.md b/docs/selections.md
index 7ec58f2b..0817c215 100644
--- a/docs/selections.md
+++ b/docs/selections.md
@@ -141,3 +141,10 @@ B1-B3 pastes over it. Everything done while B1 stays down is one undo
step, as acme marks the file once per B1 hold (text.c:881, textselect), so
B1-B2 then B1-B3 is a copy: the text cut and pasted back, the cut text
left in the register.
+
+A B1 sweep held past a pane's top (on its tag) or bottom (past it, or on
+its last row when the pane reaches the screen's bottom, which the pointer
+cannot leave) scrolls the body a line a tick, as acme's frselect scrolls,
+the sweep's end under the pointer and its start on its text. A sweep that
+scrolled ends as the text's own selection, from where it began to where it
+ended. B2 and B3 sweeps do not scroll.
diff --git a/src/mouse.zig b/src/mouse.zig
index 61bd30e0..790dc9d1 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -48,6 +48,14 @@ pub const Drag = union(enum) {
button: Mouse.Button,
chorded: bool = false,
ctrl: bool = false,
+ /// A B1 body sweep held past the pane's top (-1) or bottom (1): the
+ /// body scrolls a line a tick, as acme's frselect scrolls
+ /// (libframe frselect.c, the `scroll` callback while the button is
+ /// held outside the frame).
+ edge: i2 = 0,
+ /// Where the sweep began in the text, once it has scrolled: its
+ /// press row may be off the screen.
+ anchor: ?modal.Cursor = null,
/// This gesture began over a usable native PDF raster. The payload is
/// zero-bit when PDF support is absent.
pdf: panes.Pdf.PointerDrag = .{},
@@ -621,6 +629,17 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
// from the pointer.
const body_y = p.bodyTop(pane, r);
const body_h = r.h -| pane.tag_rows;
+ s.edge = if (s.button != config.select_button or pane.sel[b].r0 < tag_rows or pane.file == null or pane.hasPdf() or body_h == 0)
+ 0
+ else if (mrow < body_y)
+ -1
+ else if (mrow >= body_y + body_h or
+ // The pointer cannot leave the screen: at a bottom pane,
+ // its last row is the edge.
+ (body_y + body_h >= p.screen_h and mrow + 1 >= body_y + body_h))
+ 1
+ else
+ 0;
pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows)
tag_line
else if (body_h == 0)
@@ -778,6 +797,34 @@ fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, co
t.cur_pinned = true;
}
+/// On each tick while a B1 body sweep is held past an edge: the body
+/// scrolls a line toward it, the sweep's end stays under the pointer and
+/// its start stays on its text (Drag.select.anchor).
+pub fn edgeScroll(p: *Pardes) void {
+ if (p.drag != .select) return;
+ const s = &p.drag.select;
+ if (s.edge == 0) return;
+ const pane = p.panes[s.id] orelse return;
+ const sl = &pane.sel[sel_slot];
+ if (s.anchor == null) s.anchor = sweepPoint(p, pane, sl.r0, sl.c0) orelse return;
+ const before = pane.scroll();
+ pane.scrollBy(s.edge);
+ // The start's row follows its text while it is on the screen, and
+ // stays on the body's edge row once it is off (the anchor keeps it).
+ const top: i32 = pane.tag_rows;
+ sl.r0 = std.math.clamp(sl.r0 - (pane.scroll() - before), top, top + @as(i32, pane.rows) - 1);
+}
+
+/// The text position under a body cell of the sweep's space.
+fn sweepPoint(p: *Pardes, pane: *panes.Pane, r: i32, c: i32) ?modal.Cursor {
+ const visible = r - @as(i32, pane.tag_rows);
+ if (visible < 0) return null;
+ const w = pane.wrapAt(visible);
+ if (w.line < 0) return null;
+ const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, c - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0));
+ return .{ .row = @intCast(w.line), .col = @intCast(@max(0, col)) };
+}
+
fn clockMs() i64 {
if (comptime !pardes.hosted) return 0;
var ts: std.c.timespec = undefined;
@@ -873,7 +920,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
}
return;
}
- if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
+ if (dragged and s.anchor == null) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane);
const body_vis = sl.r1 - @as(i32, pane.tag_rows);
if (body_vis >= 0 and pane.body.mode != .tty) {
@@ -895,7 +942,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.body.select = false;
pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
- if (!dragged) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1);
+ // A ctrl-click is a definition jump, not half a double-click.
+ if (!dragged and !s.ctrl) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1);
+ if (s.ctrl) p.last_click = null;
+ // A sweep that scrolled has its start off the screen: it
+ // is kept as the text's own selection, start to end.
+ if (dragged) if (s.anchor) |a| {
+ pane.sel[sel_slot].state = .none;
+ pane.body.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ };
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
} else {
diff --git a/src/pardes.zig b/src/pardes.zig
index 20d46dc3..7cdd8461 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5218,6 +5218,7 @@ pub const Pardes = struct {
p.pointer_tag_hit = null;
},
.tick => {
+ mouse.edgeScroll(p);
p.chrome_animation.advance();
p.presentation.advance();
look.advanceLookHover(p);
diff --git a/test/panes.zig b/test/panes.zig
index d8dab298..a6a95124 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -86,6 +86,8 @@ const TagLayerTests = struct {
const source_y = @as(f32, @floatFromInt(layer.viewport.y)) + 0.5;
const painted_x = shown.x + (x / 10 - track.to.x) / track.to.w * shown.w;
const painted_y = shown.y + (source_y - track.to.y) / track.to.h * shown.h;
+ // Each is a first click, whatever cell the one before hit.
+ p.last_click = null;
for ([_]pardes.Mouse.Kind{ .press, .release }) |kind| {
var event = mouse(layer, x, kind);
// Hosts inverse the exact painted pixel into the tag envelope;
@@ -1057,6 +1059,32 @@ const ClickCursorTests = struct {
p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = x, .row = y } });
}
+ test "a B1 sweep held past a pane's bottom scrolls the body and keeps its start" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 });
+ defer p.deinit();
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(std.testing.allocator);
+ for (0..60) |i| try text.print(std.testing.allocator, "line{d}\n", .{i});
+ const pane = try p.setTestFile(text.items);
+ var frame = std.heap.ArenaAllocator.init(p.gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane);
+ const y = p.bodyTop(pane, rect);
+ // The pane reaches the screen's bottom: its last row is the edge.
+ const below = rect.y + rect.h - 1;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = y + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 2, .row = below } });
+ for (0..20) |_| p.update(.tick);
+ try std.testing.expect(pane.scroll() >= 20);
+ _ = try p.render(frame.allocator()); // a frame between the ticks and the release
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 2, .row = below } });
+ const got = pardes.edit.currentSelText(p, pane).?;
+ try std.testing.expect(std.mem.startsWith(u8, got, "line1\nline2\n"));
+ try std.testing.expect(std.mem.count(u8, got, "\n") > 20);
+ }
+
test "everything a B1 chord does while B1 is down is one undo step" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 });
defer p.deinit();