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 /src/edit.zig | |
| 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]>
Diffstat (limited to 'src/edit.zig')
| -rw-r--r-- | src/edit.zig | 117 |
1 files changed, 117 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]]); +} |
