diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-05 20:21:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | de4def3548a6729b0dfd2120495a61beef8c8c2c (patch) | |
| tree | 12144f0f64dc96bb4741459c1f9db57f44349330 /modal.zig | |
| parent | 7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff) | |
| download | pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip | |
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'modal.zig')
| -rw-r--r-- | modal.zig | 964 |
1 files changed, 0 insertions, 964 deletions
diff --git a/modal.zig b/modal.zig deleted file mode 100644 index 2a195432..00000000 --- a/modal.zig +++ /dev/null @@ -1,964 +0,0 @@ -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) }; -} - -// normalize two cursors into document order (lo <= hi), then byte offsets of the -// INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty. -fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } { - var lo = a; - var hi = b; - if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) { - lo = b; - hi = a; - } - const s = lineStartOffset(content, lo.row) + @min(lo.col, lineSlice(content, lo.row).len); - var e = lineStartOffset(content, hi.row) + @min(hi.col, lineSlice(content, hi.row).len); - if (e < content.len) e += 1; // include the char under the head - return .{ .s = s, .e = @max(s, e) }; -} - -// the text of the inclusive char range [a, b] (cursors in either order). Caller frees. -pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { - const r = rangeBytes(content, a, b); - return alloc.dupe(u8, content[r.s..r.e]); -} - -// delete the inclusive char range [a, b]. returns new content + the removed text. -pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted { - const r = rangeBytes(content, a, b); - const deleted = try alloc.dupe(u8, content[r.s..r.e]); - errdefer alloc.free(deleted); - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(alloc, content[0..r.s]); - try out.appendSlice(alloc, content[r.e..]); - return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted }; -} - -// 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 "rangeText + deleteRange (char-wise select)" { - const a = std.testing.allocator; - const content = "hello\nworld\nfoo"; - // same-line inclusive range: "hello"[1..3] -> "ell" - const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 }); - defer a.free(t1); - try std.testing.expectEqualStrings("ell", t1); - // reversed cursors give the same range - const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 }); - defer a.free(t2); - try std.testing.expectEqualStrings("ell", t2); - // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo" - const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); - defer a.free(t3); - try std.testing.expectEqualStrings("lo\nwo", t3); - // delete the same cross-line range - const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); - defer a.free(d.content); - defer a.free(d.deleted); - try std.testing.expectEqualStrings("helrld\nfoo", d.content); - try std.testing.expectEqualStrings("lo\nwo", 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); -} |
