diff options
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 294 |
1 files changed, 279 insertions, 15 deletions
@@ -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); |
