summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 19:20:20 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitb08322345b33abcee114c05c85fa4c46c10f4701 (patch)
tree645cbacce7ef1d5c7abd71f0b76a3c72e763835d /src
parent8429e7bd8aff1ae0c71d3b1a7a6b9f01ed52e691 (diff)
downloadpardes-b08322345b33abcee114c05c85fa4c46c10f4701.tar.gz
pardes-b08322345b33abcee114c05c85fa4c46c10f4701.zip
A double-click selects as acme's does: a word, a line, a bracketed or quoted text
A second B1 click at the same cell selected nothing more. Now, as acme's textdoubleclick (plan9port text.c:1407), it selects up to the match of a bracket just before or after the point, the whole line at a line's start or end, the quoted text inside ' " `, or else the word, in bodies, tags and terminals in normal mode. A plain click still puts the caret on its cell. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/edit.zig117
-rw-r--r--src/mouse.zig32
-rw-r--r--src/pardes.zig3
3 files changed, 152 insertions, 0 deletions
diff --git a/src/edit.zig b/src/edit.zig
index 3dfe2f20..8d3a746e 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -2227,3 +2227,120 @@ test "only the SPC clipboard commands cross to the system clipboard" {
p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expect(p.clip_pending == null);
}
+
+/// acme's double-click (plan9port acme text.c:1407, textdoubleclick): from
+/// the point `q` (before the character clicked), the text up to the match
+/// of a bracket just before it or just after it (text.c:1386, `{[(<«` and
+/// their closers), the whole line at a line's start or end, the quoted text
+/// next to `' " \``, else the word, acme's isalnum (util.c:328) run.
+pub fn doubleClickRange(text: []const u8, q_in: usize) [2]usize {
+ const sets = [_][2][]const u21{
+ .{ &.{ '{', '[', '(', '<', 0xab }, &.{ '}', ']', ')', '>', 0xbb } },
+ .{ &.{'\n'}, &.{'\n'} },
+ .{ &.{ '\'', '"', '`' }, &.{ '\'', '"', '`' } },
+ };
+ const q = @min(q_in, text.len);
+ for (sets) |set| {
+ const left, const right = set;
+ // the character to the left, looking right
+ const before = if (q == 0) .{ @as(u21, '\n'), @as(usize, 0) } else runeBefore(text, q);
+ if (std.mem.indexOfScalar(u21, left, before[0])) |k| {
+ var at = q;
+ if (clickMatch(text, before[0], right[k], true, &at)) {
+ const closer = if (before[0] == '\n') 0 else runeLen(right[k]);
+ return .{ q, at - closer };
+ }
+ return .{ q, q };
+ }
+ // the character to the right, looking left
+ const after = if (q == text.len) @as(u21, '\n') else runeAt(text, q);
+ if (std.mem.indexOfScalar(u21, right, after)) |k| {
+ var at = q;
+ if (clickMatch(text, after, left[k], false, &at)) {
+ const q1 = q + @as(usize, @intFromBool(q < text.len and after == '\n'));
+ var q0 = at;
+ if (after != '\n' or at != 0 or (text.len > 0 and text[0] == '\n')) q0 += runeLen(left[k]);
+ return .{ @min(q0, q1), q1 };
+ }
+ return .{ q, q };
+ }
+ }
+ var lo = q;
+ var hi = q;
+ while (hi < text.len and isAlnum(runeAt(text, hi))) hi += std.unicode.utf8ByteSequenceLength(text[hi]) catch 1;
+ while (lo > 0 and isAlnum(runeBefore(text, lo)[0])) lo = runeBefore(text, lo)[1];
+ return .{ lo, hi };
+}
+
+/// acme's textclickmatch (text.c:1457): the nesting match of `cl` by `cr`
+/// from `q`, leaving `q` just past it (forward) or on it (back).
+fn clickMatch(text: []const u8, cl: u21, cr: u21, forward: bool, q: *usize) bool {
+ var nest: usize = 1;
+ while (true) {
+ var c: u21 = undefined;
+ if (forward) {
+ if (q.* >= text.len) break;
+ c = runeAt(text, q.*);
+ q.* += std.unicode.utf8ByteSequenceLength(text[q.*]) catch 1;
+ } else {
+ if (q.* == 0) break;
+ const b = runeBefore(text, q.*);
+ q.* = b[1];
+ c = b[0];
+ }
+ if (c == cr) {
+ nest -= 1;
+ if (nest == 0) return true;
+ } else if (c == cl) nest += 1;
+ }
+ return cl == '\n' and nest == 1;
+}
+
+fn runeLen(c: u21) usize {
+ return std.unicode.utf8CodepointSequenceLength(c) catch 1;
+}
+
+fn runeAt(text: []const u8, at: usize) u21 {
+ const n = std.unicode.utf8ByteSequenceLength(text[at]) catch return text[at];
+ if (at + n > text.len) return text[at];
+ return std.unicode.utf8Decode(text[at..][0..n]) catch text[at];
+}
+
+/// The rune ending at `at`, and where it starts.
+fn runeBefore(text: []const u8, at: usize) struct { u21, usize } {
+ var s = at - 1;
+ while (s > 0 and text[s] & 0xC0 == 0x80 and at - s < 4) s -= 1;
+ return .{ runeAt(text, s), s };
+}
+
+fn isAlnum(c: u21) bool {
+ if (c <= ' ') return false;
+ if (c >= 0x7F and c <= 0xA0) return false;
+ if (c < 0x80 and std.mem.indexOfScalar(u8, "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~", @intCast(c)) != null) return false;
+ return true;
+}
+
+test "double-click selects as acme's does" {
+ const t = "fn foo_bar(a, b) {\n x\n}\n";
+ const Case = struct { q: usize, want: []const u8 };
+ for ([_]Case{
+ .{ .q = 4, .want = "foo_bar" }, // in a word
+ .{ .q = 11, .want = "a, b" }, // just after (
+ .{ .q = 15, .want = "a, b" }, // just before )
+ .{ .q = 18, .want = "\n x\n" }, // just after {
+ .{ .q = 0, .want = "fn foo_bar(a, b) {\n" }, // a line's start
+ }) |c| {
+ const r = doubleClickRange(t, c.q);
+ try std.testing.expectEqualStrings(c.want, t[r[0]..r[1]]);
+ }
+ const quoted = "say \"hello there\" now";
+ const r = doubleClickRange(quoted, 5);
+ try std.testing.expectEqualStrings("hello there", quoted[r[0]..r[1]]);
+ const french = "«oui»";
+ const f = doubleClickRange(french, 2);
+ try std.testing.expectEqualStrings("oui", french[f[0]..f[1]]);
+ // a line's end: the whole line, its newline included
+ const lines = "one\ntwo\n";
+ const e = doubleClickRange(lines, 7);
+ try std.testing.expectEqualStrings("two\n", lines[e[0]..e[1]]);
+}
diff --git a/src/mouse.zig b/src/mouse.zig
index 2dcd80ed..ac3e206b 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -755,6 +755,36 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
edit.setYank(p, text);
}
+/// acme's double-click (text.c:881 textselect, plan9port text.c:1407
+/// textdoubleclick): a second plain B1 click at the same cell within half a
+/// second selects what edit.doubleClickRange says, from the caret the click
+/// left. A plain click still puts the caret on its cell.
+fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, col: i32) void {
+ const now = clockMs();
+ const prev = p.last_click;
+ p.last_click = .{ .id = id, .on_tag = on_tag, .row = row, .col = col, .ms = now };
+ const c = prev orelse return;
+ if (c.id != id or c.on_tag != on_tag or c.row != row or c.col != col or now - c.ms > 500) return;
+ p.last_click = null;
+ const text = edit.flatSurface(p, t) catch return;
+ const q = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const r = edit.doubleClickRange(text, q);
+ if (r[1] <= r[0]) return;
+ const a = modal.positionAt(text, r[0]);
+ const h = modal.positionAt(text, modal.prevGrapheme(text, r[1]));
+ t.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ t.cur_row = @intCast(h.row);
+ t.cur_col = @intCast(h.col);
+ t.cur_pinned = true;
+}
+
+fn clockMs() i64 {
+ if (comptime !pardes.hosted) return 0;
+ var ts: std.c.timespec = undefined;
+ if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
+}
+
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
// A different button's release belongs to a chord or another host
// stream; only the button which began this gesture may end it.
@@ -838,6 +868,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
} else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| {
pane.tag.cur_row = @intCast(cursor.row);
pane.tag.cur_col = @intCast(cursor.col);
+ if (!dragged) doubleClick(p, s.id, &pane.tag, true, sl.r1, sl.c1);
}
return;
}
@@ -863,6 +894,7 @@ 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);
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
} else {
diff --git a/src/pardes.zig b/src/pardes.zig
index cebd851b..60d0f51b 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4115,6 +4115,9 @@ pub const Pardes = struct {
pointer_tag_hit: ?TagHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
+ /// The last plain B1 click, for acme's double-click (text.c:881,
+ /// textselect: a second click at the same point within 500 ms).
+ last_click: ?struct { id: usize, on_tag: bool, row: i32, col: i32, ms: i64 } = null,
look_hover_wait: ?look.LookHoverWait = null,
look_hover_preview: ?look.LookHoverPreview = null,
pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},