summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-27 11:45:56 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitd91c2cc53f816634a5471672bd33b842f06e849e (patch)
tree1193b6752db6a62d85d7ef6c06c38d95ae495d43 /src/pardes.zig
parent6877a61ef40ad0d8e762a28d0a3f5fa9bfefaff2 (diff)
downloadpardes-d91c2cc53f816634a5471672bd33b842f06e849e.tar.gz
pardes-d91c2cc53f816634a5471672bd33b842f06e849e.zip
tag columns are the WHOLE rendered tag: h/j/k/l walk taglines, the path selects but never edits
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig190
1 files changed, 132 insertions, 58 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 31757732..6df520ce 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -704,6 +704,12 @@ pub const Pane = struct {
/// the body mode a tag edit hijacked (tags are always insert); terminals
/// restore it on exit so clicking the tag never changes the pane's mode
tag_mode: Mode = .normal,
+ /// THE tag coordinate space: byte columns into the WHOLE rendered tag,
+ /// prefix ++ tail (tagText). One space, no conversions — the renderer, the
+ /// mouse and every motion speak it directly. The prefix is live chrome, so
+ /// it is selectable/yankable/executable but READ-ONLY: every edit op
+ /// measures from `edit0` (= tagPrefix().len, the first editable column) and
+ /// does nothing left of it.
tag_col: u16 = 0,
tag_anchor: u16 = 0,
ed_undo: std.ArrayList(EditSnap) = .empty,
@@ -838,6 +844,10 @@ const Drag = union(enum) {
border_v: struct { left_col: usize, cur_x: u16 },
border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
move: struct { id: usize, cur_x: u16, cur_y: u16 },
+ /// a left sweep along a pane's TAG row: it drives the tag's own cursor and
+ /// selection (rendered-tag columns) rather than the body's block selection,
+ /// which is what makes a one-line tag select like a line of text
+ tag: struct { id: usize },
/// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
/// the drag's own release is then inert
select: struct { id: usize, button: Mouse.Button, chorded: bool = false },
@@ -1209,10 +1219,20 @@ pub const Pardes = struct {
return pane_tail;
}
- /// focus the tag for editing, seeding the tail on first touch and placing
- /// the cursor under the click (a column within the text area)
- fn enterTagEdit(p: *Pardes, pane: *Pane, click_col: i32) void {
- const pfx = p.tagPrefix(pane) catch return;
+ /// the tag exactly as it is rendered: prefix ++ tail. THE text tag_col and
+ /// tag_anchor index, so the renderer, the mouse, the motions and the chord
+ /// all read the same bytes at the same columns.
+ fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ return std.fmt.allocPrint(arena, "{s}{s}", .{ try p.tagPrefix(pane), curTail(pane) });
+ }
+
+ /// focus the tag for editing, seeding the tail on first touch and parking
+ /// the cursor at `col` — a column of the RENDERED tag (see tag_col).
+ /// NEGATIVE means the first EDITABLE column, which is where `:` and a
+ /// tagline hop land: a place no click can name, so it needs no sentinel of
+ /// its own and the callers need no prefix length.
+ fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
+ const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len);
if (!pane.tag_init) {
pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
pane.tag_init = true;
@@ -1223,8 +1243,8 @@ pub const Pardes = struct {
// a one-line tag has no use for normal mode: always insert
pane.mode = .insert;
pane.pending = 0;
- const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(pane.tag_tail.items.len)));
- pane.tag_col = @intCast(cc);
+ const end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len));
+ pane.tag_col = @intCast(if (col < 0) edit0 else std.math.clamp(col, 0, end));
}
fn exitTagEdit(pane: *Pane) void {
@@ -1245,28 +1265,27 @@ pub const Pardes = struct {
return .{ .lo = @min(a, c), .hi = @max(a, c) };
}
- /// the text a tag Enter/Tab chord acts on: the char selection, else the
- /// file-ish word under the cursor
+ /// the text a tag Enter/Tab chord (and `y`) acts on: the char selection,
+ /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so
+ /// the path is a word like any other — Enter on it looks it.
fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 {
- const arena = p.scratch.allocator();
- const tail = pane.tag_tail.items;
+ const text = p.tagText(p.scratch.allocator(), pane) catch return null;
if (pane.tag_sel) {
const b = tagSelBounds(pane);
- const hi = @min(b.hi + 1, tail.len);
- if (hi > b.lo) return arena.dupe(u8, tail[b.lo..hi]) catch null;
- return null;
+ const hi = @min(b.hi + 1, text.len);
+ return if (hi > b.lo) text[b.lo..hi] else null;
}
- const col: usize = @min(@as(usize, pane.tag_col), tail.len);
+ const col: usize = @min(@as(usize, pane.tag_col), text.len);
var lo = col;
- while (lo > 0 and look.isFileChar(tail[lo - 1])) lo -= 1;
+ while (lo > 0 and look.isFileChar(text[lo - 1])) lo -= 1;
var hi = col;
- while (hi < tail.len and look.isFileChar(tail[hi])) hi += 1;
- if (hi > lo) return arena.dupe(u8, tail[lo..hi]) catch null;
- return null;
+ while (hi < text.len and look.isFileChar(text[hi])) hi += 1;
+ return if (hi > lo) text[lo..hi] else null;
}
- /// tag-edit key dispatch: a modal one-line editor over the tail, sharing
- /// the pane's mode. Newlines are always dropped.
+ /// tag-edit key dispatch: a modal one-line editor over the rendered tag,
+ /// sharing the pane's mode — the cursor moves over all of it, edits reach
+ /// only the tail. Newlines are always dropped.
fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
if (is(key, Key.escape)) {
exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it
@@ -1281,35 +1300,40 @@ pub const Pardes = struct {
}
return;
}
+ // the prefix is live chrome, not text you own: the cursor may sit in it
+ // (that is how the path selects), but every edit below is measured from
+ // the first EDITABLE column and simply does nothing to the left of it.
+ const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len);
+ const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail.items.len));
if (key.text.len > 0) {
+ if (pane.tag_col < edit0) return; // typing inside the path: inert
for (key.text) |ch| {
if (ch == '\n' or ch == '\r') continue; // one-line tag
- pane.tag_tail.insert(p.gpa, pane.tag_col, ch) catch return;
+ pane.tag_tail.insert(p.gpa, pane.tag_col - edit0, ch) catch return;
pane.tag_col += 1;
}
return;
}
switch (key.cp) {
- Key.backspace => if (pane.tag_col > 0) {
- _ = pane.tag_tail.orderedRemove(pane.tag_col - 1);
+ Key.backspace => if (pane.tag_col > edit0) {
+ _ = pane.tag_tail.orderedRemove(pane.tag_col - edit0 - 1);
pane.tag_col -= 1;
},
Key.left => if (pane.tag_col > 0) {
pane.tag_col -= 1;
},
- Key.right => if (pane.tag_col < pane.tag_tail.items.len) {
+ Key.right => if (pane.tag_col < end) {
pane.tag_col += 1;
},
Key.home => pane.tag_col = 0,
- Key.end => pane.tag_col = @intCast(pane.tag_tail.items.len),
+ Key.end => pane.tag_col = end,
else => {},
}
}
- /// write a helix range back onto the tag cursor + selection: the tail's
- /// one-line mirror of setPaneRange.
- fn setTagRange(pane: *Pane, r: modal.HxRange) void {
- const text = pane.tag_tail.items;
+ /// write a helix range back onto the tag cursor + selection: the rendered
+ /// tag's one-line mirror of setPaneRange.
+ fn setTagRange(pane: *Pane, text: []const u8, r: modal.HxRange) void {
const lo = @min(r.anchor, r.head);
const hi = @max(r.anchor, r.head);
pane.tag_col = @intCast(modal.hxCursor(text, r));
@@ -1318,10 +1342,12 @@ pub const Pardes = struct {
}
/// normal mode ON the tag — where `:` lands. The body's own helix motions
- /// with the tail as a one-line document, plus insert entry and the acme
- /// chords; editing keys stay in insert (`i` then type, like the mouse path).
+ /// with the WHOLE rendered tag as a one-line document (so the path selects
+ /// like any other text, and Enter on it looks it), plus insert entry and
+ /// the acme chords; editing keys stay in insert (`i` then type, like the
+ /// mouse path) and never reach left of `edit0`.
fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void {
- const text = pane.tag_tail.items;
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
const cur: usize = @min(@as(usize, pane.tag_col), text.len);
// Esc abandons the command line: back to the body, tail kept as text
if (is(key, Key.escape)) return exitTagEdit(pane);
@@ -1337,21 +1363,57 @@ pub const Pardes = struct {
p.actOnSelection(btn, id, txt, null);
return;
}
- // insert entry (one line, so I/A are the tail's ends)
+ // y — yank what the chord would run: the selection, else the word under
+ // the cursor. The path is selectable, so this is how you copy it out.
+ if (is(key, 'y')) {
+ if (p.tagChordText(pane)) |txt| p.setYank(txt);
+ return;
+ }
+ // insert entry (one line, so I/A are the tail's ends). The prefix is
+ // read-only, so entering insert inside it parks at the first editable
+ // column instead — you can never be typing into the path.
if (is(key, 'i') or is(key, 'a') or is(key, 'I') or is(key, 'A')) {
- pane.tag_col = @intCast(switch (key.cp) {
+ const edit0: usize = (p.tagPrefix(pane) catch return).len;
+ pane.tag_col = @intCast(@max(edit0, switch (key.cp) {
'a' => modal.nextGrapheme(text, cur),
'I' => 0,
'A' => text.len,
else => cur,
- });
+ }));
pane.tag_sel = false;
pane.mode = .insert;
return;
}
- const target: ?usize = if (is(key, 'h') or is(key, Key.left))
+ // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk
+ // it: focus the neighbour and land on ITS tagline, still in normal mode,
+ // so tag-to-tag navigation never drops through a body. Runs the SAME
+ // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run — one
+ // focusDir. The ARROWS below keep the in-tag grapheme motion, so the
+ // letters cost nothing. Nothing in that direction = stay put, tag and
+ // all: focusDir left `active` alone, so there is nothing to undo.
+ const dir: ?Builtin = if (is(key, 'h'))
+ .Left
+ else if (is(key, 'j'))
+ .Down
+ else if (is(key, 'k'))
+ .Up
+ else if (is(key, 'l'))
+ .Right
+ else
+ null;
+ if (dir) |d| {
+ const from = p.active;
+ p.runBuiltin(d, from, "");
+ if (p.active == from) return;
+ const dest = p.panes[p.active] orelse return;
+ exitTagEdit(pane); // the pane we left is fully out of its tag...
+ p.enterTagEdit(dest, -1); // ...and the tail's start is the new park
+ if (dest.tag_edit) dest.mode = .normal;
+ return;
+ }
+ const target: ?usize = if (is(key, Key.left))
modal.prevGrapheme(text, cur)
- else if (is(key, 'l') or is(key, Key.right))
+ else if (is(key, Key.right))
modal.nextGrapheme(text, cur)
else if (is(key, '0') or is(key, Key.home))
0
@@ -1361,7 +1423,7 @@ pub const Pardes = struct {
modal.firstNonWs(text)
else
null;
- if (target) |t| return setTagRange(pane, .{ .anchor = t, .head = t });
+ if (target) |t| return setTagRange(pane, text, .{ .anchor = t, .head = t });
// word motions select their traversed span, exactly like the body's
const wt: ?modal.WordTarget = if (is(key, 'w'))
.next_word_start
@@ -1379,18 +1441,14 @@ pub const Pardes = struct {
null;
if (wt) |t| {
const range: modal.HxRange = .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) };
- return setTagRange(pane, modal.hxWordMove(text, range, 1, t));
+ return setTagRange(pane, text, modal.hxWordMove(text, range, 1, t));
}
}
- /// d/c: delete the selection (or the char under the cursor), yanking it;
- /// c then enters insert
/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- const pfx = try p.tagPrefix(pane);
- const body = try p.bodyText(arena, pane);
- return std.fmt.allocPrint(arena, "{s}{s}\n{s}", .{ pfx, curTail(pane), body });
+ return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try p.bodyText(arena, pane) });
}
/// block-selected text, newline-joined per row; reads the rendered screen
@@ -2395,7 +2453,7 @@ pub const Pardes = struct {
// hands the body back (`:w<Tab>` = Save). Always the start, not the
// remembered tag column, so `:w` is the same keystrokes every time.
if (is(key, ':')) {
- p.enterTagEdit(pane, 0);
+ p.enterTagEdit(pane, -1);
if (pane.tag_edit) pane.mode = .normal;
return;
}
@@ -2435,7 +2493,9 @@ pub const Pardes = struct {
pane.tag_sel = false;
pane.mode = .insert;
pane.pending = 0;
- pane.tag_col = @intCast(pane.tag_tail.items.len);
+ // tag_col is a RENDERED-tag column, so the end of the tail is past the
+ // prefix (search_at stays a tail offset — it slices tag_tail)
+ pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
}
/// Enter on an armed input: the pattern is everything past the marker's
@@ -3970,9 +4030,14 @@ pub const Pardes = struct {
p.active = id;
pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
} else if (mrow < r.y + BOX_H) {
- // left click on the tag row: edit the tail in place
+ // left press on the tag row: focus the tag AT that
+ // column (a rendered-tag column — the mouse and the tag
+ // agree, no clamp into the tail) and anchor a sweep, so
+ // dragging selects any span of it, path included
p.active = id;
p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + GUTTER));
+ pane.tag_anchor = pane.tag_col;
+ p.drag = .{ .tag = .{ .id = id } };
} else {
p.active = id;
exitTagEdit(pane); // clicking the body leaves tag editing
@@ -4070,6 +4135,16 @@ pub const Pardes = struct {
pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
}
},
+ .tag => |d| {
+ // the tag cursor follows the mouse across the rendered tag; the
+ // row is ignored (a tag is one line) and the anchor stays where
+ // the press put it, so this is the mouse's `v`
+ const pane = p.panes[d.id] orelse return;
+ const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
+ const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + GUTTER);
+ pane.tag_col = @intCast(std.math.clamp(c, 0, end));
+ pane.tag_sel = pane.tag_col != pane.tag_anchor;
+ },
.none => {},
}
}
@@ -4159,6 +4234,7 @@ pub const Pardes = struct {
if (txt) |tx| p.actOnSelection(s.button, s.id, tx, arg);
}
},
+ .tag => {}, // dragUpdate already left the tag cursor + selection set
.none => {},
}
}
@@ -4987,8 +5063,7 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
slot_to_pane[id] = panes.items.len;
- const pfx = try p.tagPrefix(pane);
- const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ const tag = try p.tagText(arena, pane);
const body = try p.bodyText(arena, pane);
const scroll: usize = @intCast(@max(0, pane.scroll()));
const dp: dump.Pane = if (pane.file) |f| .{
@@ -5402,7 +5477,7 @@ pub const Pardes = struct {
s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = th.lineno } });
}
},
- .none, .select => {
+ .none, .select, .tag => {
// hover hints on a pane's own trailing edge
var c: usize = 0;
while (c + 1 < p.ncol) : (c += 1) {
@@ -5494,8 +5569,7 @@ pub const Pardes = struct {
// tag: live prefix (mode + cwd/path) + editable tail, on a dim bar
const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } };
s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } });
- const pfx = try p.tagPrefix(pane);
- const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ const tag = try p.tagText(arena, pane);
_ = s.print(tx, r.y, tw, tag, tag_style);
// pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge:
// the same rule as the `/` input — state you can see, nothing that
@@ -5514,11 +5588,12 @@ pub const Pardes = struct {
const w: u16 = @intCast(iw);
if (w < tw) _ = s.print(tx + tw - w, r.y, w, ibuf[0..iw], tag_style);
}
- // tag-tail char selection highlight (helix v/x), inclusive [lo, hi]
+ // tag char selection highlight (helix v/x, or a tagline sweep),
+ // inclusive [lo, hi] — rendered-tag columns, so no conversion
if (pane.tag_edit and pane.tag_sel) {
const b = tagSelBounds(pane);
- var col: usize = pfx.len + b.lo;
- const end: usize = pfx.len + b.hi;
+ var col: usize = b.lo;
+ const end: usize = b.hi;
while (col <= end and col < tw) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), r.y);
cell.default = false;
@@ -5526,11 +5601,10 @@ pub const Pardes = struct {
cell.style.fg = .{ .rgb = msel_fg };
}
}
- // cursor while editing the tail: prefix length + column within it
+ // cursor while editing the tag: a rendered-tag column, verbatim
if (active and pane.tag_edit) {
- const cx: usize = pfx.len + pane.tag_col;
// bar while typing, block for `:` normal mode (same rule as a body)
- if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y, .bar = pane.mode == .insert };
+ if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert };
}
// image pane: the picture fills the body — petscii glyph art into the