summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-01 17:59:23 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitf3618a5a3151e02db2c1e2280ab42d48c35eeba9 (patch)
tree959910004851d112488e132d6555e73d417dcabb
parent48d7ef85c0b7fb1bed0bceba753e82f63a842fd5 (diff)
downloadpardes-f3618a5a3151e02db2c1e2280ab42d48c35eeba9.tar.gz
pardes-f3618a5a3151e02db2c1e2280ab42d48c35eeba9.zip
tagline is editable now
-rw-r--r--main.zig294
-rw-r--r--term.zig16
2 files changed, 295 insertions, 15 deletions
diff --git a/main.zig b/main.zig
index 5ec3bca6..bf064f66 100644
--- a/main.zig
+++ b/main.zig
@@ -267,6 +267,8 @@ const builtins = [_]Builtin{
};
// per-pane tag commands (window-specific).
const pane_builtins_str = "Del Delcol";
+// default editable tail (builtins with the leading gap after the cwd/path).
+const pane_tail = " " ++ pane_builtins_str;
// the global tagbar: window-agnostic commands. Kill closes everything; Debug toggles
// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme.
const topbar_str = "Kill Newcol Tutor Debug Colors NextColor";
@@ -400,20 +402,21 @@ fn isDir(path: [*:0]const u8) bool {
return true;
}
-// 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 {
- // an image pane's tag: a label + path (+ builtins); display-only, no mode/cwd.
+// The LIVE prefix of a window tag: mode indicator + cwd/path. Regenerated every
+// frame; the editable tail (builtins onward) lives in t.tag_tail. Caller frees.
+// Image panes have no editable tail, so their whole tag (toggles + builtins) is
+// returned here and stays live/non-editable. Caller frees with `alloc`.
+fn tagPrefix(alloc: std.mem.Allocator, t: *Term) ![]u8 {
if (t.image) |iv| {
// toggles: Petscii (glyph art on/off), the color mode (C64 = commodore /
// Term = terminal palette), Ascii (include ascii glyphs in the matcher).
const mode = if (iv.pmode == .commodore) "C64" else "Term";
return std.fmt.allocPrint(alloc, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
}
- // a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty.
+ // a file pane's tag: mode + path; 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, pane_builtins_str });
+ return std.fmt.allocPrint(alloc, "{s} {s}", .{ fmode, f.path });
}
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
@@ -423,7 +426,226 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
.insert => "in",
.tty => "sy",
};
- return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, pane_builtins_str });
+ return std.fmt.allocPrint(alloc, "{s} {s}", .{ mode, cwd });
+}
+
+// The editable tail: the user's edited buffer once touched, else the default
+// builtins. Empty for image panes (whole tag is live). Borrowed, not owned.
+fn curTail(t: *Term) []const u8 {
+ if (t.image != null) return "";
+ return if (t.tag_init) t.tag_tail.items else pane_tail;
+}
+
+// prefix + tail: the full rendered/selectable tag line. Caller frees with `alloc`.
+fn fullTag(alloc: std.mem.Allocator, t: *Term) ![]u8 {
+ const pfx = try tagPrefix(alloc, t);
+ const tail = curTail(t);
+ if (tail.len == 0) return pfx;
+ defer alloc.free(pfx);
+ return std.fmt.allocPrint(alloc, "{s}{s}", .{ pfx, tail });
+}
+
+// Focus the tag for editing, seeding the tail with the default builtins on first
+// touch and placing the cursor under `click_col` (a column within the text area).
+// Enters NORMAL mode (like a body left-click), so the tag then follows the pane's
+// modal keys — i/a to insert, hjkl/w/b/e to move, v to select, Enter/Tab chords.
+fn enterTagEdit(gpa: std.mem.Allocator, alloc: std.mem.Allocator, t: *Term, click_col: i32) void {
+ if (t.image != null) return; // images keep a live toggle tag, no editable tail
+ const pfx = tagPrefix(alloc, t) catch return;
+ if (!t.tag_init) {
+ t.tag_tail.appendSlice(gpa, pane_tail) catch return;
+ t.tag_init = true;
+ }
+ t.tag_edit = true;
+ t.tag_sel = false;
+ t.mode = .normal;
+ t.pending = 0;
+ const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(t.tag_tail.items.len)));
+ t.tag_col = @intCast(cc);
+}
+
+// leave tag editing (used when a body click / edit steals focus).
+fn exitTagEdit(t: *Term) void {
+ t.tag_edit = false;
+ t.tag_sel = false;
+}
+
+// inclusive char-select bounds within the tail: [lo, hi] (hi < len).
+fn tagSelBounds(t: *Term) struct { lo: usize, hi: usize } {
+ const a: usize = t.tag_anchor;
+ const c: usize = t.tag_col;
+ return .{ .lo = @min(a, c), .hi = @max(a, c) };
+}
+
+// The text a tag Enter/Tab chord acts on: the char-selection if active, else the
+// file-ish word under the cursor (acme isfilec). Borrowed from `alloc`; null if
+// there's nothing under the cursor.
+fn tagChordText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 {
+ const tail = t.tag_tail.items;
+ if (t.tag_sel) {
+ const b = tagSelBounds(t);
+ const hi = @min(b.hi + 1, tail.len);
+ if (hi > b.lo) return alloc.dupe(u8, tail[b.lo..hi]) catch null;
+ return null;
+ }
+ const col: usize = @min(@as(usize, t.tag_col), tail.len);
+ var lo = col;
+ while (lo > 0 and isFileChar(tail[lo - 1])) lo -= 1;
+ var hi = col;
+ while (hi < tail.len and isFileChar(tail[hi])) hi += 1;
+ if (hi > lo) return alloc.dupe(u8, tail[lo..hi]) catch null;
+ return null;
+}
+
+// A single-line view of the tail for the modal motion helpers (cursor row is
+// always 0). The tail never contains a newline (inserts strip them).
+fn tagLines(t: *Term) [1][]const u8 {
+ return .{t.tag_tail.items};
+}
+
+// tag-edit key dispatch. The tag shares the pane's `mode`: normal keys navigate/
+// select/edit; insert keys type. Newlines are always dropped (one-line tag).
+fn handleTagKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
+ if (t.mode == .insert) return tagInsertKey(gpa, t, key);
+
+ const lines = tagLines(t);
+ const cur: modal.Cursor = .{ .row = 0, .col = t.tag_col };
+
+ // `g`-prefixed motions (gg/ge/gh/gl), matching the body.
+ if (t.pending == 'g') {
+ t.pending = 0;
+ const m: modal.Cursor = if (key.matches('g', .{})) modal.gotoFirst() else if (key.matches('e', .{})) modal.gotoLast(&lines) else if (key.matches('h', .{})) modal.lineStart(cur) else if (key.matches('l', .{})) modal.lineEnd(&lines, cur) else cur;
+ t.tag_col = @intCast(m.col);
+ return;
+ }
+ if (key.matches('g', .{})) {
+ t.pending = 'g';
+ return;
+ }
+
+ // motions: move the cursor (head); a live selection keeps its anchor.
+ const moved: ?modal.Cursor =
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) modal.charLeft(cur) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) modal.charRight(&lines, cur) else if (key.matches('w', .{})) modal.nextWordStart(&lines, cur, false) else if (key.matches('W', .{})) modal.nextWordStart(&lines, cur, true) else if (key.matches('b', .{})) modal.prevWordStart(&lines, cur, false) else if (key.matches('B', .{})) modal.prevWordStart(&lines, cur, true) else if (key.matches('e', .{})) modal.nextWordEnd(&lines, cur, false) else if (key.matches('E', .{})) modal.nextWordEnd(&lines, cur, true) else if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) modal.lineStart(cur) else if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) modal.lineEnd(&lines, cur) else if (key.matches('^', .{})) modal.firstNonWsOf(&lines, cur) else null;
+ if (moved) |m| {
+ t.tag_col = @intCast(m.col);
+ return;
+ }
+
+ // insert entry (no o/O — those open a new line)
+ if (key.matches('i', .{})) return tagEnterInsert(t, t.tag_col);
+ if (key.matches('a', .{})) return tagEnterInsert(t, @intCast(modal.charRight(&lines, cur).col));
+ if (key.matches('I', .{})) return tagEnterInsert(t, @intCast(modal.firstNonWsOf(&lines, cur).col));
+ if (key.matches('A', .{})) return tagEnterInsert(t, @intCast(t.tag_tail.items.len));
+
+ // select / edit
+ if (key.matches('v', .{})) {
+ t.tag_sel = !t.tag_sel;
+ if (t.tag_sel) t.tag_anchor = t.tag_col;
+ return;
+ }
+ if (key.matches('x', .{})) { // select the whole one-line tail
+ const len = t.tag_tail.items.len;
+ if (len > 0) {
+ t.tag_sel = true;
+ t.tag_anchor = 0;
+ t.tag_col = @intCast(len - 1);
+ }
+ return;
+ }
+ if (key.matches('d', .{})) return tagDelete(gpa, t, false);
+ if (key.matches('c', .{})) return tagDelete(gpa, t, true);
+ if (key.matches('y', .{})) return tagYank(gpa, t);
+ if (key.matches('p', .{})) return tagPaste(gpa, t);
+ // Esc escalates: clear the selection, else hand focus back to the body.
+ if (key.matches(vaxis.Key.escape, .{})) {
+ if (t.tag_sel) t.tag_sel = false else exitTagEdit(t);
+ return;
+ }
+}
+
+// begin insert mode at `col` within the tail.
+fn tagEnterInsert(t: *Term, col: u16) void {
+ t.tag_col = @min(col, @as(u16, @intCast(t.tag_tail.items.len)));
+ t.tag_sel = false;
+ t.mode = .insert;
+ t.pending = 0;
+}
+
+fn tagInsertKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
+ if (key.matches(vaxis.Key.escape, .{})) {
+ t.mode = .normal;
+ return;
+ }
+ if (key.text) |text| {
+ for (text) |ch| {
+ if (ch == '\n' or ch == '\r') continue; // one-line tag
+ t.tag_tail.insert(gpa, t.tag_col, ch) catch return;
+ t.tag_col += 1;
+ }
+ return;
+ }
+ switch (key.codepoint) {
+ vaxis.Key.enter => {}, // no newline
+ vaxis.Key.backspace => if (t.tag_col > 0) {
+ _ = t.tag_tail.orderedRemove(t.tag_col - 1);
+ t.tag_col -= 1;
+ },
+ vaxis.Key.left => if (t.tag_col > 0) {
+ t.tag_col -= 1;
+ },
+ vaxis.Key.right => if (t.tag_col < t.tag_tail.items.len) {
+ t.tag_col += 1;
+ },
+ vaxis.Key.home => t.tag_col = 0,
+ vaxis.Key.end => t.tag_col = @intCast(t.tag_tail.items.len),
+ else => {},
+ }
+}
+
+// `d`/`c`: delete the selection (or the char under the cursor), yanking it; `c`
+// then enters insert. Cursor lands at the start of the removed span.
+fn tagDelete(gpa: std.mem.Allocator, t: *Term, change: bool) void {
+ const tail = t.tag_tail.items;
+ var lo: usize = undefined;
+ var count: usize = undefined;
+ if (t.tag_sel) {
+ const b = tagSelBounds(t);
+ lo = b.lo;
+ count = @min(b.hi + 1, tail.len) - b.lo;
+ } else {
+ lo = @min(@as(usize, t.tag_col), tail.len);
+ count = if (lo < tail.len) 1 else 0;
+ }
+ if (count > 0) {
+ setYank(gpa, tail[lo .. lo + count]);
+ t.tag_tail.replaceRange(gpa, lo, count, "") catch return;
+ }
+ t.tag_col = @intCast(lo);
+ t.tag_sel = false;
+ if (change) t.mode = .insert;
+}
+
+fn tagYank(gpa: std.mem.Allocator, t: *Term) void {
+ const tail = t.tag_tail.items;
+ if (t.tag_sel) {
+ const b = tagSelBounds(t);
+ const hi = @min(b.hi + 1, tail.len);
+ setYank(gpa, tail[b.lo..hi]);
+ t.tag_col = @intCast(b.lo);
+ t.tag_sel = false;
+ } else setYank(gpa, tail); // whole tail, like a line yank
+}
+
+fn tagPaste(gpa: std.mem.Allocator, t: *Term) void {
+ const y = yank_reg orelse return;
+ const col: usize = @min(@as(usize, t.tag_col), t.tag_tail.items.len);
+ var at = col;
+ for (y) |ch| {
+ if (ch == '\n' or ch == '\r') continue; // one-line tag
+ t.tag_tail.insert(gpa, at, ch) catch break;
+ at += 1;
+ }
+ t.tag_col = @intCast(at);
}
// The whitespace-delimited word covering `col` in `str` (empty if on a gap / past
@@ -1003,11 +1225,34 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// light themes paint the page; dark leaves it on the terminal's own background.
if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } });
- // tag: cwd + builtins, drawn as a dim bar; plain selectable text.
- const tag = try tagLine(alloc, t);
+ // tag: live prefix (mode + cwd/path) + editable tail (builtins onward), drawn
+ // as a dim bar; plain selectable text, tail editable in place (handleTagKey).
+ const tag_pfx = try tagPrefix(alloc, t);
+ const tail = curTail(t);
+ const tag = if (tail.len == 0) tag_pfx else try std.fmt.allocPrint(alloc, "{s}{s}", .{ tag_pfx, tail });
const tagw = text.child(.{ .height = BOX_H });
tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } });
_ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none });
+ // char-selection highlight on the tail (helix `v`/`x`), inclusive [lo, hi].
+ if (t.tag_edit and t.tag_sel) {
+ const b = tagSelBounds(t);
+ var col: usize = tag_pfx.len + b.lo;
+ const end: usize = tag_pfx.len + b.hi;
+ while (col <= end and col < r.w - GUTTER) : (col += 1) {
+ if (text.readCell(@intCast(col), 0)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.bg = .{ .rgb = msel_bg };
+ nc.style.fg = .{ .rgb = msel_fg };
+ text.writeCell(@intCast(col), 0, nc);
+ }
+ }
+ }
+ // cursor while editing the tail: prefix length + column within the tail.
+ if (active and t.tag_edit) {
+ const cx: usize = tag_pfx.len + t.tag_col;
+ if (cx < r.w - GUTTER) text.showCursor(@intCast(cx), 0);
+ }
// image pane: the picture fills the body; draw the box + a plain (thumbless)
// gutter so it reads like any other pane. kitty placements are re-issued each
@@ -1202,7 +1447,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
}
- if (active) {
+ if (active and !t.tag_edit) {
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.
@@ -2681,9 +2926,14 @@ pub fn main(init: std.process.Init) !void {
const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H);
paneScrollBy(t, -local); // left: scroll up to here
}
+ } else if (mrow < r.y + BOX_H and terms[id] != null and terms[id].?.image == null) {
+ // left-click on the tag row (non-image): edit the tail.
+ active = id;
+ enterTagEdit(gpa, frame_arena.allocator(), terms[id].?, @as(i32, mcol) - @as(i32, r.x + GUTTER));
} else {
active = id;
if (terms[id]) |t| {
+ exitTagEdit(t); // clicking the body leaves tag editing
const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
t.sel[@intFromEnum(Button.left)] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
@@ -2897,12 +3147,19 @@ pub fn main(init: std.process.Init) !void {
// Normal-mode Enter/Tab mirror the acme mouse chords: Enter =
// right-click "look" (open the file/dir), Tab = middle-click
- // "execute" (run it / builtin). With an active selection (`v`/`x`)
- // they act on the SELECTED text; otherwise on the file-ish word
- // under the cursor.
+ // "execute" (run it / builtin). They act on the active selection,
+ // else the file-ish word under the cursor — on the tag tail when
+ // it's focused for editing, else the body.
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;
+ if (at.tag_edit) {
+ if (tagChordText(frame_arena.allocator(), at)) |txt| {
+ at.tag_sel = false;
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt)) break;
+ }
+ break :kpress;
+ }
+ pinCursor(at);
if (currentSelText(frame_arena.allocator(), at)) |txt| {
at.vsel.active = false;
at.msel.active = false;
@@ -2918,6 +3175,13 @@ pub fn main(init: std.process.Init) !void {
break :kpress;
}
+ // tag editing intercepts every other key: a modal one-line editor
+ // over the tail, sharing the pane's mode (handleTagKey).
+ if (at.tag_edit) {
+ handleTagKey(gpa, at, key);
+ break :kpress;
+ }
+
// `p` (normal mode) pastes the SYSTEM clipboard: request it now;
// the host replies with an OSC 52 sequence the loop surfaces as a
// .paste event, which inserts the text at the cursor.
@@ -3323,7 +3587,7 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
// The full rendered pane: tag at visual row 0, spliced body below. Caller frees.
fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 {
- const tag = try tagLine(gpa, t);
+ const tag = try fullTag(gpa, t);
defer gpa.free(tag);
const body = try bodyText(gpa, t);
defer gpa.free(body);
diff --git a/term.zig b/term.zig
index 13b4ff85..27dbfef2 100644
--- a/term.zig
+++ b/term.zig
@@ -150,6 +150,21 @@ edits: [MAX_EDITS]Edit = @splat(.{}),
ed_undo: std.ArrayList(EditSnap) = .empty,
ed_redo: std.ArrayList(EditSnap) = .empty,
+/// Editable "tail" of the tag line: everything after the live mode + cwd/path
+/// prefix (the builtin command names and anything the user types). Seeded with
+/// the default builtins the first time the tag is edited (`tag_init`), then
+/// freely editable — no newlines. `tag_col` is the cursor's byte index within
+/// the tail; `tag_edit` is true while the tag is focused for editing. Image
+/// panes keep a live (non-editable) tag, so these stay unused for them.
+tag_tail: std.ArrayList(u8) = .empty,
+tag_col: u16 = 0,
+tag_init: bool = false,
+tag_edit: bool = false,
+/// helix `v` char-select within the tag: inclusive [anchor, tag_col]. The tag
+/// shares the pane's `mode` (normal/insert), so no separate mode is kept.
+tag_sel: bool = false,
+tag_anchor: u16 = 0,
+
/// A real shell pane (has a pty/emulator), as opposed to a file or image view.
pub fn isTerminal(self: *const Term) bool {
return self.file == null and self.image == null;
@@ -173,6 +188,7 @@ pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
}
self.ed_undo.deinit(gpa);
self.ed_redo.deinit(gpa);
+ self.tag_tail.deinit(gpa);
self.stream.deinit();
self.term.deinit(gpa);
}