diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /modal.zig | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
Diffstat (limited to 'modal.zig')
| -rw-r--r-- | modal.zig | 909 |
1 files changed, 909 insertions, 0 deletions
diff --git a/modal.zig b/modal.zig new file mode 100644 index 00000000..3038c430 --- /dev/null +++ b/modal.zig @@ -0,0 +1,909 @@ +const std = @import("std"); + +// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested +// in isolation (see the `unit-test` build step). main.zig wires this onto the +// pane's cursor + (for file panes) its content. +// +// The cursor sits ON a character: col is a char index in [0, line.len]; col == +// line.len means "on the line terminator / after the last char". Motions are +// written to land on real characters; main.zig clamps for display. + +pub const Cursor = struct { + row: usize = 0, + col: usize = 0, + + pub fn eql(a: Cursor, b: Cursor) bool { + return a.row == b.row and a.col == b.col; + } +}; + +// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other +// non-ws, ws = space/tab/newline). +pub const Kind = enum { word, punct, ws }; + +pub fn kindOf(c: u8) Kind { + if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; + if (std.ascii.isAlphanumeric(c) or c == '_') return .word; + return .punct; +} + +// "long word" (W/B/E): only whitespace separates; punct is part of a word. +fn kindOfLong(c: u8) Kind { + if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; + return .word; +} + +fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { + if (c.row >= lines.len) return .ws; + const line = lines[c.row]; + if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace + return if (long) kindOfLong(line[c.col]) else kindOf(line[c.col]); +} + +fn lineLenOf(lines: []const []const u8, row: usize) usize { + if (row >= lines.len) return 0; + return lines[row].len; +} + +// advance one position across line boundaries (line terminators are positions +// too: col == line.len is the newline). Returns false at EOF. +fn stepFwd(lines: []const []const u8, c: *Cursor) bool { + if (c.row >= lines.len) return false; + const llen = lineLenOf(lines, c.row); + if (c.col < llen) { + c.col += 1; + return true; + } + // at the newline: move to next line start + if (c.row + 1 < lines.len) { + c.row += 1; + c.col = 0; + return true; + } + return false; // EOF +} + +fn stepBwd(lines: []const []const u8, c: *Cursor) bool { + if (c.col > 0) { + c.col -= 1; + return true; + } + if (c.row == 0) return false; + c.row -= 1; + c.col = lineLenOf(lines, c.row); // the previous line's newline + return true; +} + +// at EOF? (past the last line's last char) +fn atEof(lines: []const []const u8, c: Cursor) bool { + if (c.row >= lines.len) return true; + if (c.row + 1 < lines.len) return false; + return c.col >= lines[c.row].len; +} + +pub fn firstNonWs(line: []const u8) usize { + var i: usize = 0; + while (i < line.len and (line[i] == ' ' or line[i] == '\t')) i += 1; + return i; +} + +// ---- per-line motions ---- + +pub fn lineStart(c: Cursor) Cursor { + return .{ .row = c.row, .col = 0 }; +} + +pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { + const llen = lineLenOf(lines, c.row); + return .{ .row = c.row, .col = if (llen == 0) 0 else llen - 1 }; +} + +pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { + return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; +} + +// ---- char/line motions ---- + +pub fn charLeft(c: Cursor) Cursor { + return .{ .row = c.row, .col = if (c.col > 0) c.col - 1 else 0 }; +} + +pub fn charRight(lines: []const []const u8, c: Cursor) Cursor { + const llen = lineLenOf(lines, c.row); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = c.row, .col = if (c.col < last) c.col + 1 else last }; +} + +pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { + const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; + const llen = lineLenOf(lines, nr); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = nr, .col = if (c.col < last) c.col else last }; +} + +pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { + const nr = if (c.row > 0) c.row - 1 else c.row; + const llen = lineLenOf(lines, nr); + const last = if (llen == 0) 0 else llen - 1; + return .{ .row = nr, .col = if (c.col < last) c.col else last }; +} + +// ---- word motions ---- + +// `w`/`W`: to the start of the next word. +pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + const start_kind = kindAt(lines, p, long); + if (start_kind != .ws) { + // skip the rest of the current word-class run + while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { + if (!stepFwd(lines, &p)) break; + } + } + // skip whitespace (incl. newlines) to the next word start + while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { + if (!stepFwd(lines, &p)) break; + } + // p now sits on the next word's first char (or EOF -> last valid pos) + return clampToChar(lines, p); +} + +// `b`/`B`: to the start of the previous word. +pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + if (!stepBwd(lines, &p)) return c; // at buffer start + // skip whitespace backward + while (kindAt(lines, p, long) == .ws) { + if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; + } + // now on the end of the previous word; walk back to its start + const k = kindAt(lines, p, long); + while (true) { + var q = p; + if (!stepBwd(lines, &q)) { + p.col = 0; + break; + } + if (kindAt(lines, q, long) != k) break; // crossed into prior class + p = q; + } + return clampToChar(lines, p); +} + +// `e`/`E`: to the end of the current/next word. +pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { + var p = c; + if (!stepFwd(lines, &p)) return clampToChar(lines, c); + // skip whitespace forward + while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { + if (!stepFwd(lines, &p)) break; + } + if (atEof(lines, p)) return clampToChar(lines, p); + // now on a word's first char; advance to the last char of this run + const k = kindAt(lines, p, long); + while (!atEof(lines, p)) { + var q = p; + if (!stepFwd(lines, &q)) break; + if (kindAt(lines, q, long) != k) break; + p = q; + } + return clampToChar(lines, p); +} + +// ---- goto ---- + +pub fn gotoFirst() Cursor { + return .{ .row = 0, .col = 0 }; +} + +pub fn gotoLast(lines: []const []const u8) Cursor { + const r = if (lines.len == 0) 0 else lines.len - 1; + return .{ .row = r, .col = 0 }; +} + +// half/full page: target row, caller clamps + scrolls. +pub fn pageDown(c: Cursor, page: usize) Cursor { + return .{ .row = c.row + page, .col = c.col }; +} + +pub fn pageUp(c: Cursor, page: usize) Cursor { + return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col }; +} + +// ---- helpers used by motions + main.zig ---- + +// clamp a (possibly terminator/EOF) position onto a real character. +pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { + if (c.row >= lines.len) { + return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 }; + } + const llen = lineLenOf(lines, c.row); + if (llen == 0) return .{ .row = c.row, .col = 0 }; + return .{ .row = c.row, .col = @min(c.col, llen - 1) }; +} + +pub fn lineCount(content: []const u8) usize { + if (content.len == 0) return 0; + return std.mem.count(u8, content, "\n") + 1; +} + +// byte offset of the start of line `row` (0-based). row may == lineCount() +// (== content.len, the end). +pub fn lineStartOffset(content: []const u8, row: usize) usize { + var off: usize = 0; + var r: usize = 0; + while (r < row) : (r += 1) { + const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len; + off = nl + 1; + } + return off; +} + +// the text of line `row` (no terminator), a slice into `content`. +pub fn lineSlice(content: []const u8, row: usize) []const u8 { + const start = lineStartOffset(content, row); + if (start >= content.len) return ""; + const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len; + return content[start..nl]; +} + +// ---- file content mutations. caller frees the returned slice + the old one. ---- + +// insert `text` at (row, col). col is clamped to the line length. +pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { + const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row; + const line = lineSlice(content, row); + const col = @min(c.col, line.len); + const off = lineStartOffset(content, row) + col; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + try out.appendSlice(alloc, text); + try out.appendSlice(alloc, content[off..]); + return out.toOwnedSlice(alloc); +} + +// delete the character at (row, col). no-op if col is past the line end. +pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 { + const line = lineSlice(content, c.row); + if (c.col >= line.len) return alloc.dupe(u8, content); + const off = lineStartOffset(content, c.row) + c.col; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + try out.appendSlice(alloc, content[off + 1 ..]); + return out.toOwnedSlice(alloc); +} + +// delete whole lines [r0, r1] inclusive (the line content + their terminators). +// returns the new content; `deleted` is the joined removed text (no terminators). +pub const Deleted = struct { content: []u8, deleted: []u8 }; + +pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted { + const n = lineCount(content); + const lo = @min(r0, r1); + const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1); + if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") }; + + const start = lineStartOffset(content, lo); + // end = start of line (hi+1), or content.len if hi is the last line. + const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len; + // if we're removing the last line and there's a preceding newline, also + // drop that newline so we don't leave a trailing blank line. + var cut_lo = start; + const cut_hi = end; + if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line + + var deleted: std.ArrayList(u8) = .empty; + { + var r = lo; + while (r <= hi) : (r += 1) { + if (r > lo) try deleted.append(alloc, '\n'); + try deleted.appendSlice(alloc, lineSlice(content, r)); + } + } + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..cut_lo]); + try out.appendSlice(alloc, content[cut_hi..]); + return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) }; +} + +// replace line `row`'s text with "" (keep the line, empty it). For `c`hange line. +pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 { + const line = lineSlice(content, row); + const start = lineStartOffset(content, row); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..start]); + try out.appendSlice(alloc, content[start + line.len ..]); + return out.toOwnedSlice(alloc); +} + +// paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines. +pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 { + const n = lineCount(content); + const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..off]); + if (off < content.len) { + try out.appendSlice(alloc, text); + try out.append(alloc, '\n'); + } else { + if (off > 0) try out.append(alloc, '\n'); + try out.appendSlice(alloc, text); + } + try out.appendSlice(alloc, content[off..]); + return out.toOwnedSlice(alloc); +} + +// ---- file-pane modal step ---- +// One keypress on an editable FILE pane. This is the single source of truth for +// file-modal behavior: main.zig delegates file panes here, and the tutor-driven +// unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) — +// only std + the modal primitives above. Returns the new content (caller frees +// the old), or null if the key only moved the cursor / was a no-op (caller keeps +// the old content, no realloc). + +pub const Key = union(enum) { + ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes) + esc, + enter, + backspace, + left, + right, + up, + down, + home, + end, + page_down, + page_up, + half_down, // Ctrl-d + half_up, // Ctrl-u +}; + +pub const FileMode = enum { normal, insert }; + +pub const FileState = struct { + cur: Cursor = .{}, + mode: FileMode = .normal, + pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z' + msel_active: bool = false, + msel_r0: i32 = 0, + msel_r1: i32 = 0, +}; + +// The view the cursor must stay inside. `scroll` is read/written so viewport +// alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to +// the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll +// so visibility is a no-op (content is what's asserted). +pub const View = struct { + rows: usize, + scroll: *usize, +}; + +fn chEq(key: Key, cp: u21) bool { + return switch (key) { + .ch => |c| c == cp, + else => false, + }; +} + +fn tagOf(key: Key) std.meta.Tag(Key) { + return std.meta.activeTag(key); +} + +fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 { + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |ln| try ls.append(alloc, ln); + return ls.toOwnedSlice(alloc); +} + +fn ensureVisible(view: View, row: usize) void { + const s = view.scroll.*; + if (row < s) { + view.scroll.* = row; + return; + } + const last = s +| (view.rows -| 1); + if (row > last) view.scroll.* = row -| (view.rows -| 1); +} + +// helix `x`: start a line selection at the cursor, or extend it down by one. +fn lineSelectFile(st: *FileState, lines: []const []const u8) void { + if (!st.msel_active) { + st.msel_active = true; + st.msel_r0 = @intCast(st.cur.row); + st.msel_r1 = @intCast(st.cur.row); + } else { + const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1); + st.msel_r1 = @min(st.msel_r1 + 1, last); + st.cur.row = @intCast(st.msel_r1); + } + st.cur.col = 0; + st.pending = 0; +} + +// `d`: delete the selected lines (yanking them), or the char at the cursor. +fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { + if (st.msel_active) { + const r0: i32 = @min(st.msel_r0, st.msel_r1); + const r1: i32 = @max(st.msel_r0, st.msel_r1); + const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1))); + if (yank.*) |old| alloc.free(old); + yank.* = try alloc.dupe(u8, d.deleted); + alloc.free(d.deleted); + const n = lineCount(d.content); + st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 }; + st.msel_active = false; + st.pending = 0; + return d.content; + } + // no selection: delete the char at the cursor (no-op if past end) + const llen = lineLenOf(lines, st.cur.row); + if (st.cur.col >= llen) { + st.pending = 0; + return null; + } + const new = try deleteChar(alloc, content, st.cur); + st.pending = 0; + return new; +} + +// `y`: yank the selected lines, or the current line. Cursor moves to the +// selection start; the selection is collapsed. +fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void { + if (st.msel_active) { + const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); + const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); + var buf: std.ArrayList(u8) = .empty; + var r: usize = r0; + while (r <= r1) : (r += 1) { + if (r > r0) try buf.append(alloc, '\n'); + try buf.appendSlice(alloc, lineSlice(content, r)); + } + if (yank.*) |old| alloc.free(old); + yank.* = try buf.toOwnedSlice(alloc); + st.cur = .{ .row = r0, .col = 0 }; + st.msel_active = false; + } else { + if (yank.*) |old| alloc.free(old); + yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row)); + } + st.pending = 0; +} + +// `p`: paste the yanked text as a new line below the cursor. +fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 { + const y = yank.* orelse { + st.pending = 0; + return null; + }; + const new = try pasteLineBelow(alloc, content, st.cur.row, y); + st.cur = .{ .row = st.cur.row + 1, .col = 0 }; + st.pending = 0; + return new; +} + +// `c`: change the selection (delete + enter INSERT, keeping one empty line), or +// change the char at the cursor. Yanks the removed text. +fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { + if (st.msel_active) { + const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); + const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); + // yank the original lines first (from the un-mutated content) + var buf: std.ArrayList(u8) = .empty; + var r: usize = r0; + while (r <= r1) : (r += 1) { + if (r > r0) try buf.append(alloc, '\n'); + try buf.appendSlice(alloc, lineSlice(content, r)); + } + if (yank.*) |old| alloc.free(old); + yank.* = try buf.toOwnedSlice(alloc); + // drop the extra lines (keep one), then empty the kept line + const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null; + defer if (d) |dd| alloc.free(dd.deleted); + const after_del: []const u8 = if (d) |dd| dd.content else content; + const cl = try clearLine(alloc, after_del, r0); + if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy) + st.cur = .{ .row = r0, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return cl; + } + // no selection: delete the char at the cursor (if any), then enter INSERT + const llen = lineLenOf(lines, st.cur.row); + if (st.cur.col >= llen) { + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return null; + } + const new = try deleteChar(alloc, content, st.cur); + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + return new; +} + +// INSERT-mode key on a file pane. Returns new content or null (no change). +fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 { + switch (key) { + .esc => { + st.mode = .normal; + st.msel_active = false; + st.pending = 0; + return null; + }, + .ch => |cp| { + if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter); + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch return null; + const new = try insertAt(alloc, content, st.cur, buf[0..n]); + st.cur.col += n; // col is a byte offset; advance by the char's byte length + st.pending = 0; + return new; + }, + .enter => { + const new = try insertAt(alloc, content, st.cur, "\n"); + st.cur = .{ .row = st.cur.row + 1, .col = 0 }; + st.pending = 0; + return new; + }, + .backspace => { + if (st.cur.col > 0) { + const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 }); + st.cur.col -= 1; + return new; + } else if (st.cur.row > 0) { + const prevlen = lineSlice(content, st.cur.row - 1).len; + const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen }); + st.cur = .{ .row = st.cur.row - 1, .col = prevlen }; + return new; + } + return null; + }, + .left => { + st.cur = charLeft(st.cur); + return null; + }, + .right => { + st.cur = charRight(lines, st.cur); + return null; + }, + .up => { + st.cur = lineUp(lines, st.cur); + return null; + }, + .down => { + st.cur = lineDown(lines, st.cur); + return null; + }, + .home => { + st.cur = lineStart(st.cur); + return null; + }, + .end => { + st.cur = lineEnd(lines, st.cur); + return null; + }, + else => return null, + } +} + +// The full NORMAL-mode file step (motions + insert entry + select/edit). See +// handleNormal in main.zig for the terminal counterpart (which differs in its +// edit ops: terminals can't delete shell output, only pasted runs). +pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 { + const lines = try splitLines(alloc, content); + defer alloc.free(lines); + if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key); + + var cur = clampToChar(lines, st.cur); + + // prefix continuations + if (st.pending != 0) { + const p = st.pending; + st.pending = 0; + if (p == 'g') { + if (chEq(key, 'g')) cur = gotoFirst() + else if (chEq(key, 'e')) cur = gotoLast(lines) + else if (chEq(key, 'h')) cur = lineStart(cur) + else if (chEq(key, 'l')) cur = lineEnd(lines, cur) + else return null; // unknown continuation: consume, no-op + st.cur = cur; + st.msel_active = false; + return null; + } + if (p == 'z') { + if (chEq(key, 't')) { + view.scroll.* = cur.row; + } else if (chEq(key, 'z')) { + view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2)))); + } else if (chEq(key, 'b')) { + view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1)); + } else return null; + st.msel_active = false; + return null; + } + } + if (chEq(key, 'g')) { + st.pending = 'g'; + return null; + } + if (chEq(key, 'z')) { + st.pending = 'z'; + return null; + } + + // motions -> move cursor, collapse selection, keep visible + var moved: ?Cursor = null; + if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) + else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) + else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) + else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) + else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) + else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) + else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) + else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) + else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) + else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) + else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) + else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) + else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) + else if (chEq(key, 'G')) moved = gotoLast(lines) + else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) + else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) + else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) + else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); + if (moved) |m| { + st.cur = m; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, m.row); + return null; + } + + // insert entry + if (chEq(key, 'i')) { + st.mode = .insert; + st.cur = cur; + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'a')) { + st.mode = .insert; + st.cur = charRight(lines, cur); + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'I')) { + st.mode = .insert; + st.cur = firstNonWsOf(lines, cur); + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'A')) { + st.mode = .insert; + st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }; + st.msel_active = false; + st.pending = 0; + return null; + } + if (chEq(key, 'o')) { + const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n"); + st.cur = .{ .row = cur.row + 1, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, st.cur.row); + return new; + } + if (chEq(key, 'O')) { + const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n"); + st.cur = .{ .row = cur.row, .col = 0 }; + st.mode = .insert; + st.msel_active = false; + st.pending = 0; + ensureVisible(view, st.cur.row); + return new; + } + + // select / edit + if (chEq(key, 'x')) { + lineSelectFile(st, lines); + return null; + } + if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank); + if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank); + if (chEq(key, 'y')) { + try yankFile(alloc, content, st, yank); + return null; + } + if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank); + + // Enter / other keys: no-op on a file (no tty mode) + return null; +} + +// ---- tests ---- + +const testEq = struct { + fn cur(lines: []const []const u8, s: []const u8) Cursor { + var row: usize = 0; + for (lines) |ln| { + if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 }; + row += 1; + } + unreachable; + } +}; + +test "kindOf" { + try std.testing.expectEqual(Kind.word, kindOf('a')); + try std.testing.expectEqual(Kind.word, kindOf('_')); + try std.testing.expectEqual(Kind.word, kindOf('9')); + try std.testing.expectEqual(Kind.punct, kindOf('.')); + try std.testing.expectEqual(Kind.punct, kindOf('(')); + try std.testing.expectEqual(Kind.ws, kindOf(' ')); + try std.testing.expectEqual(Kind.ws, kindOf('\n')); +} + +test "char/line motions" { + const lines = [_][]const u8{ "alpha beta", " two words", "x" }; + const c = Cursor{ .row = 0, .col = 5 }; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(c)); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); + // line ends + try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); + // first non-ws + try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); +} + +test "word motions w/b/e" { + const lines = [_][]const u8{"this is a test"}; + const w = &lines; + // "this is a test", indices 0..13 + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws + // b + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" + // e + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] + try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] +} + +test "word motions cross line" { + const lines = [_][]const u8{ "foo bar", "", "baz" }; + const w = &lines; + // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); + // b from "baz" crosses back to "bar" + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); + // e from row0 col0 -> "foo" end (col2) + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); +} + +test "long word W treats punct as word" { + // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) + const lines = [_][]const u8{"foo.bar baz"}; + const w = &lines; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); + // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); +} + +test "goto" { + const lines = [_][]const u8{ "a", "b", "c" }; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); +} + +test "lineStartOffset + lineSlice" { + const content = "alpha\nbeta\n\ngamma"; + try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); + try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1)); + try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2)); + try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3)); + try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end + try std.testing.expectEqualStrings("alpha", lineSlice(content, 0)); + try std.testing.expectEqualStrings("beta", lineSlice(content, 1)); + try std.testing.expectEqualStrings("", lineSlice(content, 2)); + try std.testing.expectEqualStrings("gamma", lineSlice(content, 3)); + try std.testing.expectEqual(@as(usize, 4), lineCount(content)); +} + +test "insertAt mid-line and at end" { + const content = "hello world"; + const a = std.testing.allocator; + const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!"); + defer a.free(r1); + try std.testing.expectEqualStrings("hello! world", r1); + const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!"); + defer a.free(r2); + try std.testing.expectEqualStrings("hello world!", r2); +} + +test "insertAt multiline creates lines" { + const content = "a\nb"; + const a = std.testing.allocator; + const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY"); + defer a.free(r); + try std.testing.expectEqualStrings("aX\nY\nb", r); + try std.testing.expectEqual(@as(usize, 3), lineCount(r)); +} + +test "deleteChar" { + const content = "abc"; + const a = std.testing.allocator; + const r = try deleteChar(a, content, .{ .row = 0, .col = 1 }); + defer a.free(r); + try std.testing.expectEqualStrings("ac", r); + // past end: no-op + const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 }); + defer a.free(r2); + try std.testing.expectEqualStrings("abc", r2); +} + +test "deleteLines middle" { + const content = "one\ntwo\nthree\nfour"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 1, 2); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("one\nfour", d.content); + try std.testing.expectEqualStrings("two\nthree", d.deleted); +} + +test "deleteLines last line drops preceding newline" { + const content = "one\ntwo\nthree"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 2, 2); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("one\ntwo", d.content); + try std.testing.expectEqualStrings("three", d.deleted); +} + +test "deleteLines only line" { + const content = "only"; + const a = std.testing.allocator; + const d = try deleteLines(a, content, 0, 0); + defer a.free(d.content); + defer a.free(d.deleted); + try std.testing.expectEqualStrings("", d.content); + try std.testing.expectEqualStrings("only", d.deleted); +} + +test "clearLine" { + const content = "keep\nzap me\nkeep2"; + const a = std.testing.allocator; + const r = try clearLine(a, content, 1); + defer a.free(r); + try std.testing.expectEqualStrings("keep\n\nkeep2", r); +} + +test "pasteLineBelow" { + const content = "one\ntwo"; + const a = std.testing.allocator; + const r = try pasteLineBelow(a, content, 0, "INSERTED"); + defer a.free(r); + try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r); + // paste below last line + const r2 = try pasteLineBelow(a, content, 1, "END"); + defer a.free(r2); + try std.testing.expectEqualStrings("one\ntwo\nEND", r2); + // multiline yanked text + const r3 = try pasteLineBelow(a, content, 0, "a\nb"); + defer a.free(r3); + try std.testing.expectEqualStrings("one\na\nb\ntwo", r3); +} |
