summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig485
1 files changed, 432 insertions, 53 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);
}