diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /main.zig | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 923 |
1 files changed, 820 insertions, 103 deletions
@@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const ghostty_vt = @import("ghostty-vt"); const Term = @import("term.zig"); +const modal = @import("modal.zig"); extern "c" fn forkpty( amaster: *c_int, @@ -45,6 +46,18 @@ pub const Command = struct { } = .nop; }; +// Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style +// `y` yanks the (line) selection into here; `p` pastes it after the cursor. +var yank_reg: ?[]u8 = null; +fn freeYank(gpa: std.mem.Allocator) void { + if (yank_reg) |y| gpa.free(y); + yank_reg = null; +} +fn setYank(gpa: std.mem.Allocator, text: []const u8) void { + freeYank(gpa); + yank_reg = gpa.dupe(u8, text) catch null; +} + const Loop = vaxis.Loop(@TypeOf(Command.value)); // layout constants @@ -54,6 +67,7 @@ const MINW: u16 = 10; // min pane width (cells) const MINH: u16 = 3; // min pane height (cells) const MAX_TERMS: usize = 16; const MAX_COLS: usize = 6; +const PREFIX_W: u16 = 5; // file-pane line-number prefix width: "%4d " const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; @@ -97,15 +111,20 @@ const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a }; // 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 } }; const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; +// modal line-selection (helix `x`) highlight: a warm tan so it reads as +// keyboard-driven, distinct from the mouse selections above. +const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; +const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; // Tag builtins. Middle-clicking the name in a window's tag runs the action; // `builtins_str` is the space-joined name list, produced at comptime. -const SelectAction = enum { none, send, newcol, delcol, del }; +const SelectAction = enum { none, send, newcol, delcol, del, tutor }; const Builtin = struct { name: []const u8, action: SelectAction }; const builtins = [_]Builtin{ .{ .name = "Newcol", .action = .newcol }, .{ .name = "Delcol", .action = .delcol }, .{ .name = "Del", .action = .del }, + .{ .name = "Tutor", .action = .tutor }, }; const builtins_str = blk: { var s: []const u8 = ""; @@ -116,11 +135,14 @@ const builtins_str = blk: { // Path of the generated bash rc that adds OSC 133 semantic prompts (shell // integration). bash --rcfile reads it for interactive shells; it sources the // user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That -// marks the prompt rows so we can hide them in text mode. +// marks the prompt rows so we can hide them in text mode. The A marker also +// carries cl=line, opting in to ghostty's promptClickMove (arrow-key +// repositioning of the shell cursor within the typed input) so enterTty can +// land the shell cursor where the modal cursor navigated (start vs end). const rc_path = "/tmp/pardes-osc133.bash"; const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]' + \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" \\trap 'printf "\e]133;C\a"' DEBUG \\ @@ -224,8 +246,12 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 { if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str }); var link: [1024]u8 = undefined; const cwd = termCwd(t, &link); - // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal). - const mode = if (t.editing) "ch" else "sy"; + // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty). + const mode = switch (t.mode) { + .normal => "nm", + .insert => "in", + .tty => "sy", + }; return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str }); } @@ -345,6 +371,35 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1 return t; } +// Open the built-in Tutor as a read/editable FILE pane: same machinery as +// openFileView, but the content is the tutor text embedded at comptime (so the +// binary is self-contained — no external file needed) and the path is a fixed +// label. Middle-clicking "Tutor" in any tag spawns this. Caller wires it into +// the layout + frees later. +const tutor_text = @embedFile("tutor.txt"); +fn openTutorView(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Term { + const content = try gpa.dupe(u8, tutor_text); + errdefer gpa.free(content); + const path = try gpa.dupe(u8, "/Tutor"); + errdefer gpa.free(path); + const t = try gpa.create(Term); + errdefer gpa.destroy(t); + t.* = .{ + .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } }, + .pid = 0, + .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }), + .stream = undefined, + .reader = .{ .any_future = null, .result = {} }, + .file = .{ .path = path, .content = content, .scroll = 0 }, + .cols = cols, + .rows = rows, + .cur_pinned = true, + .cur_row = 0, + }; + t.stream = t.term.vtStream(); + return t; +} + fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void { t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop }); } @@ -583,15 +638,42 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a } } } + // modal line selection (helix `x`): highlight the selected body rows across + // the text width. Only in normal mode; r0/r1 are absolute body rows. + if (t.mode == .normal and t.msel.active) { + const lo = @min(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H); + const hi = @max(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H); + 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; + while (col < r.w - GUTTER) : (col += 1) { + if (text.readCell(col, row)) |cell| { + var nc = cell; + nc.default = false; + nc.style.bg = .{ .rgb = msel_bg }; + nc.style.fg = .{ .rgb = msel_fg }; + text.writeCell(col, row, nc); + } + } + } + } if (active) { - if (t.editing) { - // text mode: cursor at the (scroll-adjusted) edit position. until the - // user pins it (click/type) it tracks the shell cursor. + if (t.mode != .tty) { + // text mode (normal/insert): cursor at the (scroll-adjusted) edit + // position. until the user pins it (click/type) it tracks the shell cursor. const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off; const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x)); const coff = if (t.cur_pinned) t.cur_off else 0; const prow = crow - off + @as(i32, BOX_H); - const cx = renderedCol(t, crow, ccol, coff); + // insert mode uses the edit-run coordinate -> visual mapping; normal + // mode keeps cur_col already-visual; file panes add the line-number prefix. + const cx = if (t.file != null) + @as(i32, PREFIX_W) + ccol + @as(i32, coff) + else if (t.mode == .insert) + renderedCol(t, crow, ccol, coff) + else + ccol; if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER)) text.showCursor(@intCast(cx), @intCast(prow)); } else { @@ -638,6 +720,657 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a } } +// ---- modal editing helpers (wired onto Term by the key handler) ---- + +const PaneLines = struct { + lines: []const []const u8, + row0: i32, // absolute row of lines[0]; cursor lines-row = cur_row - row0 + body: ?[]u8, // terminal only: bodyText temp; arena-owned, freed by caller +}; + +// The lines the cursor moves over. File: all content lines (row0 = 0). Terminal: +// the viewport rows (row0 = scroll offset). `alloc` is per-frame scratch. +fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines { + if (t.file) |f| { + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, f.content, '\n'); + while (it.next()) |ln| try ls.append(alloc, ln); + return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = null }; + } + const body = try bodyText(alloc, t); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitAny(u8, body, "\n"); + while (it.next()) |ln| try ls.append(alloc, ln); + return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = paneScroll(t), .body = body }; +} + +fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void { + alloc.free(pl.lines); + if (pl.body) |b| alloc.free(b); +} + +fn toModalCursor(t: *Term, pl: PaneLines) modal.Cursor { + const r: i32 = t.cur_row - pl.row0; + return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, t.cur_col)) }; +} + +// write a modal Cursor back to the Term (absolute row, pin, clear run offset) +fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void { + t.cur_row = @as(i32, @intCast(c.row)) + pl.row0; + t.cur_col = @intCast(c.col); + t.cur_off = 0; + t.cur_pinned = true; +} + +// apply a motion result: move cursor, collapse any line selection, keep it visible +fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void { + fromModalCursor(t, pl, c); + t.msel.active = false; + t.pending = 0; + ensureCursorVisible(t); +} + +fn ensureCursorVisible(t: *Term) void { + const off = paneScroll(t); + const last = off + @as(i32, t.rows) - 1; + if (t.cur_row < off) paneScrollBy(t, t.cur_row - off) + else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last); +} + +fn alignViewportTop(t: *Term) void { + paneScrollBy(t, t.cur_row - paneScroll(t)); +} +fn alignViewportBottom(t: *Term) void { + paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows) - 1)); +} +fn alignViewportCenter(t: *Term) void { + paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows / 2))); +} + +// pin the cursor to the shell's current cursor (terminal) or the scroll top +// (file), the first time the user interacts in normal/insert mode. +fn pinCursor(t: *Term) void { + if (t.cur_pinned) return; + const off: i32 = paneScroll(t); + if (t.file != null) { + t.cur_row = off; + t.cur_col = 0; + } else { + t.cur_row = @as(i32, @intCast(t.term.screens.active.cursor.y)) + off; + t.cur_col = @intCast(t.term.screens.active.cursor.x); + } + t.cur_off = 0; + t.cur_pinned = true; +} + +// the last navigable row: file -> last content line; terminal -> viewport bottom. +fn lastNavRow(t: *Term) i32 { + if (t.file) |f| return @intCast(modal.lineCount(f.content) -| 1); + return paneScroll(t) + @as(i32, t.rows) - 1; +} + +const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; + +// enter insert mode, positioning the cursor per `where`. For file panes, +// 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); + const pl = paneCursorLines(talloc, t) catch { + t.mode = .insert; + t.msel.active = false; + t.pending = 0; + return; + }; + defer freePaneLines(talloc, pl); + const cur = toModalCursor(t, pl); + const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; + switch (where) { + .at => {}, + .append => fromModalCursor(t, pl, modal.charRight(pl.lines, cur)), + .line_start => fromModalCursor(t, pl, modal.firstNonWsOf(pl.lines, cur)), + .line_end => { + t.cur_col = @intCast(llen); + t.cur_pinned = true; + }, + .open_below => { + if (t.file) |*f| { + const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return; + gpa.free(f.content); + f.content = new; + t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0; + t.cur_col = 0; + } else { + const bottom = paneScroll(t) + @as(i32, t.rows) - 1; + t.cur_row = @min(bottom, t.cur_row + 1); + t.cur_col = 0; + } + t.cur_off = 0; + t.cur_pinned = true; + }, + .open_above => { + if (t.file) |*f| { + const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return; + gpa.free(f.content); + f.content = new; + // current line shifts down; the new empty line is at cur.row + t.cur_col = 0; + } else { + const top = paneScroll(t); + t.cur_row = @max(top, t.cur_row - 1); + t.cur_col = 0; + } + t.cur_off = 0; + t.cur_pinned = true; + }, + } + // run-offset for the terminal insert model: `i` inserts before the run at the + // cursor (off 0); the others append after any run already sitting there. + 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.pending = 0; + ensureCursorVisible(t); +} + +// helix `x`: extend the line selection down by one (or start it at the cursor). +fn lineSelect(t: *Term) void { + if (!t.msel.active) { + t.msel = .{ .active = true, .r0 = t.cur_row, .r1 = t.cur_row }; + } else { + const last = lastNavRow(t); + t.msel.r1 = @min(t.msel.r1 + 1, last); + t.cur_row = t.msel.r1; + } + t.cur_col = 0; + t.cur_off = 0; + t.cur_pinned = true; + t.pending = 0; +} + +// drop all insertion runs whose row is in [r0, r1] (terminal delete/change). +fn clearEditsInRange(t: *Term, r0: i32, r1: i32) void { + var w: usize = 0; + for (t.edits[0..t.nedits]) |e| { + if (e.row >= r0 and e.row <= r1) continue; + t.edits[w] = e; + w += 1; + } + t.nedits = w; +} + +// join the rendered body rows [r0, r1] (absolute) with '\n'. `alloc` is scratch; +// caller (setYank) copies it into the gpa-owned register before the frame ends. +fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 { + 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 < r0 or abs > r1) continue; + if (abs > r0) out.append(alloc, '\n') catch return ""; + out.appendSlice(alloc, ln) catch return ""; + } + return out.toOwnedSlice(alloc) catch ""; +} + +fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { + if (t.msel.active) { + const r0 = @min(t.msel.r0, t.msel.r1); + const r1 = @max(t.msel.r0, t.msel.r1); + if (t.file) |*f| { + const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch { + t.msel.active = false; + return; + }; + gpa.free(f.content); + f.content = d.content; + setYank(gpa, d.deleted); + gpa.free(d.deleted); + const n = modal.lineCount(f.content); + t.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1)); + t.cur_col = 0; + } else { + clearEditsInRange(t, r0, r1); + setYank(gpa, yankRows(talloc, t, r0, r1)); + } + t.msel.active = false; + t.cur_off = 0; + t.cur_pinned = true; + ensureCursorVisible(t); + } else if (t.file) |*f| { + 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; + gpa.free(f.content); + f.content = new; + } // terminal, no selection: can't delete shell output -> no-op +} + +fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { + if (t.msel.active) { + 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; + var r: usize = @intCast(@max(0, r0)); + const hi: usize = @intCast(@max(0, r1)); + const lo: usize = @intCast(@max(0, r0)); + while (r <= hi) : (r += 1) { + if (r > lo) buf.append(gpa, '\n') catch break; + buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break; + } + setYank(gpa, buf.items); + buf.deinit(gpa); + } else { + setYank(gpa, yankRows(talloc, t, r0, r1)); + } + t.cur_row = r0; + t.cur_col = 0; + t.msel.active = false; + } else if (t.file) |f| { + setYank(gpa, modal.lineSlice(f.content, @intCast(@max(0, t.cur_row)))); + } else { + setYank(gpa, yankRows(talloc, t, t.cur_row, t.cur_row)); + } +} + +fn normalPaste(gpa: std.mem.Allocator, t: *Term) void { + const y = yank_reg orelse return; + if (t.file) |*f| { + const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return; + gpa.free(f.content); + f.content = new; + t.cur_row += 1; + t.cur_col = 0; + } else { + // terminal: spill the yanked text into an insertion run at the cursor + insertRunText(t, y); + } + t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; + t.cur_pinned = true; + ensureCursorVisible(t); +} + +fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { + if (t.msel.active) { + const r0 = @min(t.msel.r0, t.msel.r1); + const r1 = @max(t.msel.r0, t.msel.r1); + if (t.file) |*f| { + // keep one empty line at r0, drop the rest; yank the removed text + var buf: std.ArrayList(u8) = .empty; + var r: usize = @intCast(@max(0, r0)); + const hi: usize = @intCast(@max(0, r1)); + const lo: usize = @intCast(@max(0, r0)); + while (r <= hi) : (r += 1) { + if (r > lo) buf.append(gpa, '\n') catch break; + buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break; + } + setYank(gpa, buf.items); + buf.deinit(gpa); + if (r1 > r0) { + const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch { + t.msel.active = false; + return; + }; + gpa.free(f.content); + f.content = d.content; + gpa.free(d.deleted); + } + const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch { + t.msel.active = false; + return; + }; + gpa.free(f.content); + f.content = cl; + t.cur_row = r0; + t.cur_col = 0; + } else { + clearEditsInRange(t, r0, r1); + setYank(gpa, yankRows(talloc, t, r0, r1)); + t.cur_row = r0; + t.cur_col = 0; + } + t.msel.active = false; + t.cur_off = 0; + t.cur_pinned = true; + t.mode = .insert; + t.pending = 0; + ensureCursorVisible(t); + } else if (t.file) |*f| { + 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; + gpa.free(f.content); + f.content = new; + t.mode = .insert; + t.cur_off = 0; + t.pending = 0; + } else { + // terminal, no selection: just enter insert at the cursor + t.mode = .insert; + t.cur_off = 0; + t.pending = 0; + } +} + +// insert `text` into the terminal run model at the cursor (the existing char-mode +// splice). Factored out so `p`aste reuses it. +fn insertRunText(t: *Term, text: []const u8) void { + var idx: ?usize = runAt(t, t.cur_row, t.cur_col); + if (idx == null and t.nedits < Term.MAX_EDITS) { + t.edits[t.nedits] = .{ .row = t.cur_row, .col = t.cur_col }; + t.nedits += 1; + idx = t.nedits - 1; + } + if (idx) |ei| { + const e = &t.edits[ei]; + for (text) |ch| { + if (e.len >= Term.EDIT_CAP) break; + if (t.cur_off > e.len) t.cur_off = e.len; + var k: usize = e.len; + while (k > t.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; + e.text[t.cur_off] = ch; + e.len += 1; + t.cur_off += 1; + } + } +} + +// normal-mode (helix-style) key dispatch on a pane. The cursor is a block that +// moves over the rendered text (file content lines, or the terminal viewport); +// paneCursorLines builds that view per keystroke. Bindings (helix defaults, +// adapted to the acme text model): +// move h j k l (or arrows) word w b e / W B E (long) +// line 0 $ ^ (or home/end) goto g g (top) G (last) g e g h g l +// 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 +// mode Esc -> insert exits to normal; Enter -> tty (terminals only) +// 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. +// Enter tty mode from normal (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 +// shell understands) — so dropping into tty lands where you navigated: at the +// prompt start when the input is empty, or at/after typed text at the end. Then +// hand the keyboard to the pty. If the shell isn't at a prompt (outputting) or +// the cursor is above the prompt, just switch — the shell cursor stays put. +// Requires OSC 133 with cl=line (our bash_rc emits it). +fn enterTty(t: *Term) void { + const screen = t.term.screens.active; + if (t.term.cursorIsAtPrompt()) { + const off = paneScroll(t); + const vp_row: i32 = t.cur_row - off; + if (vp_row >= 0) { + if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, t.cur_col)), .y = @intCast(vp_row) } })) |click_pin| { + const cursor_pin = screen.cursor.page_pin.*; + var pit = cursor_pin.promptIterator(.left_up, null); + if (pit.next()) |prompt_pin| { + if (!click_pin.before(prompt_pin)) { + const m = screen.promptClickMove(click_pin); + var i: usize = 0; + while (i < m.left) : (i += 1) termWrite(t, "\x1b[D"); + i = 0; + while (i < m.right) : (i += 1) termWrite(t, "\x1b[C"); + } + } + } + } + } + t.mode = .tty; + t.msel.active = false; + t.pending = 0; +} + +fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void { + pinCursor(t); + // terminals: Enter drops into tty, positioning the shell cursor at the modal + // cursor's spot on the input line (start if empty, end if typed text). + if (t.file == null and key.matches(vaxis.Key.enter, .{})) return enterTty(t); + const pl = paneCursorLines(talloc, t) catch return; + defer freePaneLines(talloc, pl); + const cur = toModalCursor(t, pl); + const lines = pl.lines; + const half: usize = @max(1, t.rows / 2); + + // prefix continuations + if (t.pending == 'g') { + t.pending = 0; + if (key.matches('g', .{})) return applyMove(t, pl, modal.gotoFirst()); + if (key.matches('e', .{})) return applyMove(t, pl, modal.gotoLast(lines)); + if (key.matches('h', .{})) return applyMove(t, pl, modal.lineStart(cur)); + if (key.matches('l', .{})) return applyMove(t, pl, modal.lineEnd(lines, cur)); + return; + } + if (t.pending == 'z') { + t.pending = 0; + if (key.matches('t', .{})) return alignViewportTop(t); + if (key.matches('z', .{})) return alignViewportCenter(t); + if (key.matches('b', .{})) return alignViewportBottom(t); + return; + } + if (key.matches('g', .{})) { + t.pending = 'g'; + return; + } + if (key.matches('z', .{})) { + t.pending = 'z'; + return; + } + + // movement + if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return applyMove(t, pl, modal.charLeft(cur)); + if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return applyMove(t, pl, modal.charRight(lines, cur)); + if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return applyMove(t, pl, modal.lineDown(lines, cur)); + if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return applyMove(t, pl, modal.lineUp(lines, cur)); + if (key.matches('w', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, false)); + if (key.matches('b', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, false)); + if (key.matches('e', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, false)); + if (key.matches('W', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, true)); + if (key.matches('B', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, true)); + if (key.matches('E', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, true)); + if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) return applyMove(t, pl, modal.lineStart(cur)); + if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) return applyMove(t, pl, modal.lineEnd(lines, cur)); + if (key.matches('^', .{})) return applyMove(t, pl, modal.firstNonWsOf(lines, cur)); + if (key.matches('G', .{})) return applyMove(t, pl, modal.gotoLast(lines)); + if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{})) + return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, half))); + if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{})) + return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, half))); + if (key.matches('f', .{ .ctrl = true })) + return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, t.rows)))); + if (key.matches('b', .{ .ctrl = true })) + return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, t.rows)))); + + // insert entry + if (key.matches('i', .{})) return enterInsert(gpa, talloc, t, .at); + if (key.matches('a', .{})) return enterInsert(gpa, talloc, t, .append); + if (key.matches('I', .{})) return enterInsert(gpa, talloc, t, .line_start); + if (key.matches('A', .{})) return enterInsert(gpa, talloc, t, .line_end); + 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) + 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); +} + +// insert-mode key dispatch on a pane. File: real content editing; terminal: the +// char-mode run splice (text shifts the underlying tty row right). +fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void { + if (t.file) |*f| { + const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }; + if (key.text) |text| { + const new = modal.insertAt(gpa, f.content, c, text) catch return; + gpa.free(f.content); + f.content = new; + for (text) |ch| { + if (ch == '\n') { + t.cur_row += 1; + t.cur_col = 0; + } else t.cur_col += 1; + } + t.cur_pinned = true; + ensureCursorVisible(t); + return; + } + switch (key.codepoint) { + vaxis.Key.enter => { + const new = modal.insertAt(gpa, f.content, c, "\n") catch return; + gpa.free(f.content); + f.content = new; + t.cur_row += 1; + t.cur_col = 0; + t.cur_pinned = true; + ensureCursorVisible(t); + }, + vaxis.Key.backspace => { + if (t.cur_col > 0) { + const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return; + gpa.free(f.content); + f.content = new; + t.cur_col -= 1; + } else if (t.cur_row > 0) { + const prevlen = modal.lineSlice(f.content, c.row - 1).len; + const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return; + gpa.free(f.content); + f.content = new; + t.cur_row -= 1; + t.cur_col = @intCast(prevlen); + } + t.cur_pinned = true; + ensureCursorVisible(t); + }, + vaxis.Key.left, vaxis.Key.right, vaxis.Key.up, vaxis.Key.down => { + const pl = paneCursorLines(talloc, t) catch return; + defer freePaneLines(talloc, pl); + const cur0 = toModalCursor(t, pl); + const nc = switch (key.codepoint) { + vaxis.Key.left => modal.charLeft(cur0), + vaxis.Key.right => modal.charRight(pl.lines, cur0), + vaxis.Key.up => modal.lineUp(pl.lines, cur0), + vaxis.Key.down => modal.lineDown(pl.lines, cur0), + else => cur0, + }; + fromModalCursor(t, pl, nc); + ensureCursorVisible(t); + }, + else => {}, + } + return; + } + + // terminal: char-mode run insert (unchanged from the original editor) + if (!t.cur_pinned) pinCursor(t); + if (key.text) |text| { + insertRunText(t, text); + } else switch (key.codepoint) { + vaxis.Key.left => { + if (t.cur_off > 0) t.cur_off -= 1 else { + t.cur_col = @max(0, t.cur_col - 1); + t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; + } + }, + vaxis.Key.right => { + const rl: u16 = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; + if (t.cur_off < rl) t.cur_off += 1 else { + t.cur_col += 1; + t.cur_off = 0; + } + }, + vaxis.Key.up => { + const off = paneScroll(t); + t.cur_row = @max(off, t.cur_row - 1); + t.cur_off = 0; + }, + vaxis.Key.down => { + const off = paneScroll(t); + t.cur_row = @min(off + @as(i32, t.rows) - 1, t.cur_row + 1); + t.cur_off = 0; + }, + else => {}, + } +} + +// tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as +// the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...). +fn forwardToPty(t: *Term, key: vaxis.Key) void { + var cbuf: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.mods.ctrl) { + const cp = key.codepoint; + if (cp >= 'a' and cp <= 'z') { + cbuf[0] = @intCast(cp - 0x60); + break :blk cbuf[0..1]; + } + if (cp == '@') { + cbuf[0] = 0; + break :blk cbuf[0..1]; + } + if (cp >= '[' and cp <= '_') { + cbuf[0] = @intCast(cp - 0x40); + break :blk cbuf[0..1]; + } + if (cp >= 'A' and cp <= 'Z') { + cbuf[0] = @intCast(cp - 0x40); + break :blk cbuf[0..1]; + } + } + if (key.text) |text| break :blk text; + break :blk switch (key.codepoint) { + vaxis.Key.enter => "\r", + vaxis.Key.backspace => "\x7f", + vaxis.Key.tab => "\t", + vaxis.Key.escape => "\x1b", + vaxis.Key.up => "\x1b[A", + vaxis.Key.down => "\x1b[B", + vaxis.Key.right => "\x1b[C", + vaxis.Key.left => "\x1b[D", + else => null, + }; + }; + if (bytes) |b| termWrite(t, b); +} + +// move focus to the neighbor pane in `dir`, if any. Rects are the current tiling. +fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, dir: enum { left, right, up, down }) void { + const a = rects[active.*]; + var best: ?usize = null; + var best_d: i32 = 0; + for (terms, 0..) |slot, i| { + if (slot == null or i == active.*) continue; + const r = rects[i]; + const vov = a.y < r.y + r.h and r.y < a.y + a.h; // vertical overlap + const hov = a.x < r.x + r.w and r.x < a.x + a.w; // horizontal overlap + const ok = switch (dir) { + .left => r.x + r.w <= a.x and vov, + .right => r.x >= a.x + a.w and vov, + .up => r.y + r.h <= a.y and hov, + .down => r.y >= a.y + a.h and hov, + }; + if (!ok) continue; + const d: i32 = switch (dir) { + .left => a.x - (r.x + r.w), + .right => r.x - (a.x + a.w), + .up => a.y - (r.y + r.h), + .down => r.y - (a.y + a.h), + }; + if (best == null or d < best_d) { + best = i; + best_d = d; + } + } + if (best) |b| active.* = b; +} + pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; @@ -650,6 +1383,7 @@ pub fn main(init: std.process.Init) !void { // the retained vaxis screen can keep referencing it through vx.render(). var frame_arena: std.heap.ArenaAllocator = .init(gpa); defer frame_arena.deinit(); + defer freeYank(gpa); var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render() var tty_buf: [0x10000]u8 = undefined; @@ -715,6 +1449,7 @@ pub fn main(init: std.process.Init) !void { var drag: Drag = .none; var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it var hover_row: u16 = 0; + var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus while (true) { const event = try loop.nextEvent(); @@ -1034,14 +1769,39 @@ pub fn main(init: std.process.Init) !void { } else break; } }, + .tutor => { + // spawn the Tutor as a file pane in the LEFT + // column (acme convention for files), stacked + // at the top with a fair share of the column. + const slot: ?usize = for (terms, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| { + if (openTutorView(gpa, screen_w, screen_h)) |nt| { + terms[free] = nt; + const lc = 0; + const ln = layout.col_n[lc]; + if (ln > 0) { + var vsum: f32 = 0; + for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| { + vsum += p.vweight; + }; + nt.vweight = vsum / @as(f32, @floatFromInt(ln)); + } + layoutInsert(&layout, lc, 0, free); + active = free; + evictLonePristineTty(&layout, &terms, io, gpa, lc, free, &active); + } else |_| {} + } + }, } } else |_| {} if (s.button == .left) { - // left-click in the body enters char mode and - // anchors the cursor at the selection end. the - // row is stored absolute (visual + scroll off) so - // it rides the scrollback. clicks on the tag (row - // < BOX_H) don't edit. + // left-click in the body enters NORMAL mode and + // anchors the cursor at the selection end (a + // visual column). The row is stored absolute + // (visual + scroll off) so it rides the scrollback. + // clicks on the tag (row < BOX_H) don't edit. const sl = t.sel[@intFromEnum(Button.left)]; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0) { @@ -1050,7 +1810,9 @@ pub fn main(init: std.process.Init) !void { t.cur_col = sl.c1; t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; t.cur_pinned = true; - t.editing = true; + t.mode = .normal; + t.msel.active = false; + t.pending = 0; } } } @@ -1060,10 +1822,27 @@ pub fn main(init: std.process.Init) !void { drag = .none; } }, - .key_press => |key| { + .key_press => |key| kpress: { if (key.matches('c', .{ .ctrl = true })) break; if (terms[active]) |at| { + // Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l). + if (ctrl_w_pending) { + ctrl_w_pending = false; + if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left) + else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right) + else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up) + else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down); + if (terms[active]) |nt| nt.pending = 0; + break :kpress; + } + if (key.matches('w', .{ .ctrl = true })) { + ctrl_w_pending = true; + break :kpress; + } + + // global window ops (any mode): Alt-n new terminal below, + // Alt-c move active terminal into a fresh column. if (key.matches('n', .{ .alt = true })) { // new terminal below the active one, in its column. // ponytail: forks from the now-multithreaded process; @@ -1080,97 +1859,31 @@ pub fn main(init: std.process.Init) !void { active = slot; } else |_| {} } - } else if (key.matches('c', .{ .alt = true })) { + break :kpress; + } + if (key.matches('c', .{ .alt = true })) { // move active terminal into a fresh column const f = layoutFindTerm(&layout, active).?; if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) { layoutRemove(&layout, active); layoutAppendColumn(&layout, active); } - } else if (key.matches('d', .{ .ctrl = true })) { - paneScrollBy(at, 15); - } else if (key.matches('u', .{ .ctrl = true })) { - paneScrollBy(at, -15); - } else if (at.editing and at.file == null and key.matches(vaxis.Key.escape, .{})) { - at.editing = false; // leave char mode (don't forward Esc); files have no tty mode - } else if (at.editing) { - // char mode: printable text inserts at the cursor (shifting - // the current run); arrows move the cursor; backspace/etc. - // are no-ops for now (deletion comes later). - if (key.text) |text| { - // first keystroke pins the cursor (it was tracking the - // shell cursor until now). - if (!at.cur_pinned) { - const coff: i32 = paneScroll(at); - if (at.file != null) { - at.cur_row = coff; - at.cur_col = 0; - } else { - at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff; - at.cur_col = @intCast(at.term.screens.active.cursor.x); - } - at.cur_off = 0; - at.cur_pinned = true; - } - // find or create the run at the cursor - var idx: ?usize = runAt(at, at.cur_row, at.cur_col); - if (idx == null and at.nedits < Term.MAX_EDITS) { - at.edits[at.nedits] = .{ .row = at.cur_row, .col = at.cur_col }; - at.nedits += 1; - idx = at.nedits - 1; - } - if (idx) |ei| { - const e = &at.edits[ei]; - for (text) |ch| { - if (e.len >= Term.EDIT_CAP) break; - if (at.cur_off > e.len) at.cur_off = e.len; - var k: usize = e.len; - while (k > at.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; - e.text[at.cur_off] = ch; - e.len += 1; - at.cur_off += 1; - } - } - } else switch (key.codepoint) { - vaxis.Key.left => { - if (at.cur_off > 0) at.cur_off -= 1 else { - at.cur_col = @max(0, at.cur_col - 1); - at.cur_off = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0; - } - }, - vaxis.Key.right => { - const rl: u16 = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0; - if (at.cur_off < rl) at.cur_off += 1 else { - at.cur_col += 1; - at.cur_off = 0; - } - }, - vaxis.Key.up => { - const off: i32 = paneScroll(at); - at.cur_row = @max(off, at.cur_row - 1); - at.cur_off = 0; - }, - vaxis.Key.down => { - const off: i32 = paneScroll(at); - at.cur_row = @min(off + @as(i32, at.rows) - 1, at.cur_row + 1); - at.cur_off = 0; - }, - else => {}, - } - } else { - // sym mode: forward to the pty as terminal input. - const bytes: ?[]const u8 = if (key.text) |text| text else switch (key.codepoint) { - vaxis.Key.enter => "\r", - vaxis.Key.backspace => "\x7f", - vaxis.Key.tab => "\t", - vaxis.Key.escape => "\x1b", - vaxis.Key.up => "\x1b[A", - vaxis.Key.down => "\x1b[B", - vaxis.Key.right => "\x1b[C", - vaxis.Key.left => "\x1b[D", - else => null, - }; - if (bytes) |b| termWrite(at, b); + break :kpress; + } + + // per-mode dispatch. Esc: insert -> normal (normal Esc is a + // no-op, like helix). Enter from normal reaches tty on terminals + // (handled in handleNormal). tty forwards every key to the pty. + switch (at.mode) { + .normal => handleNormal(gpa, frame_arena.allocator(), at, key), + .insert => { + if (key.matches(vaxis.Key.escape, .{})) { + at.mode = .normal; + at.msel.active = false; + at.pending = 0; + } else handleInsert(gpa, frame_arena.allocator(), at, key); + }, + .tty => forwardToPty(at, key), } } }, @@ -1461,11 +2174,15 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 { const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try t.term.plainString(alloc); defer alloc.free(body); - // text mode (editing) hides shell prompt rows; tty mode shows everything. + // text mode hides shell prompt rows only in INSERT mode (the acme compose + // surface: a clean blank line to type into). NORMAL mode shows the real + // terminal — prompts + typed input visible — so the modal cursor can navigate + // to the input line and Enter drops into tty at that spot (start vs end). + // tty mode shows everything. var flagbuf: [512]bool = undefined; const nrows: usize = @min(t.rows, flagbuf.len); const flags = flagbuf[0..nrows]; - if (t.editing) promptFlags(t, flags) else @memset(flags, false); + if (t.mode == .insert) promptFlags(t, flags) else @memset(flags, false); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(alloc); // pad to the full grid height: plainString trims trailing blank rows, but we |
