summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.zig485
-rw-r--r--modal.zig55
-rw-r--r--term.zig47
-rw-r--r--tests.zig111
4 files changed, 638 insertions, 60 deletions
diff --git a/main.zig b/main.zig
index 17837cc3..2ed94ccb 100644
--- a/main.zig
+++ b/main.zig
@@ -135,14 +135,10 @@ const gray = struct {
const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview
const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
};
-// the drag button: a distinctive red (brighter when the window is focused).
-const red = [3]u8{ 0xcc, 0x33, 0x33 };
-const red_dim = [3]u8{ 0x66, 0x2a, 0x2a };
-// resize-border hover tint: a teal bar makes the (invisible) drag zones discoverable.
-const hover_bg = [3]u8{ 0x3a, 0x70, 0x70 };
-// char-mode inserted text (bright on a faint box so it reads as an overlay).
-const edit_fg = [3]u8{ 0xea, 0xea, 0xea };
-const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a };
+// the drag button: an indigo-purple accent (brighter when the window is focused),
+// dimming to a dark grey-blue when idle.
+const accent = [3]u8{ 0x6e, 0x4f, 0xd0 };
+const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 };
// per-button selection highlight: distinct (muted) colors so left/middle/right
// selections are easy to tell apart against the otherwise-monochrome chrome.
const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
@@ -173,6 +169,9 @@ const Theme = struct {
str: [3]u8,
num: [3]u8,
comment: [3]u8,
+ lineno: [3]u8, // muted file-pane line-number gutter
+ scroll_track: [3]u8, // scrollbar strip
+ scroll_thumb: [3]u8, // scrollbar visible-portion indicator
// the 16 ANSI colors for terminal-pane output; null = pass palette indices
// through to the real terminal (dark keeps the terminal's own colors).
palette: ?[16][3]u8,
@@ -188,6 +187,9 @@ const themes = [_]Theme{
.str = .{ 0x8a, 0xb8, 0x7a }, // green
.num = .{ 0xd0, 0xc0, 0x70 }, // yellow
.comment = .{ 0xf0, 0xf0, 0xf0 }, // bold white
+ .lineno = .{ 0x5a, 0x5a, 0x5a }, // dim gray gutter
+ .scroll_track = .{ 0x2a, 0x2a, 0x2a }, // gray.bar (unchanged dark scrollbar)
+ .scroll_thumb = .{ 0x52, 0x52, 0x52 }, // gray.mid
.palette = null, // terminal-native ANSI colors
},
.{
@@ -200,6 +202,9 @@ const themes = [_]Theme{
.str = .{ 0x00, 0x77, 0x33 },
.num = .{ 0xa0, 0x52, 0x00 },
.comment = .{ 0x55, 0x55, 0x55 }, // bold, darkened to read on yellow
+ .lineno = .{ 0x99, 0x99, 0x80 }, // muted gutter, reads on the pale page
+ .scroll_track = .{ 0x99, 0x99, 0x4c }, // acme olive strip (BORD)
+ .scroll_thumb = .{ 0xff, 0xff, 0xea }, // acme pale-yellow scroller (BACK)
// darkened ANSI palette so shell output (ls colors, prompts) reads on the
// pale-yellow page; 7/15 (the "whites") map to gray/black, not invisible.
.palette = .{
@@ -947,10 +952,10 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
if (t.image) |*iv| {
if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }));
const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
- const box_bg = if (active) red else red_dim;
+ const box_bg = if (active) accent else accent_dim;
var gy: u16 = 0;
while (gy < r.h) : (gy += 1) {
- const bg = if (gy < BOX_H) box_bg else gray.bar;
+ const bg = if (gy < BOX_H) box_bg else T().scroll_track;
var gx: u16 = 0;
while (gx < GUTTER) : (gx += 1)
gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } });
@@ -972,6 +977,23 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
const body_w = r.w - GUTTER;
const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ // mute the file-pane line-number gutter (the first PREFIX_W cols). Cheap chrome,
+ // so it's not gated on colors_on; the selection/cursor passes below still win.
+ if (t.file != null) {
+ const ln_fg = rgbColor(T().lineno);
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < PREFIX_W and c < body_w) : (c += 1) {
+ if (text.readCell(c, BOX_H + vr)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.fg = ln_fg;
+ text.writeCell(c, BOX_H + vr, nc);
+ }
+ }
+ }
+ }
// syntax colors (file panes): recolor each content cell from its tree-sitter
// style. Content starts after the PREFIX_W line-number gutter; highlights are
// indexed by content byte, and a line slice's byte offset is its ptr - content.
@@ -1022,9 +1044,13 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
- // recolor the inserted cells so typed text reads distinctly from tty output.
- // edit.row is absolute; its visual pane row is (row - off) + BOX_H.
- for (t.edits[0..t.nedits]) |*e| {
+ // repaint the inserted cells with the plain body style so typed text reads
+ // EXACTLY like normal text — not a distinct overlay. (The pass is still needed:
+ // the tty recolor above paints cell (c,vr) from the emulator cell at column c,
+ // but inserted runs shifted the row right, so those cells would otherwise pick
+ // up the wrong emulator colors.) edit.row is absolute -> (row - off) + BOX_H.
+ // Skipped in tty mode: the overlay is hidden there (bodyText doesn't splice it).
+ if (t.mode != .tty) for (t.edits[0..t.nedits]) |*e| {
if (e.len == 0) continue;
const prow = e.row - off + @as(i32, BOX_H);
if (prow < 0 or prow >= @as(i32, r.h)) continue;
@@ -1036,12 +1062,11 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
if (text.readCell(@intCast(cc), @intCast(prow))) |cell| {
var nc = cell;
nc.default = false;
- nc.style.fg = .{ .rgb = edit_fg };
- nc.style.bg = .{ .rgb = edit_bg };
+ nc.style = body_style;
text.writeCell(@intCast(cc), @intCast(prow), nc);
}
}
- }
+ };
// recolor selected cells (pane-local visual coords), one pass per button —
// later buttons win on overlap; only while actively dragging.
@@ -1054,7 +1079,8 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
var row: u16 = 0;
while (row < r.h) : (row += 1) {
if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
- var col: i32 = c0;
+ // file panes: don't paint over the line-number gutter.
+ var col: i32 = if (t.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
while (col <= c1 and col < @as(i32, r.w - GUTTER)) : (col += 1) {
const cc: u16 = @intCast(col);
if (text.readCell(cc, row)) |cell| {
@@ -1075,7 +1101,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
var row: u16 = 0;
while (row < r.h) : (row += 1) {
if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
- var col: u16 = 0;
+ var col: u16 = if (t.file != null) PREFIX_W else 0; // skip the line-number gutter
while (col < r.w - GUTTER) : (col += 1) {
if (text.readCell(col, row)) |cell| {
var nc = cell;
@@ -1087,6 +1113,32 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
}
+ // modal char selection (helix `v`): stream-shaped highlight from the anchor to
+ // the cursor (head). r0/r1 are absolute body rows; cols are content-relative on
+ // file panes (offset by PREFIX_W) and visual on terminals. Normal mode only.
+ if (t.mode == .normal and t.vsel.active) {
+ const bnd = vselBounds(t);
+ const pfx: i32 = if (t.file != null) PREFIX_W else 0;
+ var ar: i32 = bnd.lo_row;
+ while (ar <= bnd.hi_row) : (ar += 1) {
+ const prow = ar - off + @as(i32, BOX_H);
+ if (prow < 0 or prow >= @as(i32, r.h)) continue;
+ // first row starts at the anchor col, last row ends at the head col
+ // (inclusive); interior rows span the whole width.
+ const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + pfx else pfx;
+ const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + pfx else @as(i32, r.w - GUTTER) - 1;
+ var col: i32 = @max(cstart, pfx);
+ while (col <= cend and col < @as(i32, r.w - GUTTER)) : (col += 1) {
+ if (text.readCell(@intCast(col), @intCast(prow))) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.bg = .{ .rgb = msel_bg };
+ nc.style.fg = .{ .rgb = msel_fg };
+ text.writeCell(@intCast(col), @intCast(prow), nc);
+ }
+ }
+ }
+ }
if (active) {
if (t.mode != .tty) {
// text mode (normal/insert): cursor at the (scroll-adjusted) edit
@@ -1112,7 +1164,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// gutter: box (the drag/rearrange handle) on top, scrollbar below
const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
- const box_bg = if (active) red else red_dim;
+ const box_bg = if (active) accent else accent_dim;
var bx: u16 = 0;
while (bx < GUTTER) : (bx += 1) {
var by: u16 = 0;
@@ -1141,7 +1193,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
var gy: usize = 0;
while (gy < track_h) : (gy += 1) {
const thumb = gy >= pos and gy < pos + len;
- const st_bg = if (thumb) gray.mid else gray.bar;
+ const st_bg = if (thumb) T().scroll_thumb else T().scroll_track;
var cx: u16 = 0;
while (cx < GUTTER) : (cx += 1)
gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = st_bg } } });
@@ -1191,7 +1243,9 @@ fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void {
t.cur_pinned = true;
}
-// apply a motion result: move cursor, collapse any line selection, keep it visible
+// apply a motion result: move the cursor and keep it visible. A line selection
+// (`x`) collapses on motion; a char selection (`v`) is left active so the motion
+// extends it — the head is just the cursor, which fromModalCursor moved.
fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void {
fromModalCursor(t, pl, c);
t.msel.active = false;
@@ -1243,9 +1297,13 @@ const InsertAt = enum { at, append, line_start, line_end, open_below, open_above
// open_below/open_above splice a newline into the content; terminals just move.
fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, where: InsertAt) void {
pinCursor(t);
+ // snapshot once per insert session so `u` undoes the whole session, not each key.
+ // (file: content; terminal: the char-mode runs — both via the unified pushUndo.)
+ pushUndo(gpa, t);
const pl = paneCursorLines(talloc, t) catch {
t.mode = .insert;
t.msel.active = false;
+ t.vsel.active = false;
t.pending = 0;
return;
};
@@ -1294,10 +1352,194 @@ fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, wher
t.cur_off = if (where == .at) 0 else if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
t.mode = .insert;
t.msel.active = false;
+ t.vsel.active = false;
t.pending = 0;
ensureCursorVisible(t);
}
+// the char selection [anchor, cursor] normalized to document order. Absolute body
+// rows; cols in the cursor's coords (content for files, visual for terminals).
+const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+fn vselBounds(t: *Term) Bounds {
+ var ar = t.vsel.row;
+ var ac = t.vsel.col;
+ var br = t.cur_row;
+ var bc = t.cur_col;
+ if (br < ar or (br == ar and bc < ac)) {
+ const sr = ar;
+ const sc = ac;
+ ar = br;
+ ac = bc;
+ br = sr;
+ bc = sc;
+ }
+ return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
+}
+
+// the selected text of the char selection. File: sliced from content (content
+// cols). Terminal: sliced from the rendered body (visual cols), so it copies tty
+// output and typed runs alike. Caller frees via `alloc`.
+fn vselText(alloc: std.mem.Allocator, t: *Term) []const u8 {
+ const b = vselBounds(t);
+ if (t.file) |f| {
+ return modal.rangeText(alloc, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
+ }
+ const body = bodyText(alloc, t) catch return "";
+ defer alloc.free(body);
+ const off = paneScroll(t);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < b.lo_row or abs > b.hi_row) continue;
+ const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0;
+ const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len;
+ if (abs > b.lo_row) out.append(alloc, '\n') catch return "";
+ out.appendSlice(alloc, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return "";
+ }
+ return out.toOwnedSlice(alloc) catch "";
+}
+
+// The active modal selection (`v` char range or `x` line range) as text, or null
+// if nothing is selected. The normal-mode Enter/Tab chords use this to act on the
+// SELECTION instead of the word under the cursor. `alloc` owns the result.
+fn currentSelText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 {
+ if (t.vsel.active) return vselText(alloc, t);
+ if (!t.msel.active) return null;
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |f| {
+ var buf: std.ArrayList(u8) = .empty;
+ const lo: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ var r: usize = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(alloc, '\n') catch break;
+ buf.appendSlice(alloc, modal.lineSlice(f.content, r)) catch break;
+ }
+ return buf.toOwnedSlice(alloc) catch null;
+ }
+ return yankRows(alloc, t, r0, r1);
+}
+
+// ---- dumb undo/redo: whole-state snapshots, one per edit op ----
+// File panes snapshot the content bytes (File.undo/redo); terminal panes snapshot
+// the char-mode insertion runs (Term.ed_undo/redo). One `pushUndo(t)` handles both
+// so every edit site is type-agnostic.
+const UNDO_MAX = 256;
+const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter
+
+fn editSnapEq(a: Term.EditSnap, b: Term.EditSnap) bool {
+ if (a.nedits != b.nedits) return false;
+ for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| {
+ if (x.row != y.row or x.col != y.col or x.len != y.len) return false;
+ if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false;
+ }
+ return true;
+}
+
+fn snapTerm(t: *Term) Term.EditSnap {
+ return .{ .edits = t.edits, .nedits = t.nedits, .cur_row = t.cur_row, .cur_col = t.cur_col, .cur_off = t.cur_off };
+}
+
+// snapshot the pane's edit state onto its undo stack before an edit, and clear
+// redo. Deduped against the stack top so repeated no-op ops don't pile up.
+fn pushUndo(gpa: std.mem.Allocator, t: *Term) void {
+ if (t.file) |*f| {
+ if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
+ const snap = gpa.dupe(u8, f.content) catch return;
+ f.undo.append(gpa, snap) catch {
+ gpa.free(snap);
+ return;
+ };
+ if (f.undo.items.len > UNDO_MAX) gpa.free(f.undo.orderedRemove(0));
+ for (f.redo.items) |s| gpa.free(s);
+ f.redo.clearRetainingCapacity();
+ } else if (t.isTerminal()) {
+ const snap = snapTerm(t);
+ if (t.ed_undo.items.len > 0 and editSnapEq(t.ed_undo.items[t.ed_undo.items.len - 1], snap)) return;
+ t.ed_undo.append(gpa, snap) catch return;
+ if (t.ed_undo.items.len > EDIT_UNDO_MAX) _ = t.ed_undo.orderedRemove(0);
+ t.ed_redo.clearRetainingCapacity();
+ }
+}
+
+fn doUndo(gpa: std.mem.Allocator, t: *Term) void {
+ if (t.file) |*f| {
+ if (f.undo.items.len == 0) return;
+ const cur = gpa.dupe(u8, f.content) catch return;
+ f.redo.append(gpa, cur) catch {
+ gpa.free(cur);
+ return;
+ };
+ const prev = f.undo.pop() orelse return;
+ setFileContent(gpa, f, prev); // transfers ownership of `prev` to f.content
+ clampCursorToFile(t, f);
+ return;
+ }
+ if (!t.isTerminal() or t.ed_undo.items.len == 0) return;
+ t.ed_redo.append(gpa, snapTerm(t)) catch return;
+ restoreTerm(t, t.ed_undo.pop() orelse return);
+}
+
+fn doRedo(gpa: std.mem.Allocator, t: *Term) void {
+ if (t.file) |*f| {
+ if (f.redo.items.len == 0) return;
+ const cur = gpa.dupe(u8, f.content) catch return;
+ f.undo.append(gpa, cur) catch {
+ gpa.free(cur);
+ return;
+ };
+ const next = f.redo.pop() orelse return;
+ setFileContent(gpa, f, next);
+ clampCursorToFile(t, f);
+ return;
+ }
+ if (!t.isTerminal() or t.ed_redo.items.len == 0) return;
+ t.ed_undo.append(gpa, snapTerm(t)) catch return;
+ restoreTerm(t, t.ed_redo.pop() orelse return);
+}
+
+fn restoreTerm(t: *Term, snap: Term.EditSnap) void {
+ t.edits = snap.edits;
+ t.nedits = snap.nedits;
+ t.cur_row = snap.cur_row;
+ t.cur_col = snap.cur_col;
+ t.cur_off = snap.cur_off;
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ t.msel.active = false;
+ ensureCursorVisible(t);
+}
+
+// clamp the cursor into the (possibly shrunken) content and collapse selections.
+fn clampCursorToFile(t: *Term, f: *Term.File) void {
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, t.cur_row))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ t.cur_row = @intCast(row);
+ t.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_col))), llen));
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ t.msel.active = false;
+ ensureCursorVisible(t);
+}
+
+// helix `v`: toggle char-range select. Starting it anchors at the cursor; while
+// active, motions move the head (the cursor) and the selection spans [anchor,head].
+fn charSelect(t: *Term) void {
+ if (t.vsel.active) {
+ t.vsel.active = false;
+ } else {
+ t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col };
+ }
+ t.msel.active = false;
+ t.cur_pinned = true;
+ t.pending = 0;
+}
+
// helix `x`: extend the line selection down by one (or start it at the cursor).
fn lineSelect(t: *Term) void {
if (!t.msel.active) {
@@ -1307,6 +1549,7 @@ fn lineSelect(t: *Term) void {
t.msel.r1 = @min(t.msel.r1 + 1, last);
t.cur_row = t.msel.r1;
}
+ t.vsel.active = false;
t.cur_col = 0;
t.cur_off = 0;
t.cur_pinned = true;
@@ -1343,9 +1586,36 @@ fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 {
}
fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.vsel.active) {
+ const b = vselBounds(t);
+ setYank(gpa, vselText(talloc, t)); // read the range before the cursor/content move
+ pushUndo(gpa, t);
+ if (t.file) |*f| {
+ const d = modal.deleteRange(gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ t.vsel.active = false;
+ return;
+ };
+ setFileContent(gpa, f, d.content);
+ gpa.free(d.deleted);
+ t.cur_row = b.lo_row;
+ t.cur_col = b.lo_col;
+ clampCursorToFile(t, f);
+ } else {
+ // terminal: can't delete shell output — only drop typed runs in the span.
+ clearEditsInRange(t, b.lo_row, b.hi_row);
+ t.cur_row = b.lo_row;
+ t.cur_col = b.lo_col;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ ensureCursorVisible(t);
+ }
+ return;
+ }
if (t.msel.active) {
const r0 = @min(t.msel.r0, t.msel.r1);
const r1 = @max(t.msel.r0, t.msel.r1);
+ pushUndo(gpa, t);
if (t.file) |*f| {
const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
t.msel.active = false;
@@ -1366,13 +1636,45 @@ fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
t.cur_pinned = true;
ensureCursorVisible(t);
} else if (t.file) |*f| {
+ pushUndo(gpa, t);
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
const new = modal.deleteChar(gpa, f.content, c) catch return;
setFileContent(gpa, f, new);
- } // terminal, no selection: can't delete shell output -> no-op
+ } else {
+ // terminal, no selection: can't delete shell output, but CAN delete a char
+ // of your own inserted run sitting under the (visual) cursor.
+ deleteRunCharAtCursor(gpa, t);
+ }
+}
+
+// delete the inserted char under the normal-mode cursor (visual col) from its run,
+// if any. tty output is untouched. No-op when the cursor isn't over a typed run.
+fn deleteRunCharAtCursor(gpa: std.mem.Allocator, t: *Term) void {
+ for (t.edits[0..t.nedits]) |*e| {
+ if (e.row != t.cur_row or e.len == 0) continue;
+ const vstart = renderedCol(t, e.row, e.col, 0);
+ if (t.cur_col < vstart or t.cur_col >= vstart + @as(i32, e.len)) continue;
+ pushUndo(gpa, t);
+ var k: usize = @intCast(t.cur_col - vstart);
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ t.cur_pinned = true;
+ return;
+ }
}
fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.vsel.active) {
+ const b = vselBounds(t);
+ setYank(gpa, vselText(talloc, t));
+ t.cur_row = b.lo_row;
+ t.cur_col = b.lo_col;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ ensureCursorVisible(t);
+ return;
+ }
if (t.msel.active) {
const r0 = @min(t.msel.r0, t.msel.r1);
const r1 = @max(t.msel.r0, t.msel.r1);
@@ -1402,6 +1704,7 @@ fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void
fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
const y = yank_reg orelse return;
+ pushUndo(gpa, t);
if (t.file) |*f| {
const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return;
setFileContent(gpa, f, new);
@@ -1417,9 +1720,37 @@ fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
}
fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.vsel.active) {
+ const b = vselBounds(t);
+ setYank(gpa, vselText(talloc, t));
+ pushUndo(gpa, t);
+ if (t.file) |*f| {
+ const d = modal.deleteRange(gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ t.vsel.active = false;
+ return;
+ };
+ setFileContent(gpa, f, d.content);
+ gpa.free(d.deleted);
+ t.cur_row = b.lo_row;
+ t.cur_col = b.lo_col;
+ clampCursorToFile(t, f);
+ } else {
+ clearEditsInRange(t, b.lo_row, b.hi_row);
+ t.cur_row = b.lo_row;
+ t.cur_col = b.lo_col;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ ensureCursorVisible(t);
+ }
+ t.mode = .insert;
+ t.pending = 0;
+ return;
+ }
if (t.msel.active) {
const r0 = @min(t.msel.r0, t.msel.r1);
const r1 = @max(t.msel.r0, t.msel.r1);
+ pushUndo(gpa, t);
if (t.file) |*f| {
// keep one empty line at r0, drop the rest; yank the removed text
var buf: std.ArrayList(u8) = .empty;
@@ -1460,6 +1791,7 @@ fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
t.pending = 0;
ensureCursorVisible(t);
} else if (t.file) |*f| {
+ pushUndo(gpa, t);
const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
const new = modal.deleteChar(gpa, f.content, c) catch return;
setFileContent(gpa, f, new);
@@ -1467,7 +1799,9 @@ fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) voi
t.cur_off = 0;
t.pending = 0;
} else {
- // terminal, no selection: just enter insert at the cursor
+ // terminal, no selection: just enter insert at the cursor (snapshot first so
+ // the typing that follows is undoable as one group).
+ pushUndo(gpa, t);
t.mode = .insert;
t.cur_off = 0;
t.pending = 0;
@@ -1506,14 +1840,15 @@ fn insertRunText(t: *Term, text: []const u8) void {
// page Ctrl-d/u (half) Ctrl-f/b (full) PgUp/PgDn
// viewport z t / z z / z b (top/center/bottom)
// insert i a I A o O (at/append/line-start/line-end/below/above)
-// select line x (extend down) delete d change c yank y paste p
-// chord Enter -> acme look (open file/dir) Tab -> acme execute (run word)
-// mode Esc -> insert exits to normal; Ctrl-b -> raw tty (terminals)
+// select v (char range, motions extend) x (whole lines, extend down)
+// edit delete d change c yank y paste p undo u redo U
+// chord Enter -> acme look (open file/dir) Tab -> acme execute (run it)
+// (act on the selection if one is active, else the word under cursor)
+// mode Esc collapses the selection; insert Esc -> normal; Ctrl-b -> raw tty
// window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col)
-// File panes mutate their content (real edits); terminals yank rendered text and
-// paste it as an insertion run (shell output can't be deleted, so d/c there only
-// clear pasted runs in the selection). Select mode (`v`, char ranges) is not yet
-// wired; use `x` for line-oriented selection.
+// File panes mutate their content (real edits) with whole-content undo/redo;
+// terminals yank rendered text and paste it as an insertion run (shell output can't
+// be deleted, so d/c there only clear pasted runs in the selected rows + yank).
// Enter raw tty mode (the Ctrl-b toggle; terminals only). tty-native: if the shell is at a
// prompt and the modal cursor sits on the current input line, move the shell's
// REAL cursor there via ghostty's promptClickMove (sends left/right arrows the
@@ -1545,6 +1880,7 @@ fn enterTty(t: *Term) void {
}
t.mode = .tty;
t.msel.active = false;
+ t.vsel.active = false;
t.pending = 0;
}
@@ -1556,6 +1892,15 @@ fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
const lines = pl.lines;
const half: usize = @max(1, t.rows / 2);
+ // Esc collapses any selection / cancels a pending prefix (helix). It does NOT
+ // drop terminals to tty — that's the deliberate Ctrl-b toggle.
+ if (key.matches(vaxis.Key.escape, .{})) {
+ t.vsel.active = false;
+ t.msel.active = false;
+ t.pending = 0;
+ return;
+ }
+
// prefix continuations
if (t.pending == 'g') {
t.pending = 0;
@@ -1613,12 +1958,15 @@ fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
if (key.matches('o', .{})) return enterInsert(gpa, talloc, t, .open_below);
if (key.matches('O', .{})) return enterInsert(gpa, talloc, t, .open_above);
- // line selection + edit (helix `x` then d/c/y; p pastes)
+ // selection + edit (helix `v` char range / `x` lines, then d/c/y; p pastes)
+ if (key.matches('v', .{})) return charSelect(t);
if (key.matches('x', .{})) return lineSelect(t);
if (key.matches('d', .{})) return normalDelete(gpa, talloc, t);
if (key.matches('c', .{})) return normalChange(gpa, talloc, t);
if (key.matches('y', .{})) return normalYank(gpa, talloc, t);
if (key.matches('p', .{})) return normalPaste(gpa, t);
+ if (key.matches('u', .{})) return doUndo(gpa, t);
+ if (key.matches('U', .{})) return doRedo(gpa, t);
}
// insert-mode key dispatch on a pane. File: real content editing; terminal: the
@@ -1682,11 +2030,24 @@ fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
return;
}
- // terminal: char-mode run insert (unchanged from the original editor)
+ // terminal: char-mode run insert. Editing only ever touches YOUR inserted runs
+ // (the shell's output underneath is immutable), so backspace erases within the
+ // run at the cursor and stops at its start.
if (!t.cur_pinned) pinCursor(t);
if (key.text) |text| {
insertRunText(t, text);
} else switch (key.codepoint) {
+ vaxis.Key.backspace => {
+ if (runAt(t, t.cur_row, t.cur_col)) |ei| {
+ const e = &t.edits[ei];
+ if (t.cur_off > 0 and e.len > 0) {
+ var k: usize = t.cur_off - 1;
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ t.cur_off -= 1;
+ }
+ }
+ },
vaxis.Key.left => {
if (t.cur_off > 0) t.cur_off -= 1 else {
t.cur_col = @max(0, t.cur_col - 1);
@@ -2137,11 +2498,11 @@ pub fn main(init: std.process.Init) !void {
} else if (mouse.button == .wheel_up) {
if (hovered) |id| paneScrollBy(terms[id].?, -1);
} else if (mouse.type == .press and mrow < TOPBAR_H) {
- // global tagbar: run the command under the click. Left OR middle
- // (middle for acme muscle memory; left so it also works where the
- // terminal swallows middle-click, e.g. ghostty). id=active is just
- // a valid pane for the action context; Kill/Newcol/Tutor ignore it.
- if (mouse.button == .left or mouse.button == .middle) {
+ // global tagbar: MIDDLE-click runs the command under the click
+ // (acme "execute"; left-click must NOT execute — a stray left-click
+ // on "Kill" would quit). id=active is just a valid pane for the
+ // action context; Kill/Newcol/Tutor ignore it.
+ if (mouse.button == .middle) {
const word = wordAtCol(topbar_str, mcol);
if (std.mem.eql(u8, word, "Debug")) {
show_debug = !show_debug;
@@ -2312,9 +2673,12 @@ pub fn main(init: std.process.Init) !void {
// anchors the cursor at the selection end. The row is
// stored absolute (visual + scroll off) so it rides
// the scrollback. tag clicks (row < BOX_H) don't edit.
+ // In tty mode we DON'T do this: the mouse stays usable
+ // for selecting/copying without yanking you out of the
+ // shell — the selection is still recorded above.
const sl = t.sel[@intFromEnum(Button.left)];
const body_vis = sl.r1 - @as(i32, BOX_H);
- if (body_vis >= 0) {
+ if (body_vis >= 0 and t.mode != .tty) {
const off: i32 = paneScroll(t);
t.cur_row = body_vis + off;
// file panes render a line-number prefix; cur_col
@@ -2402,18 +2766,26 @@ pub fn main(init: std.process.Init) !void {
break :kpress;
}
- // Normal-mode Enter/Tab mirror the acme mouse chords on the word
- // under the cursor: Enter = right-click "look" (open the file/dir),
- // Tab = middle-click "execute" (run it / builtin).
+ // Normal-mode Enter/Tab mirror the acme mouse chords: Enter =
+ // right-click "look" (open the file/dir), Tab = middle-click
+ // "execute" (run it / builtin). With an active selection (`v`/`x`)
+ // they act on the SELECTED text; otherwise on the file-ish word
+ // under the cursor.
if (at.mode == .normal and (key.matches(vaxis.Key.enter, .{}) or key.matches(vaxis.Key.tab, .{}))) {
pinCursor(at);
const btn: Button = if (key.matches(vaxis.Key.tab, .{})) .middle else .right;
- var sel = cursorWordSel(at);
- expandSel(gpa, at, &sel);
- if (selectionText(gpa, at, sel)) |word| {
- defer gpa.free(word);
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word)) break;
- } else |_| {}
+ if (currentSelText(frame_arena.allocator(), at)) |txt| {
+ at.vsel.active = false;
+ at.msel.active = false;
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt)) break;
+ } else {
+ var sel = cursorWordSel(at);
+ expandSel(gpa, at, &sel);
+ if (selectionText(gpa, at, sel)) |word| {
+ defer gpa.free(word);
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word)) break;
+ } else |_| {}
+ }
break :kpress;
}
@@ -2501,6 +2873,10 @@ pub fn main(init: std.process.Init) !void {
// ---- render ----
const win = vx.window();
win.clear();
+ // paint the inter-pane gaps (and any uncovered area) with the scrollbar
+ // track color so the gap reads as chrome, not raw black — most visible on
+ // the light theme. Panes paint over their own rects on top of this.
+ win.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } });
for (&terms, 0..) |*slot, id| {
if (slot.*) |t| {
@@ -2548,14 +2924,15 @@ pub fn main(init: std.process.Init) !void {
win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = gray.mid } } });
},
else => {
- // not dragging: tint the resize border under the cursor (matching
- // the press-handler hit tests) so the invisible drag zones show.
+ // not dragging: preview the resize border under the cursor with the
+ // same dashed line the drag shows, drawn on top (no background tint),
+ // so the invisible drag zones are discoverable on hover.
var c: usize = 0;
while (c + 1 < layout.ncol) : (c += 1) {
if (hover_col != col_x[c] + col_w[c]) continue;
var ry: u16 = TOPBAR_H;
while (ry < screen_h) : (ry += 1)
- win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } });
+ win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .rgb = gray.lit } } });
}
var cc: usize = 0;
while (cc < layout.ncol) : (cc += 1) {
@@ -2566,7 +2943,7 @@ pub fn main(init: std.process.Init) !void {
if (hover_row != r.y + r.h) continue;
var rx: u16 = col_x[cc];
while (rx < col_x[cc] + col_w[cc]) : (rx += 1)
- win.writeCell(rx, hover_row, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } });
+ win.writeCell(rx, hover_row, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.lit } } });
}
}
},
@@ -2744,7 +3121,7 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
var lbuf: [16]u8 = undefined;
// unsigned: Zig 0.16's {d} prints a leading '+' for signed ints.
const lineno: usize = @intCast(abs + 1);
- const prefix = std.fmt.bufPrint(&lbuf, "{d:0>4} ", .{lineno}) catch " ";
+ const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
var base: std.ArrayList(u8) = .empty;
defer base.deinit(alloc);
try base.appendSlice(alloc, prefix);
@@ -2774,7 +3151,9 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
if (i > 0) try out.append(alloc, '\n');
const raw = it.next() orelse "";
const line = if (flags[i]) "" else raw; // hidden prompt -> blank (edits still splice)
- try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line);
+ // raw tty mode shows the REAL terminal: the char-mode insertion overlay is
+ // hidden, so don't splice the runs — just emit the line as-is.
+ if (t.mode == .tty) try out.appendSlice(alloc, line) else try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line);
}
return out.toOwnedSlice(alloc);
}
diff --git a/modal.zig b/modal.zig
index 3038c430..2a195432 100644
--- a/modal.zig
+++ b/modal.zig
@@ -306,6 +306,38 @@ pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1:
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);
@@ -884,6 +916,29 @@ test "deleteLines only line" {
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;
diff --git a/term.zig b/term.zig
index c4fe0d93..9ef6ff02 100644
--- a/term.zig
+++ b/term.zig
@@ -15,9 +15,10 @@ pub const Sel = struct {
/// Modal input mode. normal/insert are the helix-style text-editing modes; tty
/// forwards keys to the shell (terminal panes only — file panes never use it).
/// normal -> helix NORMAL: hjkl/w/b/e/... move a block cursor; i/a/o -> insert;
-/// Esc -> tty (terminals) or stays (files).
+/// v/x select; Esc collapses the selection; Ctrl-b -> tty (terminals).
/// insert -> keys insert text at the cursor (acme click-and-type); Esc -> normal.
-/// tty -> keys -> pty as terminal sequences; a body left-click returns to normal.
+/// tty -> keys -> pty as terminal sequences. The mouse stays usable here (it
+/// selects/copies without dropping you back to normal); Ctrl-b exits.
pub const Mode = enum { normal, insert, tty };
/// A modal line selection (helix `x` extends the selection down line by line).
@@ -29,6 +30,16 @@ pub const LineSel = struct {
r1: i32 = 0,
};
+/// A modal character-range selection (helix `v` select mode). The anchor is here
+/// (row/col in the same coords as the cursor: ABSOLUTE body row; content col for
+/// files, visual col for terminals); the head is the cursor. Motions extend it;
+/// d/c/y act on [anchor, cursor] inclusive. Mutually exclusive with LineSel.
+pub const CharSel = struct {
+ active: bool = false,
+ row: i32 = 0,
+ col: i32 = 0,
+};
+
pub const EDIT_CAP = 128; // max chars in one insertion run
pub const MAX_EDITS = 16; // max concurrent insertion runs per window
@@ -40,6 +51,11 @@ pub const File = struct {
/// tree-sitter syntax styles, one byte per `content` byte (0 = none). Empty
/// when the file's language isn't highlighted. gpa-owned. See main.highlightFile.
highlights: []u8 = &.{},
+ /// dumb whole-content undo/redo: each entry is a gpa-owned snapshot of
+ /// `content`. main.pushUndo snapshots before an edit (one per insert session /
+ /// delete / paste); `u`/`U` move between them. Bounded (oldest dropped).
+ undo: std.ArrayList([]u8) = .empty,
+ redo: std.ArrayList([]u8) = .empty,
};
/// A run of inserted ("char mode") text. `col` is the ORIGINAL tty column it is
@@ -52,6 +68,17 @@ pub const Edit = struct {
text: [EDIT_CAP]u8 = undefined,
};
+/// A dumb whole-state snapshot of a terminal pane's char-mode insertions, for
+/// undo/redo (the terminal analogue of File's content snapshots). Plain value —
+/// the edits array has no pointers — so it copies by assignment, no allocation.
+pub const EditSnap = struct {
+ edits: [MAX_EDITS]Edit,
+ nedits: usize,
+ cur_row: i32,
+ cur_col: i32,
+ cur_off: u16,
+};
+
/// Persistent per-terminal state: the pty handle, the ghostty emulator and its
/// (stateful) VT parser, the background reader task, plus its layout weight and
/// text selection.
@@ -100,6 +127,9 @@ mode: Mode = .normal,
pending: u21 = 0,
/// modal line selection from `x` (helix extend-line). In absolute body rows.
msel: LineSel = .{},
+/// modal character-range selection from `v` (helix select). Anchor is here, head
+/// is the cursor. Mutually exclusive with `msel`.
+vsel: CharSel = .{},
/// cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll
/// offset) so they ride the scrollback, not the screen. `cur_off` is the offset
/// into the run sitting at (cur_row, cur_col). `cur_pinned` is false until the
@@ -114,6 +144,11 @@ cur_col: i32 = 0,
cur_off: u16 = 0,
nedits: usize = 0,
edits: [MAX_EDITS]Edit = @splat(.{}),
+/// terminal char-mode undo/redo: snapshots of the edit state, one per edit op
+/// (insert session / delete / paste). File panes use File.undo/redo instead;
+/// these stay empty for them. See main.pushUndo/doUndo.
+ed_undo: std.ArrayList(EditSnap) = .empty,
+ed_redo: std.ArrayList(EditSnap) = .empty,
/// A real shell pane (has a pty/emulator), as opposed to a file or image view.
pub fn isTerminal(self: *const Term) bool {
@@ -121,16 +156,22 @@ pub fn isTerminal(self: *const Term) bool {
}
pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
- if (self.file) |f| {
+ if (self.file) |*f| {
gpa.free(f.path);
gpa.free(f.content);
if (f.highlights.len > 0) gpa.free(f.highlights);
+ for (f.undo.items) |s| gpa.free(s);
+ for (f.redo.items) |s| gpa.free(s);
+ f.undo.deinit(gpa);
+ f.redo.deinit(gpa);
} else if (self.image) |iv| {
gpa.free(iv.path);
} else {
_ = self.reader.cancel(io) catch {};
self.pty.close(io);
}
+ self.ed_undo.deinit(gpa);
+ self.ed_redo.deinit(gpa);
self.stream.deinit();
self.term.deinit(gpa);
}
diff --git a/tests.zig b/tests.zig
index 51b4e83a..bd44e2ea 100644
--- a/tests.zig
+++ b/tests.zig
@@ -72,7 +72,9 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(600);
try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
try hs.pump(1200);
- try hs.send("\x1b[<0;8;3M\x1b[<0;8;3m"); // left-click the path on body row 0 -> normal + pin (+1 row: global tagbar)
+ try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
+ try hs.pump(600);
+ try hs.send("\x1b[<0;8;3M\x1b[<0;8;3m"); // left-click the path on body row 0 -> pin the cursor on it (+1 row: global tagbar)
try hs.pump(600);
try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
@@ -141,6 +143,12 @@ pub fn main(init: std.process.Init) !void {
try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)");
+ // 4a. The mouse stays in tty mode: a body left-click (press+release) must NOT
+ // drop to normal — only Ctrl-b exits tty now. The tag stays "sy".
+ try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click + release in the body
+ try hs.pump(500);
+ try hs.expectContains("sy /", "a left-click in tty mode wrongly returned to normal");
+
// 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the
// tag flips off "sy" (raw tty) back to "nm" (normal).
try hs.send("\x02"); // tty -> normal (Ctrl-b)
@@ -151,12 +159,14 @@ pub fn main(init: std.process.Init) !void {
// 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line +
// output). BOTH normal and insert hide the prompt/command rows (the acme
- // compose surface) while keeping output. Run a command in tty, left-click
- // to NORMAL, and check: output present, the command's prompt row hidden.
+ // compose surface) while keeping output. Run a command in tty, Ctrl-b to
+ // NORMAL, and check: output present, the command's prompt row hidden.
try hs.send("echo CMDHIDEZZ\r");
try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
try hs.pump(800); // let tty-mode frames settle
- try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click body -> normal (prompts now hidden) (+1 row: global tagbar)
+ try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
+ try hs.pump(600);
+ try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click body to pin the cursor (already normal) (+1 row: global tagbar)
try hs.pump(600);
try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
// The prompt row (with the typed command) is hidden in normal mode now too. We
@@ -267,6 +277,34 @@ pub fn main(init: std.process.Init) !void {
// sanity: the deep marker (line 150, above the opened/deleted line) survives.
try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");
+ // 10b. Char-wise select (`v`) + delete, then undo/redo. Open a line, type a
+ // marker, `0` to the line start, `v` to start a char selection, `$` to
+ // extend to the line end, `d` to delete the range (gone), `u` to undo it
+ // (back), `U` to redo (gone again). Proves v-select, real char-range
+ // deletion, and the dumb undo/redo stack on a file pane.
+ try hs.send("o"); // open line below -> insert
+ try hs.pump(500);
+ try hs.send("ZZVSELMARK");
+ try hs.pump(500);
+ try hs.send("\x1b"); // -> normal, cursor on the new line
+ try hs.pump(500);
+ try hs.expectContains("ZZVSELMARK", "char-select setup: typed marker not shown");
+ try hs.send("0"); // line start
+ try hs.pump(300);
+ try hs.send("v"); // start char selection
+ try hs.pump(300);
+ try hs.send("$"); // extend the selection to the line end
+ try hs.pump(300);
+ try hs.send("d"); // delete the selected char range
+ try hs.pump(800);
+ try hs.expectNotContains("ZZVSELMARK", "v + $ + d did not delete the char selection");
+ try hs.send("u"); // undo the delete
+ try hs.pump(800);
+ try hs.expectContains("ZZVSELMARK", "u (undo) did not restore the deleted text");
+ try hs.send("U"); // redo the delete
+ try hs.pump(800);
+ try hs.expectNotContains("ZZVSELMARK", "U (redo) did not re-apply the delete");
+
// 11. The Tutor builtin now lives in the GLOBAL tagbar (top row, full width):
// "Kill Newcol Tutor Debug". Clicking "Tutor" there spawns the tutor as a
// file pane. The tutor is embedded in the binary (@embedFile), so its
@@ -289,4 +327,69 @@ pub fn main(init: std.process.Init) !void {
try hs2.send("echo ZZTTYFLAG\r");
try hs2.expectWaitFor("ZZTTYFLAG", 8000, "--tty single shell did not forward input to the shell");
}
+
+ // 13. Terminal char-mode editing end to end (fresh instance for a clean pane):
+ // insert an overlay run, backspace part of it, Ctrl-b to tty (overlay hidden)
+ // and back (overlay returns), then u (undo the whole insert) / U (redo).
+ {
+ var hs3 = try Harness.init(gpa, exe.ptr, 24, 80);
+ defer hs3.deinit();
+ try hs3.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ // pin the cursor on the top-left pane and type a marker (insert mode).
+ try hs3.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // left-click -> normal + pin (+1 row: global tagbar)
+ try hs3.pump(400);
+ try hs3.send("i");
+ try hs3.pump(300);
+ try hs3.send("ZZTERMINSXYZ");
+ try hs3.pump(500);
+ try hs3.expectContains("ZZTERMINSXYZ", "terminal insert did not show the typed overlay");
+ // insert-mode backspace erases within the run: drop the trailing "XYZ".
+ try hs3.send("\x7f\x7f\x7f");
+ try hs3.pump(500);
+ try hs3.expectNotContains("ZZTERMINSXYZ", "terminal insert-mode backspace did not erase typed text");
+ try hs3.expectContains("ZZTERMINS", "backspace erased too much of the run");
+ try hs3.send("\x1b"); // insert -> normal
+ try hs3.pump(400);
+ try hs3.send("\x02"); // normal -> tty: the overlay is hidden
+ try hs3.pump(700);
+ try hs3.expectNotContains("ZZTERMINS", "tty mode did not hide the inserted-text overlay");
+ try hs3.send("\x02"); // tty -> normal: the overlay returns
+ try hs3.pump(700);
+ try hs3.expectContains("ZZTERMINS", "overlay did not return after leaving tty mode");
+ try hs3.send("u"); // undo the whole insert session
+ try hs3.pump(600);
+ try hs3.expectNotContains("ZZTERMINS", "u (undo) did not remove the terminal insertion");
+ try hs3.send("U"); // redo
+ try hs3.pump(600);
+ try hs3.expectContains("ZZTERMINS", "U (redo) did not restore the terminal insertion");
+ }
+
+ // 14. Normal-mode Tab/Enter act on the SELECTION when one is active (not the
+ // word under the cursor). Clear a fresh pane, type a computed command as an
+ // overlay, `x` to select the whole line, Tab -> the SELECTION runs. The
+ // output marker (ZZSEL5X) differs from the source ($((2+3))) so it only
+ // matches if the selection actually executed.
+ {
+ var hs4 = try Harness.init(gpa, exe.ptr, 24, 80);
+ defer hs4.deinit();
+ try hs4.expectWaitFor("build.zig", 8000, "fresh terminal never auto-ls'd");
+ try hs4.send("\x02"); // normal -> tty
+ try hs4.pump(500);
+ try hs4.send("clear\r"); // blank the pane, prompt to row 0
+ try hs4.pump(900);
+ try hs4.send("\x02"); // tty -> normal
+ try hs4.pump(400);
+ try hs4.send("\x1b[<0;2;6M\x1b[<0;2;6m"); // pin a blank low row near col 0
+ try hs4.pump(400);
+ try hs4.send("i");
+ try hs4.pump(300);
+ try hs4.send("echo ZZSEL$((2+3))X"); // overlay; output is "ZZSEL5X"
+ try hs4.pump(500);
+ try hs4.send("\x1b"); // -> normal
+ try hs4.pump(400);
+ try hs4.send("x"); // select the whole typed line
+ try hs4.pump(400);
+ try hs4.send("\t"); // Tab: execute the SELECTION (not the word under cursor)
+ try hs4.expectWaitFor("ZZSEL5X", 8000, "Tab on a selection did not execute the selected text");
+ }
}