diff options
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 485 |
1 files changed, 432 insertions, 53 deletions
@@ -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); } |
