From 651af1db938bcbaa039d14b65ff6bc184a3d91b4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 30 Jun 2026 11:43:52 -0300 Subject: vibes cleaning slop --- main.zig | 534 +++++++++++++++++++++++++++++++++++++++----------------------- tests.zig | 166 ++++++++++++++----- 2 files changed, 463 insertions(+), 237 deletions(-) diff --git a/main.zig b/main.zig index 49413eec..7221c5eb 100644 --- a/main.zig +++ b/main.zig @@ -213,6 +213,28 @@ fn paneScrollBy(t: *Term, delta: i32) void { } } +// Before running a composed command in a terminal, push the shell down so its +// output lands BELOW the last row the user typed overlay text on. The shell owns +// its cursor, so we move it the only way we can: emit newlines to the pty until +// the prompt sits at max(last-edit row, the shell's current prompt row). The empty +// prompts this creates are hidden in text mode (promptFlags), so the gap reads as +// blank padding and the output flows right under the composed text. No-op unless +// the shell is at a prompt (don't inject newlines into a running program). +// ponytail: pad caps at the grid height — you can't click/type past the pane. +fn padOutputBelowEdits(t: *Term) void { + if (t.file != null or t.nedits == 0) return; + if (!t.term.cursorIsAtPrompt()) return; + var max_row: i32 = std.math.minInt(i32); + for (t.edits[0..t.nedits]) |e| { + if (e.len > 0 and e.row > max_row) max_row = e.row; + } + if (max_row == std.math.minInt(i32)) return; + const cursor_abs: i32 = paneScroll(t) + @as(i32, @intCast(t.term.screens.active.cursor.y)); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, t.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) termWrite(t, "\r"); +} + // The shell's current working directory (via /proc//cwd). `buf` holds it. // A raw linux syscall returns -errno; check the sign directly. (std.posix.errno // is the LIBC convention here — `rc == -1` — and we link libc, so it misreads a @@ -242,8 +264,11 @@ fn isDir(path: [*:0]const u8) bool { // The window tag: the shell's cwd followed by the builtin command names. Plain, // selectable text (acme-style). Caller frees with `alloc`. fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 { - // a file pane's tag is just its path (+ builtins); no tty mode/cwd. - if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str }); + // a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty. + if (t.file) |f| { + const fmode = if (t.mode == .insert) "in" else "nm"; + return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ fmode, f.path, builtins_str }); + } var link: [1024]u8 = undefined; const cwd = termCwd(t, &link); // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty). @@ -518,6 +543,40 @@ fn evictLonePristineTty(l: *Layout, terms: *[MAX_TERMS]?*Term, io: std.Io, gpa: if (active.* == tty_id) active.* = keep_id; } +// Find a terminal whose shell cwd is `dir`, else fork a fresh shell there and +// stack it at the bottom of the rightmost column. Returns the terminal to drive, +// or null if no slot is free / the fork failed. Backs middle-click "send" from a +// file pane: the selected line runs in a shell in the file's dir. Does NOT change +// focus — middle/execute keeps you where you were editing (right/look focuses). +// ponytail: rightmost column; with a single column the tty stacks under the file. +fn ttyForDir( + io: std.Io, + gpa: std.mem.Allocator, + loop: *Loop, + terms: *[MAX_TERMS]?*Term, + layout: *Layout, + sw: u16, + sh: u16, + dir: []const u8, +) ?*Term { + for (terms.*) |slot| if (slot) |tt| { + if (tt.file != null) continue; + var cb: [1024]u8 = undefined; + if (std.mem.eql(u8, termCwd(tt, &cb), dir)) return tt; + }; + const free: usize = for (terms.*, 0..) |slot, i| { + if (slot == null) break i; + } else return null; + var dirbuf: [1024]u8 = undefined; + const dirz = std.fmt.bufPrintZ(&dirbuf, "{s}", .{dir}) catch return null; + const nt = forkTerm(gpa, sw, sh, dirz.ptr) catch return null; + terms[free] = nt; + startReader(io, gpa, loop, nt, free) catch {}; + const rc = if (layout.ncol > 0) layout.ncol - 1 else 0; + layoutInsert(layout, rc, layout.col_n[rc], free); + return nt; +} + // Tile the screen: columns by col_weight, then each column's windows by vweight. // Last column/window absorbs rounding so the tiling always fills the screen. // `out_*` written for every live term and live column. @@ -1087,13 +1146,14 @@ fn insertRunText(t: *Term, text: []const u8) void { // 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) +// chord Enter -> acme look (open file/dir) Tab -> acme execute (run word) +// mode Esc -> insert exits to normal; Ctrl-b -> raw tty (terminals) // 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 +// 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 // shell understands) — so dropping into tty lands where you navigated: at the @@ -1129,9 +1189,6 @@ fn enterTty(t: *Term) void { 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); @@ -1371,6 +1428,213 @@ fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_ if (best) |b| active.* = b; } +// Perform an acme button-2/3 chord with `txt` as the text, from pane `id`: +// .right = "look" (open the file/dir under the word, peeling a :line suffix), +// .middle = "execute" (a builtin, else run the text in a shell). Shared by the +// mouse release and the normal-mode Enter (look) / Tab (execute) keys. Returns +// true if the app should quit (the last pane was closed). +fn actOnSelection( + io: std.Io, + gpa: std.mem.Allocator, + loop: *Loop, + terms: *[MAX_TERMS]?*Term, + layout: *Layout, + rects: *const [MAX_TERMS]Rect, + active: *usize, + sw: u16, + sh: u16, + button: Button, + id: usize, + txt: []const u8, +) bool { + const t = terms[id] orelse return false; + if (button == .right) { + // acme "look": resolve the selected word (peeling a :line suffix) relative + // to the window's dir. A directory opens/focuses a terminal there + ls; a + // file opens/focuses a file pane scrolled to :line. + const word_raw = std.mem.trim(u8, txt, " \t\r\n"); + const pl = parsePathLine(word_raw); + const word = pl.path; + var cwdbuf: [1024]u8 = undefined; + var joinbuf: [2048]u8 = undefined; + var realbuf: [4096]u8 = undefined; + const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf); + const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/') + (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null) + else + (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null); + if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) { + const dir = std.mem.span(rp); + // focus an existing terminal on this dir + var found: ?usize = null; + for (terms, 0..) |*slot, i| if (slot.*) |tt| { + var cb: [1024]u8 = undefined; + if (std.mem.eql(u8, termCwd(tt, &cb), dir)) { + found = i; + break; + } + }; + if (found) |fid| { + active.* = fid; + if (terms[fid]) |tt| termWrite(tt, "ls\r"); + } else { + const slot: ?usize = for (terms.*, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| { + if (forkTerm(gpa, sw, sh, rp)) |nt| { + terms[free] = nt; + startReader(io, gpa, loop, nt, free) catch {}; + const f = layoutFindTerm(layout, id).?; + layoutInsert(layout, f.col, f.idx + 1, free); + splitBelow(t, nt, rects[id].h); + active.* = free; + termWrite(nt, "ls\r"); + } else |_| {} + } + } + } else { + const fp = std.mem.span(rp); + // focus an existing file pane for this path + var found: ?usize = null; + for (terms, 0..) |*slot, i| if (slot.*) |tt| { + if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) { + found = i; + break; + }; + }; + if (found) |fid| { + active.* = fid; + if (pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff| + if (pl.line <= fileLineCount(ff.content)) { + ff.scroll = pl.line - 1; + }; + } else { + const slot: ?usize = for (terms.*, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| { + if (openFileView(gpa, rp, pl.line, sw, sh)) |nt| { + terms[free] = nt; + // files live in the LEFT column (acme convention). Stack at its + // top with a fair share of the column height, then drop a + // leftover unused shell. + 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 |_| {} + } + } + }; + return false; + } + switch (onSelect(button, txt)) { + .none => {}, + .send => { + // terminal: run in itself. file pane: run in a shell sitting in the + // file's directory (found by cwd, else a fresh tty in the right column). + const cmd = std.mem.trim(u8, txt, " \t\r\n"); + const dst: ?*Term = if (t.file) |f| + ttyForDir(io, gpa, loop, terms, layout, sw, sh, std.fs.path.dirname(f.path) orelse "/") + else + t; + if (dst) |dt| { + padOutputBelowEdits(dt); + termWrite(dt, cmd); + termWrite(dt, "\r"); + } + }, + .newcol => { + // new terminal in a fresh column; ls its directory. + const slot: ?usize = for (terms.*, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| if (layout.ncol < MAX_COLS) { + if (forkTerm(gpa, sw, sh, null)) |nt| { + terms[free] = nt; + startReader(io, gpa, loop, nt, free) catch {}; + layoutAppendColumn(layout, free); + active.* = free; + termWrite(nt, "ls\r"); + } else |_| {} + }; + }, + .del => { + // close just this window + absorbVWeight(layout, terms, id); + layoutRemove(layout, id); + t.deinit(io, gpa); + gpa.destroy(t); + terms[id] = null; + if (active.* == id) active.* = for (terms.*, 0..) |sl, i| { + if (sl != null) break i; + } else return true; + }, + .delcol => { + // close every terminal in this one's column + if (layoutFindTerm(layout, id)) |f| { + var ids: [MAX_TERMS]usize = undefined; + const nids = layout.col_n[f.col]; + for (0..nids) |k| ids[k] = layout.col_terms[f.col][k]; + for (ids[0..nids]) |tid| { + if (terms[tid]) |tt| { + layoutRemove(layout, tid); + tt.deinit(io, gpa); + gpa.destroy(tt); + terms[tid] = null; + } + } + if (terms[active.*] == null) active.* = for (terms.*, 0..) |sl, i| { + if (sl != null) break i; + } else return true; + } + }, + .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, sw, sh)) |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 |_| {} + } + }, + } + return false; +} + +// The word under the modal cursor as a pane-local selection in paneText coords +// (row 0 is the tag; file panes carry the line-number prefix). Feed it through +// expandSel + selectionText to get the acme chord text for the Enter/Tab keys. +fn cursorWordSel(t: *Term) Term.Sel { + const vrow = (t.cur_row - paneScroll(t)) + @as(i32, BOX_H); + const vcol = if (t.file != null) t.cur_col + @as(i32, PREFIX_W) else t.cur_col; + return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; +} + pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; @@ -1423,6 +1687,9 @@ pub fn main(init: std.process.Init) !void { terms[0] = try forkTerm(gpa, screen_w, screen_h, null); terms[1] = try forkTerm(gpa, screen_w, screen_h, null); terms[2] = try forkTerm(gpa, screen_w, screen_h, null); + // a fresh shell greets you with its directory listing (the readers below pick + // up the output once they start). + for (terms[0..3]) |slot| if (slot) |t| termWrite(t, "ls\r"); layout.ncol = 2; layout.col_n[0] = 2; layout.col_terms[0][0] = 0; @@ -1526,19 +1793,21 @@ pub fn main(init: std.process.Init) !void { } } } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) { - // middle/right select text too (own state + color), text area only + // middle/right select text too (own state + color), text area only. + // Right focuses the pane (acme "look" lands you there); middle + // (execute) does NOT — you stay where you were editing. const b: Button = if (mouse.button == .middle) .middle else .right; if (hovered) |id| { const r = rects[id]; if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) { // gutter (scrollbar): right scrolls down, mirroring left's up - active = id; + if (b == .right) active = id; if (terms[id]) |t| { const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); paneScrollBy(t, if (b == .right) local else -local); } } else if (mcol >= r.x + GUTTER) { - active = id; + if (b == .right) active = id; if (terms[id]) |t| { const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); @@ -1623,197 +1892,33 @@ pub fn main(init: std.process.Init) !void { .select => |s| { if (terms[s.id]) |t| { t.sel[@intFromEnum(s.button)].state = .done; - // a no-drag middle/right click expands to the word - // under the cursor (acme button-2/3 behavior). - if (s.button != .left) expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); - if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { - defer gpa.free(txt); - if (s.button == .right) { - // acme "look": resolve the selected word - // (peeling a :line suffix) relative to the - // window's dir. A directory opens/focuses a - // terminal there + ls; a file opens/focuses a - // file pane scrolled to :line. - const word_raw = std.mem.trim(u8, txt, " \t\r\n"); - const pl = parsePathLine(word_raw); - const word = pl.path; - var cwdbuf: [1024]u8 = undefined; - var joinbuf: [2048]u8 = undefined; - var realbuf: [4096]u8 = undefined; - const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf); - const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/') - (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null) - else - (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null); - if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) { - const dir = std.mem.span(rp); - // focus an existing terminal on this dir - var found: ?usize = null; - for (&terms, 0..) |*slot, i| if (slot.*) |tt| { - var cb: [1024]u8 = undefined; - if (std.mem.eql(u8, termCwd(tt, &cb), dir)) { - found = i; - break; - } - }; - if (found) |id| { - active = id; - if (terms[id]) |tt| termWrite(tt, "ls\r"); - } else { - const slot: ?usize = for (terms, 0..) |sl, i| { - if (sl == null) break i; - } else null; - if (slot) |free| { - if (forkTerm(gpa, screen_w, screen_h, rp)) |nt| { - terms[free] = nt; - startReader(io, gpa, &loop, nt, free) catch {}; - const f = layoutFindTerm(&layout, s.id).?; - layoutInsert(&layout, f.col, f.idx + 1, free); - splitBelow(t, nt, rects[s.id].h); - active = free; - termWrite(nt, "ls\r"); - } else |_| {} - } - } - } else { - const fp = std.mem.span(rp); - // focus an existing file pane for this path - var found: ?usize = null; - for (&terms, 0..) |*slot, i| if (slot.*) |tt| { - if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) { - found = i; - break; - }; - }; - if (found) |id| { - active = id; - if (pl.line > 0) if (terms[id]) |tt| if (tt.file) |*ff| - if (pl.line <= fileLineCount(ff.content)) { - ff.scroll = pl.line - 1; - }; - } else { - const slot: ?usize = for (terms, 0..) |sl, i| { - if (sl == null) break i; - } else null; - if (slot) |free| { - if (openFileView(gpa, rp, pl.line, screen_w, screen_h)) |nt| { - terms[free] = nt; - // files live in the LEFT column (acme - // convention). Stack at its top with a - // fair share of the column height, then - // drop a leftover unused shell. - 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 switch (onSelect(s.button, txt)) { - .none => {}, - .send => { - // run the selection in the terminal it came from - termWrite(t, std.mem.trim(u8, txt, " \t\r\n")); - termWrite(t, "\r"); - }, - .newcol => { - // new terminal in a fresh column - const slot: ?usize = for (terms, 0..) |sl, i| { - if (sl == null) break i; - } else null; - if (slot) |free| if (layout.ncol < MAX_COLS) { - if (forkTerm(gpa, screen_w, screen_h, null)) |nt| { - terms[free] = nt; - startReader(io, gpa, &loop, nt, free) catch {}; - layoutAppendColumn(&layout, free); - active = free; - } else |_| {} - }; - }, - .del => { - // close just this window - absorbVWeight(&layout, &terms, s.id); - layoutRemove(&layout, s.id); - t.deinit(io, gpa); - gpa.destroy(t); - terms[s.id] = null; - if (active == s.id) active = for (terms, 0..) |sl, i| { - if (sl != null) break i; - } else break; - }, - .delcol => { - // close every terminal in this one's column - if (layoutFindTerm(&layout, s.id)) |f| { - var ids: [MAX_TERMS]usize = undefined; - const nids = layout.col_n[f.col]; - for (0..nids) |k| ids[k] = layout.col_terms[f.col][k]; - for (ids[0..nids]) |tid| { - if (terms[tid]) |tt| { - layoutRemove(&layout, tid); - tt.deinit(io, gpa); - gpa.destroy(tt); - terms[tid] = null; - } - } - if (terms[active] == null) active = for (terms, 0..) |sl, i| { - if (sl != null) break i; - } 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 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. + // 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. const sl = t.sel[@intFromEnum(Button.left)]; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0) { const off: i32 = paneScroll(t); t.cur_row = body_vis + off; - t.cur_col = sl.c1; + // file panes render a line-number prefix; cur_col + // is content-relative, so peel PREFIX_W off the click. + t.cur_col = if (t.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else 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.mode = .normal; t.msel.active = false; t.pending = 0; } + } else { + // middle/right: acme execute/look. A no-drag click + // expands to the file-ish word under the cursor first. + expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); + if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { + defer gpa.free(txt); + if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, s.button, s.id, txt)) break; + } else |_| {} } } }, @@ -1857,6 +1962,7 @@ pub fn main(init: std.process.Init) !void { const f = layoutFindTerm(&layout, active).?; layoutInsert(&layout, f.col, f.idx + 1, slot); active = slot; + termWrite(nt, "ls\r"); } else |_| {} } break :kpress; @@ -1871,9 +1977,37 @@ pub fn main(init: std.process.Init) !void { break :kpress; } + // Ctrl-b: hidden toggle in/out of raw tty mode (terminals only). + // tty forwards every key straight to the pty, so it's deliberately + // off the normal editing path — this toggle is the only way in/out. + if (key.matches('b', .{ .ctrl = true })) { + if (at.file == null) { + if (at.mode == .tty) { + at.mode = .normal; + at.pending = 0; + } else enterTty(at); + } + 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). + 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, btn, active, word)) break; + } else |_| {} + 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. + // no-op, like helix). Ctrl-b toggles raw tty on terminals (above). + // tty forwards every key to the pty. switch (at.mode) { .normal => handleNormal(gpa, frame_arena.allocator(), at, key), .insert => { @@ -1889,7 +2023,15 @@ pub fn main(init: std.process.Init) !void { }, .pty_read => |pr| { defer gpa.free(pr.bytes); - if (terms[pr.id]) |t| t.stream.nextSlice(pr.bytes); + if (terms[pr.id]) |t| { + t.stream.nextSlice(pr.bytes); + // follow new output to the bottom, so a command's result shows + // even in a pane you didn't focus. Exception: the pane you're + // actively navigating in a text mode — don't yank the cursor out + // from under you (it still tracks the bottom on its own there). + if (!(pr.id == active and t.mode != .tty)) + t.term.screens.active.scroll(.active); + } }, .pty_eof => |id| { if (terms[id]) |t| { @@ -2174,15 +2316,13 @@ 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 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. + // text mode (normal AND insert) hides shell prompt rows: a clean acme-style + // page to navigate and type on. Only raw tty mode (the Ctrl-b toggle) shows + // the real terminal with its prompts + typed input. var flagbuf: [512]bool = undefined; const nrows: usize = @min(t.rows, flagbuf.len); const flags = flagbuf[0..nrows]; - if (t.mode == .insert) promptFlags(t, flags) else @memset(flags, false); + if (t.mode != .tty) 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 diff --git a/tests.zig b/tests.zig index c389b8cf..504dad4d 100644 --- a/tests.zig +++ b/tests.zig @@ -16,6 +16,16 @@ const config = @import("config"); // Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right. // A drag is press at start + release at end (with different coords). +// Row index (0-based) of the first screen line containing `needle`, or null. +fn rowOf(text: []const u8, needle: []const u8) ?usize { + var it = std.mem.splitScalar(u8, text, '\n'); + var i: usize = 0; + while (it.next()) |line| : (i += 1) { + if (std.mem.indexOf(u8, line, needle) != null) return i; + } + return null; +} + pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const args = try init.minimal.args.toSlice(init.arena.allocator()); @@ -42,54 +52,110 @@ pub fn main(init: std.process.Init) !void { // 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor). try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)"); - // 4. Enter switches to TTY mode from normal and forwards keystrokes to the - // shell. (Esc no longer reaches tty; Enter is the tty-enter key.) + // 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app + // boots in the project dir, so a known file shows up as command output + // (visible even in normal mode — only prompt rows are hidden). + try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open"); + + // 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor. + // Drop a one-line executable, echo its path onto body row 0 (clear homes the + // cursor first), left-click the path to enter normal + pin the cursor on it, + // then TAB. It runs the word in place (no new pane) -> the script's marker. + try hs.send("\x02"); // normal -> tty (Ctrl-b) + try hs.pump(600); + try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r"); + try hs.pump(1200); + try hs.send("\x1b[<0;8;2M\x1b[<0;8;2m"); // left-click the path on body row 0 -> normal + pin + try hs.pump(600); + try hs.send("\t"); // TAB: execute "/tmp/zztabcmd" + try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor"); + try hs.pump(400); + + // 3d. OUTPUT PADDING: a command composed (typed as an overlay) far below the + // prompt runs BELOW where it was typed, not back up at the prompt. The app + // pads the shell down with newlines to max(last-edit row, prompt row); the + // padding prompts are hidden in text mode, so the output flows under the + // composed line. Clear the pane (prompt -> row 0), type a command on a low + // row whose OUTPUT marker (PAD13Z) differs from its source (echo PAD...), + // run it, and assert the output row is BELOW the typed row. (Screen is 30x100 + // from step 2; terms[0] is the ~15-row top-left pane.) + try hs.send("\x02"); // normal -> tty + try hs.pump(400); + try hs.send("clear\r"); // prompt back to row 0 + try hs.pump(900); + try hs.send("\x02"); // tty -> normal + try hs.pump(400); + try hs.send("\x1b[<0;5;10M\x1b[<0;5;10m"); // left-click a low blank row (screen row 9) -> normal + pin + try hs.pump(400); + try hs.send("i"); // insert + try hs.pump(400); + try hs.send("echo PAD$((6+7))Z"); // overlay; output is "PAD13Z" + try hs.pump(600); + try hs.send("\x1b"); // -> normal + try hs.pump(400); + try hs.send("\x1b[<1;5;10M\x1b[<33;21;10M\x1b[<1;21;10m"); // middle-drag-select the line -> run (padded) + try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run"); + try hs.pump(600); + { + const text = try hs.screenText(); + defer gpa.free(text); + const typed = rowOf(text, "echo PAD") orelse { + hs.dump("composed line vanished before the padding check"); + return error.ExpectFailed; + }; + const outp = rowOf(text, "PAD13Z") orelse { + hs.dump("padded output not found on screen"); + return error.ExpectFailed; + }; + if (outp <= typed) { + hs.dump("output did not land below the composed command (padding failed)"); + return error.ExpectFailed; + } + // and it should land RIGHT under the composed line (tight alignment), not + // somewhere far below — the hidden padding prompts fill the gap above it. + if (outp - typed > 2) { + hs.dump("padded output landed too far below the composed line (misaligned)"); + return error.ExpectFailed; + } + } + // tidy up: drop the lingering overlay so it doesn't ride the scrollback into + // later steps (cursor is on the typed row; x selects it, d clears the run). + try hs.send("x"); + try hs.pump(300); + try hs.send("d"); + try hs.pump(300); + + // 4. Ctrl-b toggles raw TTY mode from normal and forwards keystrokes to the + // shell. tty is a hidden toggle (Ctrl-b), off the normal editing workflow. const marker = "PARDES_E2E"; - try hs.send("\r"); // normal -> tty (Enter) + try hs.send("\x02"); // normal -> tty (Ctrl-b) try hs.pump(600); try hs.send("echo " ++ marker ++ "\r"); try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell"); - try hs.expectContains("sy /", "no 'sy' indicator after Enter (tty mode)"); - - // 4b. Enter-to-tty POSITIONING (the start-vs-end distinction). Still in tty: - // clear to a clean screen, type input WITHOUT running it, go to normal, - // navigate the modal cursor to the last char of the typed text, then - // Enter. enterTty uses ghostty's promptClickMove to move the shell's real - // cursor there, so the next typed char inserts BEFORE that last char. - // Running it yields "ABCDEXF" — proving the shell cursor was repositioned - // mid-input. A no-op Enter (cursor stays at end) would append -> "ABCDEFX". - try hs.send("clear\r"); - try hs.pump(800); - try hs.send("echo ABCDEF"); // typed input, NOT run (no \r) - try hs.pump(800); - try hs.send("\x1b[<0;3;2M\x1b[<0;3;2m"); // left-click body row 0, col 0 (col 3 = past gutter) -> normal - try hs.pump(600); - try hs.send("$"); // modal: end of row 0 (lands on 'F') - try hs.pump(600); - try hs.send("\r"); // Enter: normal -> tty, shell cursor moved onto 'F' + try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)"); + + // 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the + // tag flips off "sy" (raw tty) back to "nm" (normal). + try hs.send("\x02"); // tty -> normal (Ctrl-b) try hs.pump(600); - try hs.send("X\r"); // insert 'X' before 'F', then run -> "ABCDEXF" - try hs.expectWaitFor("ABCDEXF", 10000, "Enter did not reposition the shell cursor mid-input (no ABCDEXF)"); + try hs.expectContains("nm /", "Ctrl-b did not toggle tty back to normal"); + try hs.send("\x02"); // normal -> tty again for the prompt-hiding step try hs.pump(600); - try hs.expectNotContains("ABCDEFX", "Enter left the shell cursor at the end (appended X -> ABCDEFX)"); - // 5. Prompt hiding. In tty mode a command line + its output are both visible. - // Normal mode shows the real terminal (prompts visible); INSERT mode is the - // acme compose surface, hiding prompt/command rows but keeping the output. - // Run a command, left-click to normal, press `i` to enter insert, then - // check: output present, prompt + command absent. + // 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line + + // output). BOTH normal and insert hide the prompt/command rows (the acme + // compose surface) while keeping output. Run a command in tty, left-click + // to NORMAL, and check: output present, the command's prompt row hidden. try hs.send("echo CMDHIDEZZ\r"); try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command"); try hs.pump(800); // let tty-mode frames settle - try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal - try hs.pump(600); - try hs.send("i"); // normal -> insert (prompts now hidden) + try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal (prompts now hidden) try hs.pump(600); - try hs.expectContains("CMDHIDEZZ", "command output not shown in insert mode"); - // The prompt row (with the typed command) is hidden in insert mode. We check - // "echo CMDHIDEZZ" (specific to this pane's prompt row) rather than - // "goblin@" because other panes in normal mode show their own prompts. - try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in insert mode (prompt row not hidden)"); + try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode"); + // The prompt row (with the typed command) is hidden in normal mode now too. We + // check "echo CMDHIDEZZ" (this pane's prompt row) rather than "goblin@" since a + // raw-tty pane elsewhere could show its own prompt. + try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in normal mode (prompt row not hidden)"); // 6. Insert-mode editing + middle-click execute. Click a blank row to pin the // cursor at text col 0, type a computed echo, then middle-select those 15 @@ -103,16 +169,36 @@ pub fn main(init: std.process.Init) !void { try hs.pump(600); // let the run build try hs.send("\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text"); - // leave insert -> normal so the next step can reach tty via Enter + // leave insert -> normal so the next step can reach tty via Ctrl-b try hs.send("\x1b"); try hs.pump(400); + // 6b. FOLLOW OUTPUT: new tty output scrolls the pane to the bottom, so a result + // shows even after you've scrolled up. Fill the pane, wheel UP off the + // bottom (LN40 disappears), then run a command — the pane snaps back and the + // new marker is visible. Done in tty mode so the active pane follows too. + try hs.send("\x02"); // normal -> tty + try hs.pump(400); + try hs.send("clear; for i in $(seq 1 40); do echo LN$i; done\r"); + try hs.expectWaitFor("LN40", 8000, "filler output never appeared"); + try hs.pump(500); + { + var w: usize = 0; + while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;5M"); // wheel UP over the pane + } + try hs.pump(500); + try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom"); + try hs.send("echo ZZFOLLOW\r"); + try hs.expectWaitFor("ZZFOLLOW", 8000, "new output did not scroll the pane back to the bottom"); + try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b) + try hs.pump(400); + // 7. Right-click a directory opens (acme "look") a terminal there and ls's // it. Make a dir with a unique entry, then `clear` and echo its path so the // path lands as contiguous output on body row 0. Right-click (no drag) on // it: the new terminal lists the dir, so the unique entry appears only if // the directory was actually opened. - try hs.send("\r"); // normal -> tty (Enter) + try hs.send("\x02"); // normal -> tty (Ctrl-b) try hs.pump(700); try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r"); try hs.pump(2000); // let it run + settle @@ -180,7 +266,7 @@ pub fn main(init: std.process.Init) !void { // intro) appears on screen. Middle-click is button 1 (SGR: <1;...M). // Tag row 0 is the first row of any pane; "Tutor" sits at the end of the // tag. Click somewhere in the tag's right half to hit it. - try hs.send("\x1b[<1;45;1M\x1b[<1;45;1m"); // middle-click the tag row (pane 0) + try hs.send("\x1b[<1;48;1M\x1b[<1;48;1m"); // middle-click "Tutor" in the tag row (file pane's "nm " prefix shifts it +3) try hs.expectWaitFor("PARDES TUTOR", 10000, "middle-click 'Tutor' did not spawn the tutor pane"); try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)"); -- cgit v1.3