summaryrefslogtreecommitdiff
path: root/src/edit.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/edit.zig')
-rw-r--r--src/edit.zig214
1 files changed, 182 insertions, 32 deletions
diff --git a/src/edit.zig b/src/edit.zig
index 84bbb292..a194ef48 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -202,7 +202,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
/// (the tag's lines first, the ones it shows)
pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- const tag = try tagline.tagText(p, arena, pane);
+ const tag = (try tagline.shownTag(p, arena, pane)).text;
const from = modal.lineStartOffset(tag, pane.tag_top);
const cut = modal.lineStartOffset(tag, pane.tag_top + pane.tag_rows);
const shown = if (cut < tag.len) tag[from .. cut - 1] else tag[from..];
@@ -234,10 +234,13 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
errdefer p.gpa.free(rows);
const raw_text = if (raw) try p.gpa.dupe(u8, try selectionText(p, pane, gesture)) else null;
const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
- const left = @max(0, @min(gesture.c0, gesture.c1));
- const right = @max(0, @max(gesture.c0, gesture.c1));
for (rows, 0..) |*row, index| {
const visible = first + @as(i32, @intCast(index));
+ // A stream: only the first row starts where the press was; the
+ // others start after the line numbers, and all but the last run on.
+ const cols = sweepCols(gesture, visible + @as(i32, pane.tag_rows)).?;
+ const left: i32 = if (cols.first) cols.lo else prefix;
+ const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2;
var source = pane.wrapAt(visible);
if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 };
const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line);
@@ -250,7 +253,8 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
row.* = .{
.row = source.line,
.lo = lo,
- .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)),
+ .hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)),
+ .to_edge = cols.hi == null,
.raw_terminal = raw,
.prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0,
};
@@ -294,40 +298,55 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 {
}
return result;
}
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
- const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
const text = try paneText(p, pane);
- var total: usize = 0;
- var selected: usize = 0;
- var count_it = std.mem.splitAny(u8, text, "\n");
- var count_row: i32 = 0;
- while (count_it.next()) |line| : (count_row += 1) {
- if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len));
- total += b1 - b0 + @intFromBool(selected > 0);
- selected += 1;
- }
- const out = try arena.alloc(u8, total);
- var at: usize = 0;
+ const shown = try tagline.shownTag(p, arena, pane);
+ var out: std.ArrayList(u8) = .empty;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
var first = true;
while (it.next()) |line| : (v += 1) {
- if (v < r0 or v > r1) continue;
- if (!first) {
- out[at] = '\n';
- at += 1;
- }
+ const cols = sweepCols(sl, v) orelse continue;
+ // A row the tag's wrapping began carries on its text: no newline.
+ const soft = v > 0 and v < pane.tag_rows and std.mem.indexOfScalar(
+ usize,
+ shown.soft,
+ modal.lineStartOffset(shown.text, @as(usize, @intCast(v)) + pane.tag_top) -| 1,
+ ) != null;
+ if (!first and !soft) try out.append(arena, '\n');
first = false;
- const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len));
- @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
- at += b1 - b0;
+ // Past the first row a body row starts after its line numbers.
+ const gutter: i32 = if (pane.file != null and v >= pane.tag_rows) panes.File.gutterWidth(pane) else 0;
+ const lo: usize = @intCast(if (cols.first) cols.lo else gutter);
+ const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, lo), line.len);
+ const b1 = if (cols.hi) |hi|
+ @max(b0, modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, @intCast(hi)), line.len)))
+ else
+ line.len;
+ try out.appendSlice(arena, line[b0..b1]);
}
- return out;
+ return out.items;
+}
+
+/// A sweep's columns on `row`, in its Sel space: acme's stream, not a
+/// rectangle (libframe/frselect.c:115-140, frselectpaint): the first row
+/// from the press to its end, the rows between whole, the last from its
+/// start to the release. `hi` null runs to the end of the row.
+pub const SweepCols = struct { lo: i32, hi: ?i32, first: bool };
+
+pub fn sweepCols(sl: Pane.Sel, row: i32) ?SweepCols {
+ const start, const end = sweepEnds(sl);
+ if (row < start[0] or row > end[0]) return null;
+ return .{
+ .lo = if (row == start[0]) @max(0, start[1]) else 0,
+ .hi = if (row == end[0]) @max(0, end[1]) else null,
+ .first = row == start[0],
+ };
+}
+
+/// The sweep's two ends in reading order, each (row, col).
+pub fn sweepEnds(sl: Pane.Sel) struct { [2]i32, [2]i32 } {
+ const fwd = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1);
+ return if (fwd) .{ .{ sl.r0, sl.c0 }, .{ sl.r1, sl.c1 } } else .{ .{ sl.r1, sl.c1 }, .{ sl.r0, sl.c0 } };
}
pub fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool {
@@ -738,6 +757,19 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
Key.left, Key.right, Key.up, Key.down => {
const lines = paneCursorLines(p, t) catch return;
const cur0 = t.toModalCursor();
+ // In a pane's tag, Up on its first line collapses it and Down
+ // on its last expands it, where they would move nothing; acme's
+ // arrows in a tag always do (plan9port text.c:776, Tagdown/Tagup).
+ if (t.what == .tag) if (t.pane()) |pane| {
+ if (key.cp == Key.up and cur0.row == 0) {
+ pane.tag_expanded = false;
+ return;
+ }
+ if (key.cp == Key.down and cur0.row + 1 >= lines.len) {
+ pane.tag_expanded = true;
+ return;
+ }
+ };
const nc = switch (key.cp) {
Key.left => modal.charLeft(lines, cur0),
Key.right => modal.charRight(lines, cur0),
@@ -2000,7 +2032,8 @@ pub fn pushUndo(p: *Pardes, t: *Text) void {
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
switch (t.what) {
- .body => {},
+ // A B1 chord took its one undo point when it began (mouse.zig).
+ .body => if (p.chord_hold) return,
.tag, .column, .workspace => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null),
// a prompt's answer is typed once and gone; it keeps no history
.input => return,
@@ -2215,3 +2248,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]]);
+}