summaryrefslogtreecommitdiff
path: root/src/edit.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/edit.zig')
-rw-r--r--src/edit.zig629
1 files changed, 522 insertions, 107 deletions
diff --git a/src/edit.zig b/src/edit.zig
index 3cbbc28c..84bbb292 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -13,11 +13,12 @@ const body_layer = @import("body_layer.zig");
const config = @import("config.zig");
const pdf_enabled = panes.Pdf.enabled;
const lsp = @import("lsp/lsp.zig");
+const syntax = @import("syntax.zig");
const platform = pardes.platform;
const Pane = panes.Pane;
const Text = panes.Text;
const tag_layer = @import("tag_layer.zig");
-const TagHit = tag_layer.Hit;
+const TagHit = pardes.TagHit;
const BOX_H = pardes.BOX_H;
const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
const Mouse = pardes.Mouse;
@@ -26,23 +27,75 @@ const hit = pardes.hit;
const sel_slot = pardes.sel_slot;
const Effect = pardes.Effect;
const Pardes = pardes.Pardes;
+const Registers = pardes.Registers;
+/// Write `text` as this range's value of the register the running command
+/// names (`"<reg>`), the default one otherwise. `_` swallows it, and the
+/// registers computed from the editor (`#`, `.`, `%`) cannot be written.
+/// `+` and `*` are the system clipboard: the value is sent there too.
pub fn setYank(p: *Pardes, text: []const u8) void {
- if (p.multi_on and !p.multi_first) {
- const old = p.yank orelse "";
- const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n";
- const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return;
- if (p.yank) |y| p.gpa.free(y);
- p.yank = joined;
- return;
+ var name: u21 = if (p.register != 0) p.register else Registers.default;
+ switch (name) {
+ '_', '#', '.', '%' => return,
+ '*' => name = '+',
+ else => {},
}
- if (p.yank) |y| p.gpa.free(y);
- p.yank = p.gpa.dupe(u8, text) catch null;
+ const one = !p.multi_on;
+ p.registers.put(p.gpa, name, text, if (one) 0 else p.multi_index, if (one) 1 else p.multi_count, one or p.multi_first);
+ if (name == '+' and (one or p.multi_first)) p.emit(.{ .set_clipboard = {} });
}
pub fn setClipboard(p: *Pardes, text: []const u8) void {
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
setYank(p, text);
- if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} });
+}
+
+/// Value `i` of register `name` as the command at range `i` reads it, the
+/// computed registers included: `#` is the range's number from 1, `.` its
+/// text, `%` the file's name, `_` nothing. Scratch- or register-owned.
+pub fn registerValue(p: *Pardes, t: *Text, name: u21) ?[]const u8 {
+ const i = if (p.multi_on) p.multi_index else 0;
+ return switch (name) {
+ '_' => null,
+ '#' => std.fmt.allocPrint(p.scratch.allocator(), "{d}", .{i + 1}) catch null,
+ '.' => selText(p, t),
+ '%' => if (t.what == .body) if (t.pane()) |pane| (if (pane.file) |f| f.path else null) else null else null,
+ else => p.registers.value(name, i),
+ };
+}
+
+/// the text of the range under the cursor: the selection, or the cursor's
+/// own character
+fn selText(p: *Pardes, t: *Text) []const u8 {
+ if (t.msel.active) return yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1));
+ const save = t.vsel;
+ defer t.vsel = save;
+ if (!t.vsel.active) t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false };
+ return vselText(p, t);
+}
+
+/// `p` / `P` / `R` from the register the command names: this range's value
+/// of it. The clipboard's is asked of the shell and pasted when it answers,
+/// once, at the primary.
+pub fn pasteRegister(p: *Pardes, t: *Text, how: enum { after, before, replace }, count: usize) void {
+ const name: u21 = if (p.register != 0) p.register else Registers.default;
+ if (name == '+' or name == '*') {
+ if (!normal.multiOnce(p)) return;
+ const pane = t.pane() orelse return;
+ return clipRequest(p, p.paneIdOf(pane) orelse return, switch (how) {
+ .after => .after,
+ .before => .before,
+ .replace => .replace,
+ });
+ }
+ const y = registerValue(p, t, name) orelse return;
+ switch (how) {
+ .after => pasteText(p, t, y, false, count),
+ .before => pasteText(p, t, y, true, count),
+ .replace => replaceWithText(p, t, y),
+ }
}
/// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers.
@@ -96,7 +149,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
n += 1;
};
if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] });
- if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split);
+ if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode);
return;
}
if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes);
@@ -114,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void {
if (bytes.len == 0) return;
const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes };
if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return;
- const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel };
+ var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined;
+ const kept_n = t.nsel;
+ @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]);
t.refused = false;
if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1);
- if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ if (t.refused) {
+ t.cur_row, t.cur_col, t.vsel, t.msel = kept;
+ t.restoreSels(kept_sels[0..kept_n]);
+ }
}
pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
@@ -125,6 +184,10 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text);
return;
};
+ // helix's `"+y`: the clipboard register, and only it
+ const named = p.register;
+ p.register = '+';
+ defer p.register = named;
if (pane.body.nsel > 0 and !main_only) {
normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } });
} else {
@@ -133,7 +196,6 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
normalYank(p, &pane.body);
pane.body.nsel = others;
}
- p.emit(.{ .set_clipboard = {} });
}
/// tag + '\n' + body: the full selectable pane text, a row to a screen row
@@ -286,30 +348,24 @@ pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
pub fn exitInsert(p: *Pardes, t: *Text) void {
t.mode = .normal;
+ t.reg_wait = false;
t.msel.active = false;
t.normal.clear();
- if (t.append_at) |aa| {
- t.append_at = null;
- const text = flatSurface(p, t) catch return;
- const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
- const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
- const back = @max(a_off, modal.prevGrapheme(text, gap));
- const bc = modal.positionAt(text, back);
- t.cur_row = @intCast(bc.row);
- t.cur_col = @intCast(bc.col);
- t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
- t.cur_pinned = true;
- t.show();
- for (t.sels[0..t.nsel]) |*s| {
- const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
- const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
- const bp = modal.positionAt(text, b2);
- s.row = @intCast(bp.row);
- s.col = @intCast(bp.col);
- s.arow = s.row;
- s.acol = s.col;
- }
+ if (!t.restore_cursor) return;
+ t.restore_cursor = false;
+ // helix enter_normal_mode: every range, from its start to the char
+ // before its end when it points forward, else to its end
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ const text = flatSurface(p, t) catch return;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, 0, &rs);
+ for (rs[0..got.n]) |*r| {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to;
+ r.* = .{ .anchor = from, .head = head };
}
+ t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl);
}
pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
@@ -415,7 +471,9 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void {
if (t.what == .tag) {
// A tag is edited as it is shown, but only what follows its
// prefix is its own: an edit that changed the prefix is refused.
- defer p.gpa.free(new);
+ // The shown text stays alive until the next such edit, since every
+ // caller reads the new text back, as a body's is kept.
+ defer retireEditText(p, new);
const pane = t.pane() orelse return;
const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return;
if (!std.mem.startsWith(u8, new, prefix)) {
@@ -432,11 +490,25 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void {
t.own = new;
return;
}
- const pane = t.pane() orelse return p.gpa.free(new);
+ const pane = t.pane() orelse return retireEditText(p, new);
+ // what changed ended where the texts agree again from their ends
+ const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 };
+ const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len);
+ var back: usize = 0;
+ while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1;
+ const end = modal.positionAt(new, new.len - back);
+ t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) };
if (pane.file) |*f| return panes.File.setContent(p, f, new);
panes.Terminal.setEditText(p, pane, new);
}
+/// Take a text an edit made that nothing keeps, holding it until the next
+/// one: the edit's caller still reads it to place the cursor.
+pub fn retireEditText(p: *Pardes, new: []u8) void {
+ if (p.retired_edit) |old| p.gpa.free(old);
+ p.retired_edit = new;
+}
+
pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
/// Files insert only inside existing rows and at grapheme boundaries.
@@ -462,7 +534,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
// `u` undoes the whole session and restores what was selected
pushUndo(p, t);
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.sticky_col = -1;
const text = flatSurface(p, t) catch {
t.mode = .insert;
@@ -488,12 +560,25 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
const lo_col = if (b) |bb| bb.lo_col else t.cur_col;
const hi_row = if (b) |bb| bb.hi_row else t.cur_row;
const hi_col = if (b) |bb| bb.hi_col else t.cur_col;
- t.append_at = .{ .row = lo_row, .col = lo_col };
- const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }));
- const gc = modal.positionAt(text, gap);
+ t.restore_cursor = true;
+ const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) });
+ const gap = modal.nextGrapheme(text, hi);
+ // helix append_mode: a range that reaches the end of the text
+ // gets a newline after it to append before (a terminal's text
+ // is not the editor's to grow)
+ const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false;
+ var now = text;
+ if (hi < text.len and gap >= text.len and !terminal) {
+ const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return;
+ setEditText(p, t, grown);
+ now = grown; // what setEditText keeps; `text` may be gone
+ }
+ const gc = modal.positionAt(now, gap);
t.cur_row = @intCast(gc.row);
t.cur_col = @intCast(gc.col);
- t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false };
+ // helix append_mode: the range reaches one past its end, which
+ // is where typing goes; a bare cursor becomes a range too
+ t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false };
t.cur_pinned = true;
},
.line_start => {
@@ -565,6 +650,87 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace });
if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter });
if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete });
+ // Ctrl-r <reg>: type this range's value of the register (helix
+ // insert_register)
+ if (t.reg_wait) {
+ t.reg_wait = false;
+ if (key.ctrl or key.alt or key.cp >= 0xF0000) return;
+ const y = registerValue(p, t, key.cp) orelse return;
+ if (y.len > 0) insertKey(p, t, .{ .cp = key.cp, .text = y });
+ return;
+ }
+ if (hit(key, config.insert_register)) {
+ t.reg_wait = true;
+ return;
+ }
+ // helix commit_undo_checkpoint: `u` after the session goes back to here
+ if (hit(key, config.insert_checkpoint)) return pushUndo(p, t);
+ // helix maps every range through each insert-mode edit (Range::map):
+ // the edits below work on the bare cursor, and the range the cursor
+ // heads is carried through what they did to the text afterwards
+ if (!t.vsel.active) return insertEdit(p, t, key);
+ const old = flatSurface(p, t) catch return insertEdit(p, t, key);
+ const r = t.primaryRange(old, 0);
+ const c = modal.selectionCursor(old, r);
+ const old_len = old.len;
+ const explicit = t.vsel.explicit;
+ t.vsel.active = false;
+ insertEdit(p, t, key);
+ if (t.mode != .insert) return;
+ const new = flatSurface(p, t) catch return;
+ const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ // every insert-mode edit happens at the cursor: an insertion there, or
+ // a deletion that ends there (backward) or starts there (forward)
+ if (key.cp == Key.enter) {
+ // helix insert_newline: an appending range stretches by what the
+ // cursor moved, any other slides by it
+ const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c));
+ const head: usize = @intCast(@as(isize, @intCast(r.head)) + d);
+ const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d);
+ return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit);
+ }
+ var at: usize = c;
+ var del: usize = 0;
+ var ins: usize = 0;
+ if (new.len > old_len and c2 == c + (new.len - old_len)) {
+ ins = new.len - old_len;
+ } else if (new.len < old_len and c2 + (old_len - new.len) == c) {
+ at = c2;
+ del = old_len - new.len;
+ } else if (new.len < old_len and c2 == c) {
+ del = old_len - new.len;
+ } else if (new.len != old_len or c2 != c) {
+ return; // a motion: the range collapses onto the cursor (helix)
+ }
+ // helix Assoc: the head sticks after an insertion at it when the range
+ // points backward or is empty, the anchor when it points forward
+ const fwd = r.anchor < r.head;
+ setInsertRange(t, new, .{
+ .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head),
+ .head = mapThroughEdit(r.head, at, del, ins, fwd),
+ }, explicit);
+}
+
+fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void {
+ const cells = Text.rangeCells(new, mapped);
+ const cc = modal.positionAt(new, cells.cur);
+ const ac = modal.positionAt(new, cells.anc);
+ t.cur_row = @intCast(cc.row);
+ t.cur_col = @intCast(cc.col);
+ t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit };
+}
+
+/// A position through one edit at `at` that deleted `del` bytes and put
+/// `ins` in (one of the two is 0). A position exactly at an insertion stays
+/// before it when `before` is set, else moves past it.
+fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize {
+ if (pos < at) return pos;
+ if (del > 0) return if (pos < at + del) at else pos - del;
+ if (pos == at and before) return pos;
+ return pos + ins;
+}
+
+fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
t.vsel.active = false;
if (!t.cur_pinned) pinPaneCursor(p, t);
_ = clampFileCursor(t);
@@ -679,12 +845,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
switch (key.cp) {
Key.enter => {
const line = modal.lineSlice(text, c.row);
+ const before = line[0..@min(c.col, line.len)];
+ // helix insert_newline: after nothing but blanks, the line
+ // itself moves down, blanks and all; else the blanks before the
+ // cursor go and the new line takes the indent
+ if (std.mem.indexOfNone(u8, before, " \t") == null) {
+ const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return;
+ setEditText(p, t, new);
+ t.cur_row += 1;
+ t.cur_pinned = true;
+ t.show();
+ return;
+ }
const indent = modal.newlineIndentWidth(line, c.col);
const arena = p.scratch.allocator();
const block = arena.alloc(u8, 1 + indent) catch return;
block[0] = '\n';
@memset(block[1..], ' ');
- const new = modal.insertAt(p.gpa, text, c, block) catch return;
+ // a tag keeps its blanks: they part its words, and the last
+ // may be its read-only prefix's
+ const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col;
+ const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return;
+ defer p.gpa.free(cut);
+ const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return;
setEditText(p, t, new);
t.cur_row += 1;
t.cur_col = @intCast(indent);
@@ -694,8 +877,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
Key.backspace => {
if (t.cur_col > 0) {
const line = modal.lineSlice(text, c.row);
- const prev = modal.prevGrapheme(line, c.col);
- const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return;
+ var prev = modal.prevGrapheme(line, c.col);
+ // in leading blanks, back to the previous indent stop (helix
+ // dedent): a whole unit when on one, else the part past it
+ const lead = line[0..@min(c.col, line.len)];
+ if (std.mem.indexOfNone(u8, lead, " \t") == null and prev < line.len and line[prev] != '\t') {
+ var width: usize = 0;
+ for (lead) |ch| width += if (ch == '\t') modal.INDENT_W else 1;
+ var drop = width % modal.INDENT_W;
+ if (drop == 0) drop = modal.INDENT_W;
+ prev = c.col;
+ while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1;
+ }
+ const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return;
setEditText(p, t, new);
t.cur_col = @intCast(prev);
t.cur_pinned = true;
@@ -742,6 +936,17 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
p.lspRequest(p.active, .completion, "");
if (p.lsp_seq != seq) return;
}
+ // helix smart_tab: after text, Tab leaves the syntax node the
+ // cursor is in; only where the file has a grammar, since the
+ // reference pins smart tab off, which a file without one agrees
+ // with. Shift-Tab always types the tab.
+ const after_text = std.mem.indexOfNone(u8, ln[0..@min(c.col, ln.len)], " \t") != null;
+ if (!key.shift and after_text and t.what == .body) smart: {
+ const pane = t.pane() orelse break :smart;
+ const f = pane.file orelse break :smart;
+ if (!syntax.supportsPath(f.path)) break :smart;
+ return normal.nodeSelect(p, pane, t, .parent_end);
+ }
insertTab(p, t);
},
else => {},
@@ -1121,31 +1326,45 @@ pub fn normalReplaceChar(p: *Pardes, t: *Text, ch: u21) void {
/// `R`: replace the selection (or the cursor char) with the DEFAULT
/// register. `SPC R` is the same verb over the system clipboard.
-pub fn normalReplaceYank(p: *Pardes, t: *Text) void {
- replaceWithText(p, t, p.yank orelse return);
+/// The acme chords' replace: the default register as one text.
+pub fn chordReplace(p: *Pardes, t: *Text) void {
+ replaceWithText(p, t, p.registers.text(p.gpa, Registers.default) orelse return);
}
-/// The pasted text becomes the selection, head on its last char (which for
-/// a trailing-newline `y` is the '\n' cell of the last full line).
+/// The pasted text becomes the selection, in the direction the replaced one
+/// had: a forward head lands on its last char (which for a trailing-newline
+/// `y` is the '\n' cell of the last full line), a backward one on its first.
fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void {
if (y.len == 0) return;
t.select = false;
const eb = editTextEol(p, t, selRows(t)) orelse return;
const r = selRange(t, eb.text, eb.row0);
+ const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col));
pushUndo(p, t);
const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return;
setEditText(p, t, new);
t.msel.active = false;
- t.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
const end = modal.advanceBy(r.a, y);
+ var last_row: i32 = undefined;
+ var last_col: i32 = undefined;
if (end.col > 0) {
- t.cur_row = @as(i32, @intCast(end.row)) + eb.row0;
- t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
+ last_row = @as(i32, @intCast(end.row)) + eb.row0;
+ last_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
} else {
- // the yank ended in '\n': the cursor lands ON that newline
- t.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
- t.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len);
+ // the yank ended in '\n': its last cell is that newline
+ last_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
+ last_col = @intCast(modal.lineSlice(new, end.row -| 1).len);
}
+ const first_row = @as(i32, @intCast(r.a.row)) + eb.row0;
+ const first_col: i32 = @intCast(r.a.col);
+ t.vsel = .{
+ .active = modal.nextGrapheme(y, 0) < y.len,
+ .row = if (back) last_row else first_row,
+ .col = if (back) last_col else first_col,
+ .explicit = false,
+ };
+ t.cur_row = if (back) first_row else last_row;
+ t.cur_col = if (back) first_col else last_col;
t.cur_pinned = true;
t.sticky_col = -1;
t.show();
@@ -1201,53 +1420,213 @@ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 {
return out;
}
-pub fn normalJoin(p: *Pardes, t: *Text) void {
+/// `J` / `Alt-J`: join every range's lines with the next, in ONE edit
+/// (helix join_selections): the newline and the next line's leading blanks
+/// become one space, or nothing when that line is blank, and a line two
+/// ranges share is joined once. `Alt-J` then selects the spaces it put in.
+pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
// a join always eats the newline of its last line, so the buffer has
- // to reach one row PAST the selection
- const sr = selRows(t);
- const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return;
+ // to reach one row PAST every range
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
+ }
+ const eb = editText(p, t, b.lo_row, b.hi_row + 1, -1) orelse return;
const text = eb.text;
- const range = t.primaryRange(text, eb.row0);
- const span = Text.rangeLineSpan(text, range);
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
const nlines = modal.cursorLineCount(text);
- var end = span.end;
- if (span.start == end) end = @min(end + 1, nlines - 1);
- if (end <= span.start) return;
const arena = p.scratch.allocator();
- const chs = arena.alloc(TextChange, end - span.start) catch return;
+ const chs = arena.alloc(TextChange, nlines) catch return;
var chs_len: usize = 0;
- var l = span.start;
- while (l < end) : (l += 1) {
- const from = modal.lineEndOffset(text, l);
- var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
- while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
- const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " ";
- chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
- chs_len += 1;
+ var next: usize = 0; // lines below this one are joined already
+ for (rs[0..got.n]) |r| {
+ const span = Text.rangeLineSpan(text, r);
+ var end = span.end;
+ if (span.start == end) end = @min(end + 1, nlines - 1);
+ var l = @max(span.start, next);
+ while (l < end) : (l += 1) {
+ const from = modal.lineEndOffset(text, l);
+ var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1);
+ while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1;
+ const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " ";
+ chs[chs_len] = .{ .from = from, .to = to, .ins = sep };
+ chs_len += 1;
+ }
+ next = @max(next, end);
}
if (chs_len == 0) return;
pushUndo(p, t);
- const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) });
- const anc_off = if (t.vsel.active)
- modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.vsel.col)) })
- else
- cur_off;
const new = applyChanges(p, text, chs[0..chs_len]) catch return;
- const nc = mapThroughChanges(chs[0..chs_len], cur_off);
- const na = mapThroughChanges(chs[0..chs_len], anc_off);
+ // every range's cells ride through the edit; Alt-J replaces them with
+ // the spaces, when it put any in
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ var n: usize = 0;
+ var pri = got.pri;
+ if (select_space) {
+ var delta: i64 = 0;
+ for (chs[0..chs_len]) |ch| {
+ const at: usize = @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
+ if (ch.ins.len == 0 or n == cells.len) continue;
+ const pos = modal.positionAt(new, at);
+ cells[n] = .{ .row = @as(i32, @intCast(pos.row)) + eb.row0, .col = @intCast(pos.col), .arow = @as(i32, @intCast(pos.row)) + eb.row0, .acol = @intCast(pos.col) };
+ n += 1;
+ }
+ pri = 0;
+ }
+ if (n == 0) {
+ for (rs[0..got.n]) |r| {
+ const c = Text.rangeCells(text, r);
+ const cc = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.cur));
+ const ac = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.anc));
+ cells[n] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ n += 1;
+ }
+ pri = got.pri;
+ }
setEditText(p, t, new);
- const cc = modal.positionAt(new, nc);
- const ac = modal.positionAt(new, na);
- t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
- t.cur_col = @intCast(cc.col);
- if (t.vsel.active) {
- t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0;
- t.vsel.col = @intCast(ac.col);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, pri, expl);
+}
+
+/// `Alt-)` / `Alt-(`: rotate the TEXT of the selections forward / back by
+/// one range (count ranges), in one edit (helix reorder_selection_contents).
+/// Each range comes back over the text it now holds, in its own direction,
+/// and the primary moves with the text it had.
+pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
}
- t.msel.active = false;
- t.cur_pinned = true;
- t.sticky_col = -1;
- t.show();
+ const eb = editTextEol(p, t, b) orelse return;
+ const text = eb.text;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
+ const n = got.n;
+ if (n < 2) return;
+ const by = @min(cnt, n);
+ var chs: [Text.max_selections]TextChange = undefined;
+ for (rs[0..n], 0..) |r, i| {
+ const src = rs[if (forward) (i + n - by) % n else (i + by) % n];
+ chs[i] = .{
+ .from = @min(r.anchor, r.head),
+ .to = @max(r.anchor, r.head),
+ .ins = text[@min(src.anchor, src.head)..@max(src.anchor, src.head)],
+ };
+ }
+ pushUndo(p, t);
+ const new = applyChanges(p, text, chs[0..n]) catch return;
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ var delta: i64 = 0;
+ for (chs[0..n], rs[0..n], 0..) |ch, r, i| {
+ const from: usize = @intCast(@as(i64, @intCast(ch.from)) + delta);
+ delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from));
+ const to = from + ch.ins.len;
+ const c = Text.rangeCells(new, if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to });
+ const cc = modal.positionAt(new, c.cur);
+ const ac = modal.positionAt(new, c.anc);
+ cells[i] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ }
+ setEditText(p, t, new);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl);
+}
+
+/// `&`: pad the selections into columns, in one edit (helix
+/// align_selections). The k-th range of every line is a column; spaces go in
+/// before each range so its head lands on the column's widest head, measured
+/// in display cells. A range over several lines aligns nothing.
+pub fn normalAlign(p: *Pardes, t: *Text) void {
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ var b = selRows(t);
+ for (t.sels[0..t.nsel]) |s| {
+ b.lo_row = @min(b.lo_row, @min(s.row, s.arow));
+ b.hi_row = @max(b.hi_row, @max(s.row, s.arow));
+ }
+ const eb = editTextEol(p, t, b) orelse return;
+ const text = eb.text;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, eb.row0, &rs);
+ const n = got.n;
+ var row: [Text.max_selections]usize = undefined;
+ var col: [Text.max_selections]usize = undefined;
+ var widths: [Text.max_selections]usize = @splat(0);
+ var line: usize = std.math.maxInt(usize);
+ var k: usize = 0;
+ var run: usize = 0;
+ for (rs[0..n], 0..) |r, i| {
+ row[i] = modal.lineAtOffset(text, r.head);
+ if (modal.lineAtOffset(text, r.anchor) != row[i]) return;
+ const ls = modal.lineStartOffset(text, row[i]);
+ col[i] = panes.File.rawDisplayCol(text[ls..modal.lineEndOffset(text, row[i])], r.head - ls);
+ if (row[i] != line) {
+ line = row[i];
+ k = 0;
+ run = 0;
+ }
+ widths[k] = @max(widths[k], col[i] - run);
+ run = col[i];
+ k += 1;
+ }
+ t.select = false; // helix exit_select_mode
+ // each column's head position, left to right
+ for (1..n) |i| widths[i] += widths[i - 1];
+ const arena = p.scratch.allocator();
+ var chs: [Text.max_selections]TextChange = undefined;
+ var m: usize = 0;
+ line = std.math.maxInt(usize);
+ for (rs[0..n], 0..) |r, i| {
+ if (row[i] != line) {
+ line = row[i];
+ k = 0;
+ run = 0;
+ }
+ const pad = widths[k] - col[i] - run;
+ k += 1;
+ run += pad;
+ if (pad == 0) continue;
+ const spaces = arena.alloc(u8, pad) catch return;
+ @memset(spaces, ' ');
+ const at = @min(r.anchor, r.head);
+ chs[m] = .{ .from = at, .to = at, .ins = spaces };
+ m += 1;
+ }
+ if (m == 0) return;
+ pushUndo(p, t);
+ const new = applyChanges(p, text, chs[0..m]) catch return;
+ var cells: [Text.max_selections]Text.SelRange = undefined;
+ for (rs[0..n], 0..) |r, i| {
+ const c = Text.rangeCells(text, r);
+ const cc = modal.positionAt(new, mapThroughChanges(chs[0..m], c.cur));
+ const ac = modal.positionAt(new, mapThroughChanges(chs[0..m], c.anc));
+ cells[i] = .{
+ .row = @as(i32, @intCast(cc.row)) + eb.row0,
+ .col = @intCast(cc.col),
+ .arow = @as(i32, @intCast(ac.row)) + eb.row0,
+ .acol = @intCast(ac.col),
+ };
+ }
+ setEditText(p, t, new);
+ const t2 = flatSurface(p, t) catch return;
+ for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col);
+ t.setRanges(t2, rs[0..n], &.{}, got.pri, expl);
}
pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void {
@@ -1439,9 +1818,12 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void {
/// `]Space` / `[Space`: add blank lines below/above, the cursor staying
/// on its text line (the selection rides along)
pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void {
- const eb = editText(p, t, t.cur_row, t.cur_row, -1) orelse return;
+ // above the selection's first line, below its last (helix line_range)
+ const sr = selRows(t);
+ const at = if (below) sr.hi_row else sr.lo_row;
+ const eb = editText(p, t, at, at, -1) orelse return;
pushUndo(p, t);
- const row: usize = @intCast(@max(0, t.cur_row - eb.row0));
+ const row: usize = @intCast(@max(0, at - eb.row0));
const arena = p.scratch.allocator();
const nl = arena.alloc(u8, cnt) catch return;
@memset(nl, '\n');
@@ -1449,10 +1831,14 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void {
const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return;
setEditText(p, t, new);
if (!below) {
- t.cur_row += @intCast(cnt); // the line moved down; stay on it
- if (t.vsel.active and t.vsel.row >= @as(i32, @intCast(row))) t.vsel.row += @intCast(cnt);
- } else if (t.vsel.active and t.vsel.row > @as(i32, @intCast(row))) {
- t.vsel.row += @intCast(cnt);
+ // the whole selection moved down; stay on it
+ const n: i32 = @intCast(cnt);
+ t.cur_row += n;
+ if (t.vsel.active) t.vsel.row += n;
+ if (t.msel.active) {
+ t.msel.r0 += n;
+ t.msel.r1 += n;
+ }
}
t.cur_pinned = true;
t.show();
@@ -1472,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } {
pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void {
const range = t.primaryRange(text, 0);
+ // helix's tree-sitter objects, from the grammar's textobjects query
+ const object: ?modal.Normal.Object = switch (obj) {
+ 'f' => .function,
+ 't' => .class,
+ 'a' => .parameter,
+ 'c' => .comment,
+ 'T' => .@"test",
+ 'e' => .entry,
+ 'x' => .xml_element,
+ else => null,
+ };
+ if (object) |o| {
+ if (t.what != .body) return;
+ const pane = t.pane() orelse return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = normal.fileTree(p, f) orelse return;
+ const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return;
+ return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false);
+ }
switch (obj) {
'w', 'W' => {
const r = modal.selectWord(text, range, around, obj == 'W');
@@ -1512,16 +1917,22 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void {
t.select = false;
const eb = editTextEol(p, t, selRows(t)) orelse return;
const r = selRange(t, eb.text, eb.row0);
+ // the wrapped range keeps the direction it had (helix with_direction)
+ const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col));
pushUndo(p, t);
const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col);
var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return;
setEditText(p, t, new);
new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
setEditText(p, t, new);
+ const open_row = @as(i32, @intCast(r.a.row)) + eb.row0;
+ const open_col: i32 = @intCast(r.a.col);
+ const close_row = @as(i32, @intCast(r.b.row)) + eb.row0;
+ const close_at: i32 = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
t.msel.active = false;
- t.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
- t.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0;
- t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
+ t.vsel = .{ .active = true, .row = if (back) close_row else open_row, .col = if (back) close_at else open_col, .explicit = false };
+ t.cur_row = if (back) open_row else close_row;
+ t.cur_col = if (back) open_col else close_at;
t.cur_pinned = true;
t.sticky_col = -1;
t.show();
@@ -1760,16 +2171,20 @@ test "only the SPC clipboard commands cross to the system clipboard" {
_ = drainedEffect(p, .set_clipboard);
p.update(.{ .key = .{ .cp = 'y' } });
- try std.testing.expect(p.yank != null and p.yank.?.len > 0);
+ const plain = p.registers.text(gpa, Registers.default) orelse return error.MissingYank;
+ try std.testing.expect(plain.len > 0);
try std.testing.expect(!drainedEffect(p, .set_clipboard));
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expect(!drainedEffect(p, .set_clipboard));
- // `SPC y` is the one that does, and it puts the SAME text there that `y`
- // put in the register — it is the ordinary yank path plus the mirror.
+ // `SPC y` is the one that does: helix's `"+y`, the ordinary yank into
+ // the clipboard register instead of the default one.
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'y' } });
- const yanked = p.yank orelse return error.MissingYank;
+ const yanked = try gpa.dupe(u8, p.registers.text(gpa, Registers.default) orelse "");
+ defer gpa.free(yanked);
+ const clip: []const u8 = p.registers.text(gpa, '+') orelse "";
+ try std.testing.expect(clip.len > 0);
try std.testing.expect(drainedEffect(p, .set_clipboard));
const before = pane.file.?.content.len;
@@ -1783,14 +2198,14 @@ test "only the SPC clipboard commands cross to the system clipboard" {
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null);
// ...and it did NOT land in the register on its way past, nor echo back
// out to the clipboard it came from.
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
try std.testing.expect(!drainedEffect(p, .set_clipboard));
// An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V —
// is the same event with no request behind it, and means paste after.
p.update(.{ .paste = "UNASKED" });
try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null);
- try std.testing.expectEqualStrings(yanked, p.yank orelse "");
+ try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse "");
// A request the shell never answers dies at the next keystroke rather
// than firing late into whatever pane is focused by then.