summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-29 20:17:20 -0300
committerGabriel Schneider <[email protected]>2026-06-29 21:37:01 -0300
commitb52e1e7274770fdfdf859938cb01095a8cc1a320 (patch)
treee6aad47c2b5c80037c026653fbd204ad387f49a5
parent3c1fef20894772e0cb48c7c2ae2c1a7ded48970d (diff)
downloadpardes-b52e1e7274770fdfdf859938cb01095a8cc1a320.tar.gz
pardes-b52e1e7274770fdfdf859938cb01095a8cc1a320.zip
vibes acme-like text editing?
-rw-r--r--main.zig200
-rw-r--r--term.zig27
-rw-r--r--tests.zig70
3 files changed, 287 insertions, 10 deletions
diff --git a/main.zig b/main.zig
index 3debdf65..181c2f58 100644
--- a/main.zig
+++ b/main.zig
@@ -86,6 +86,9 @@ const gray = struct {
// 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 };
+// 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 };
// per-button selection highlight: three gray levels, muted text.
const SEL_BG = [3][3]u8{ .{ 0x38, 0x38, 0x38 }, .{ 0x4c, 0x4c, 0x4c }, .{ 0x60, 0x60, 0x60 } };
const sel_fg = [3]u8{ 0xc0, 0xc0, 0xc0 };
@@ -141,7 +144,9 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
else => "?",
};
};
- return std.fmt.allocPrint(alloc, "{s} {s}", .{ cwd, builtins_str });
+ // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal).
+ const mode = if (t.editing) "ch" else "sy";
+ return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str });
}
// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it
@@ -321,11 +326,34 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// be hidden until we source ghostty's integration script or wrap PS1/
// PROMPT_COMMAND with the 133;A/B/C/D markers in each spawned pty. Best as a
// toggle (a Hide/Show tag builtin) so it degrades gracefully without integration.
- const tty_str = try t.term.plainString(alloc);
- var it = std.mem.splitAny(u8, tty_str, "\n");
+ // body = tty rows with insertion runs spliced in (text shifts, not overdrawn).
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ const body = try bodyText(alloc, t);
+ var it = std.mem.splitAny(u8, body, "\n");
var i: u16 = 0;
while (it.next()) |line| : (i += 1) {
- _ = text.printSegment(.{ .text = line }, .{ .row_offset = BOX_H + i });
+ _ = text.printSegment(.{ .text = line }, .{ .row_offset = BOX_H + i, .wrap = .none });
+ }
+
+ // 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| {
+ if (e.len == 0) continue;
+ const prow = e.row - off + @as(i32, BOX_H);
+ if (prow < 0 or prow >= @as(i32, r.h)) continue;
+ const start = renderedCol(t, e.row, e.col, 0);
+ var k: u16 = 0;
+ while (k < e.len) : (k += 1) {
+ const cc = start + @as(i32, k);
+ if (cc < 0 or cc >= @as(i32, r.w - GUTTER)) continue;
+ 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 };
+ text.writeCell(@intCast(cc), @intCast(prow), nc);
+ }
+ }
}
// recolor selected cells (pane-local visual coords), one pass per button —
@@ -352,7 +380,17 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
}
- if (active) text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y + BOX_H);
+ if (active) {
+ if (t.editing) {
+ // char mode: cursor at the rendered cursor position (scroll-adjusted).
+ const prow = t.cur_row - off + @as(i32, BOX_H);
+ const cx = renderedCol(t, t.cur_row, t.cur_col, t.cur_off);
+ 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 {
+ text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y + BOX_H);
+ }
+ }
// 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 });
@@ -663,6 +701,22 @@ pub fn main(init: std.process.Init) !void {
},
}
} 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.
+ const sl = t.sel[@intFromEnum(Button.left)];
+ const body_vis = sl.r1 - @as(i32, BOX_H);
+ if (body_vis >= 0) {
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ t.cur_row = body_vis + off;
+ 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.editing = true;
+ }
+ }
}
},
.none => {},
@@ -701,7 +755,60 @@ pub fn main(init: std.process.Init) !void {
at.term.screens.active.scroll(.{ .delta_row = 15 });
} else if (key.matches('u', .{ .ctrl = true })) {
at.term.screens.active.scroll(.{ .delta_row = -15 });
+ } else if (at.editing and key.matches(vaxis.Key.escape, .{})) {
+ at.editing = false; // leave char mode (don't forward Esc)
+ } 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| {
+ // 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 = @intCast(at.term.screens.active.pages.scrollbar().offset);
+ at.cur_row = @max(off, at.cur_row - 1);
+ at.cur_off = 0;
+ },
+ vaxis.Key.down => {
+ const off: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset);
+ 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",
@@ -892,9 +999,84 @@ fn moveTerm(
layoutInsert(l, tc, ti, id);
}
-// Build the block-selected text, newline-joined per row; caller frees. Visual
-// row 0 is the tag line, rows >= BOX_H are the terminal body — same layout the
-// pane renders, so a selection over the tag yields the tag's text.
+// index of the run anchored exactly at (row, col), if any.
+fn runAt(t: *Term, row: i32, col: i32) ?usize {
+ for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == row and e.col == col) return i;
+ return null;
+}
+
+// rendered column of a position: original col + earlier-on-row insertions + the
+// offset into the run at this col. (horizontal only; scrolling doesn't matter.)
+fn renderedCol(t: *Term, row: i32, col: i32, off: u16) i32 {
+ var rc: i32 = col + @as(i32, off);
+ for (t.edits[0..t.nedits]) |e| if (e.row == row and e.col < col) {
+ rc += @as(i32, e.len);
+ };
+ return rc;
+}
+
+// Splice this row's insertion runs into `line` (the raw tty text for absolute
+// row `abs_row`), shifting the underlying text right rather than overwriting it.
+fn spliceRow(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *Term, abs_row: i32, line: []const u8) !void {
+ var idxs: [Term.MAX_EDITS]usize = undefined;
+ var n: usize = 0;
+ for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == abs_row and e.len > 0) {
+ idxs[n] = i;
+ n += 1;
+ };
+ // insertion-sort the runs by column (n is tiny)
+ var a: usize = 1;
+ while (a < n) : (a += 1) {
+ const key = idxs[a];
+ var b = a;
+ while (b > 0 and t.edits[idxs[b - 1]].col > t.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1];
+ idxs[b] = key;
+ }
+ var vpos: usize = 0; // original-line columns consumed so far
+ for (idxs[0..n]) |i| {
+ const e = &t.edits[i];
+ const c: usize = @intCast(@max(0, e.col));
+ if (c <= line.len) {
+ if (c > vpos) try out.appendSlice(alloc, line[vpos..c]);
+ vpos = c;
+ } else {
+ // run anchored past the text: emit the rest, then pad with spaces
+ if (vpos < line.len) {
+ try out.appendSlice(alloc, line[vpos..]);
+ vpos = line.len;
+ }
+ while (vpos < c) : (vpos += 1) try out.append(alloc, ' ');
+ }
+ try out.appendSlice(alloc, e.text[0..e.len]);
+ }
+ if (vpos < line.len) try out.appendSlice(alloc, line[vpos..]);
+}
+
+// The terminal body with all insertion runs spliced in, newline-joined. This is
+// the actual on-screen text: render and selection both read it, so there's no
+// tty-vs-typed distinction. Caller frees.
+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);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ // pad to the full grid height: plainString trims trailing blank rows, but we
+ // must still be able to type (and splice) onto those blank rows.
+ var it = std.mem.splitAny(u8, body, "\n");
+ const nrows: i32 = @intCast(t.rows);
+ var i: i32 = 0;
+ while (i < nrows) : (i += 1) {
+ if (i > 0) try out.append(alloc, '\n');
+ const line = it.next() orelse "";
+ try spliceRow(alloc, &out, t, i + off, line); // line i shows absolute row i+off
+ }
+ return out.toOwnedSlice(alloc);
+}
+
+// Block-selected text, newline-joined per row; caller frees. Reads the rendered
+// screen (tag at visual row 0, spliced body below), so selecting typed text and
+// tty text works identically.
fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
const r0 = @min(sl.r0, sl.r1);
const r1 = @max(sl.r0, sl.r1);
@@ -902,7 +1084,7 @@ fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
const tag = try tagLine(gpa, t);
defer gpa.free(tag);
- const body = try t.term.plainString(gpa);
+ const body = try bodyText(gpa, t);
defer gpa.free(body);
const pane = try std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body });
defer gpa.free(pane);
diff --git a/term.zig b/term.zig
index a07469e7..d5030e5b 100644
--- a/term.zig
+++ b/term.zig
@@ -11,6 +11,19 @@ pub const Sel = struct {
r1: i32 = 0,
};
+pub const EDIT_CAP = 128; // max chars in one insertion run
+pub const MAX_EDITS = 16; // max concurrent insertion runs per window
+
+/// A run of inserted ("char mode") text. `col` is the ORIGINAL tty column it is
+/// spliced in *before* — the run shifts the underlying row right, it doesn't
+/// overwrite. `row`/`col` are pane-local (row carries the BOX_H tag offset).
+pub const Edit = struct {
+ row: i32 = 0,
+ col: i32 = 0,
+ len: u16 = 0,
+ text: [EDIT_CAP]u8 = undefined,
+};
+
/// Persistent per-terminal state: the pty handle, the ghostty emulator and its
/// (stateful) VT parser, the background reader task, plus its layout weight and
/// text selection.
@@ -36,6 +49,20 @@ vweight: f32 = 1,
/// One independent selection per mouse button: 0=left, 1=middle, 2=right.
sel: [3]Sel = .{ .{}, .{}, .{} },
+/// modal input. false = "sym" mode (keys -> pty as terminal sequences),
+/// true = "char" mode (keys insert text at the cursor). a left-click enters
+/// char mode and anchors the cursor; Esc leaves it.
+///
+/// The cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll
+/// offset) so they ride the scrollback, not the screen. `cur_off` is the offset
+/// into the run sitting at (cur_row, cur_col).
+editing: bool = false,
+cur_row: i32 = 0,
+cur_col: i32 = 0,
+cur_off: u16 = 0,
+nedits: usize = 0,
+edits: [MAX_EDITS]Edit = @splat(.{}),
+
pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
_ = self.reader.cancel(io) catch {};
self.stream.deinit();
diff --git a/tests.zig b/tests.zig
index 51f9cefc..1d5e8c4c 100644
--- a/tests.zig
+++ b/tests.zig
@@ -112,13 +112,81 @@ pub fn main(init: std.process.Init) !void {
return error.ResizeNotDelivered;
}
- // 3. Sanity: the app still forwards keystrokes to its embedded shell.
+ // 3. Sym mode (default): keystrokes are forwarded to the embedded shell.
+ // The tag shows the "sy" mode indicator and the marker echoes back.
const marker = "PARDES_E2E";
try writeAll(master, "echo " ++ marker ++ "\r");
if (!try pollFor(gpa, master, &output, marker, 80)) {
dump(output.items, "app did not render echoed marker");
return error.MarkerNotRendered;
}
+ if (!try pollFor(gpa, master, &output, "sy ", 20)) {
+ dump(output.items, "no 'sy' mode indicator on the tag in sym mode");
+ return error.ModeIndicatorMissing;
+ }
+
+ // NOTE on the polls below: vaxis diff-renders, and char-mode text is built
+ // one keystroke per frame, so the inserted chars are emitted across frames
+ // and never appear as a contiguous byte run. We therefore `settle` (drain so
+ // the input is fully processed, which also avoids racing the resize), force a
+ // full repaint with setWinsize (which re-emits whole rows contiguously), and
+ // only then poll. Command *output* (e.g. R42Q) lands in one frame already, so
+ // it needs no repaint.
+
+ // 4. A left-click enters char mode and typed text is inserted as an overlay.
+ // SGR mouse: press+release of button 0 at a body cell (col 6, row 3).
+ try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m");
+ try writeAll(master, "EDITZZ1");
+ _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
+ try setWinsize(master, 50, 201); // force a full repaint
+ if (!try pollFor(gpa, master, &output, "EDITZZ1", 80)) {
+ dump(output.items, "char-mode insertion did not render");
+ return error.EditNotRendered;
+ }
+
+ // 5. The same repaint confirms the mode flipped sy -> ch on the tag. If char
+ // mode never engaged, the marker went to the shell and the tag reads "sy".
+ if (!try pollFor(gpa, master, &output, "ch /", 80)) {
+ dump(output.items, "left-click did not switch the tag to 'ch' (char mode)");
+ return error.CharModeNotEntered;
+ }
+
+ // 6. A second click anchors a second independent insertion run.
+ try writeAll(master, "\x1b[<0;6;5M\x1b[<0;6;5m");
+ try writeAll(master, "EDITZZ2");
+ _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
+ try setWinsize(master, 50, 202); // force a full repaint
+ if (!try pollFor(gpa, master, &output, "EDITZZ2", 80)) {
+ dump(output.items, "second anchored insertion did not render");
+ return error.SecondEditNotRendered;
+ }
+
+ // 6b. Middle-click executes the typed text read off the rendered screen.
+ // Char-click a blank body row, type a computed echo as an insertion, then
+ // middle-select exactly those 15 cells (text col 0 -> screen cols 2..16)
+ // and release. The result "Q7Z" appears only if the SELECTED inserted
+ // text ran — proving selection sees typed text, not the tty beneath it.
+ try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // char-click blank row 13
+ try writeAll(master, "echo Q$((3+4))Z");
+ _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle (build the run)
+ try writeAll(master, "\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
+ if (!try pollFor(gpa, master, &output, "Q7Z", 80)) {
+ dump(output.items, "middle-click did not execute the selected typed text");
+ return error.MiddleExecFailed;
+ }
+
+ // 7. Esc leaves char mode (tag back to sy) and input resumes going to the
+ // pty: a computed echo's result only appears if the shell executed it.
+ // (In char mode the literal "$((20+22))" would render but never "R42Q".)
+ // The settle after Esc lets vaxis emit a *standalone* Escape — a lone ESC
+ // immediately followed by text is otherwise parsed as Alt+<char>.
+ try writeAll(master, "\x1b");
+ _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
+ try writeAll(master, "echo R$((20+22))Q\r");
+ if (!try pollFor(gpa, master, &output, "R42Q", 80)) {
+ dump(output.items, "Esc did not return to sym mode (computed echo never ran)");
+ return error.SymModeNotRestored;
+ }
// Tell the app to quit (ctrl-c is the app's own exit key).
try writeAll(master, "\x03");