diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-27 11:45:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | d91c2cc53f816634a5471672bd33b842f06e849e (patch) | |
| tree | 1193b6752db6a62d85d7ef6c06c38d95ae495d43 /src/pardes.zig | |
| parent | 6877a61ef40ad0d8e762a28d0a3f5fa9bfefaff2 (diff) | |
| download | pardes-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.zig | 190 |
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 |
