diff options
Diffstat (limited to 'src/edit.zig')
| -rw-r--r-- | src/edit.zig | 214 |
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]]); +} |
