diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 19:20:20 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | b08322345b33abcee114c05c85fa4c46c10f4701 (patch) | |
| tree | 645cbacce7ef1d5c7abd71f0b76a3c72e763835d | |
| parent | 8429e7bd8aff1ae0c71d3b1a7a6b9f01ed52e691 (diff) | |
| download | pardes-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]>
| -rw-r--r-- | docs/selections.md | 14 | ||||
| -rw-r--r-- | src/edit.zig | 117 | ||||
| -rw-r--r-- | src/mouse.zig | 32 | ||||
| -rw-r--r-- | src/pardes.zig | 3 | ||||
| -rw-r--r-- | test/panes.zig | 37 |
5 files changed, 203 insertions, 0 deletions
diff --git a/docs/selections.md b/docs/selections.md index 5cc1df1a..4e26f5ab 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -121,3 +121,17 @@ the replay. line is its own haystack, so `^`/`$` hold at every line and a class like `\s` does not reach the newline unless the pattern names `\n`. - Undo restores only the primary (above). + +## The mouse + +A B1, B2 or B3 sweep selects a stream, as acme's does (libframe +frselect.c:115): from the press to the end of its line, the lines between +whole, the last from its start to the release, the character under the +pointer included. A plain B1 click puts the caret on its cell. A second +plain click at the same cell within half a second is acme's double-click +(plan9port acme text.c:1407, textdoubleclick): just after `{ [ ( < «` or +just before their closers it selects up to the match, nested pairs +counted; at a line's start or end the whole line; just inside `' " \`` +the quoted text; anywhere else the word (letters, digits, `_` and any +non-ASCII character). It works in bodies, tags and a terminal in normal +mode; a terminal whose program has the tty gets its own clicks. 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 {}, diff --git a/test/panes.zig b/test/panes.zig index 233e7627..166e12c9 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -1057,6 +1057,43 @@ const ClickCursorTests = struct { p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = x, .row = y } }); } + test "a double-click selects a word, a line, a bracketed or quoted text, as acme's" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("call foo_bar(a, b)\nsay \"hi there\"\n"); + 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); + const Want = struct { col: u16, row: u16, text: []const u8 }; + for ([_]Want{ + .{ .col = 6, .row = 0, .text = "foo_bar" }, + .{ .col = 13, .row = 0, .text = "a, b" }, + .{ .col = 0, .row = 0, .text = "call foo_bar(a, b)\n" }, + .{ .col = 5, .row = 1, .text = "hi there" }, + }) |w| { + click(p, .left, x + w.col, y + w.row); + click(p, .left, x + w.col, y + w.row); + try std.testing.expectEqualStrings(w.text, pardes.edit.currentSelText(p, pane).?); + // A third click is a plain click again: the caret, no selection. + click(p, .left, x + w.col, y + w.row); + try std.testing.expect(!pane.body.vsel.active); + } + // In the tag too. + const tag_text = try pardes.tagline.tagText(p, frame.allocator(), pane); + const del: u16 = @intCast(std.mem.indexOf(u8, tag_text, "Del").? + 1); + const tag_x = rect.x + pardes.TAG_TEXT_INSET + del; + click(p, .left, tag_x, p.tagTop(pane, rect)); + click(p, .left, tag_x, p.tagTop(pane, rect)); + try std.testing.expect(pane.tag.vsel.active); + const tag_now = try pardes.tagline.tagText(p, frame.allocator(), pane); + const a = modal.offsetAt(tag_now, .{ .row = @intCast(pane.tag.vsel.row), .col = @intCast(pane.tag.vsel.col) }); + const h = modal.offsetAt(tag_now, .{ .row = @intCast(pane.tag.cur_row), .col = @intCast(pane.tag.cur_col) }); + try std.testing.expectEqualStrings("Del", tag_now[a .. h + 1]); + } + test "click cursor and insertion agree beyond line and file ends" { for ([_]bool{ false, true }) |already_insert| { for ([_]bool{ false, true }) |bottom_tag| { |
