summaryrefslogtreecommitdiff
path: root/src/tagline.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 00:42:04 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit425d9beb9c977df364ed83743cb2efdef9429478 (patch)
treeeaba2e20ff041b50539d1b47fdeb498e59237cf0 /src/tagline.zig
parent1912878e753ce12fdc1ac72ae2e83ddfc18df002 (diff)
downloadpardes-425d9beb9c977df364ed83743cb2efdef9429478.tar.gz
pardes-425d9beb9c977df364ed83743cb2efdef9429478.zip
File the tag code into tagline.zig and draw tags beside bodies
With tags reduced to Texts, what is left of them is the computed prefix, the default and saved tails, entering and leaving a tag and the headers: that goes to tagline.zig, as acme keeps the tag half of a window in wind.c. Tag and header drawing moves next to body drawing in body_layer.zig, and the tag hit helpers go to tag_layer.zig with the Hit they read, where sameCell now also tells the lines of a taller tag apart. The docs describe the tag as a Text. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/tagline.zig')
-rw-r--r--src/tagline.zig360
1 files changed, 360 insertions, 0 deletions
diff --git a/src/tagline.zig b/src/tagline.zig
new file mode 100644
index 00000000..1021e9db
--- /dev/null
+++ b/src/tagline.zig
@@ -0,0 +1,360 @@
+//! The tags (acme's tag half of wind.c): a pane's computed prefix and its
+//! default and saved tails, going into a tag and out again, the path drafted
+//! as a new name, and the column and workspace tags above the panes.
+const pardes = @import("pardes.zig");
+const std = @import("std");
+const modal = @import("modal.zig");
+const ctlfs = @import("ninep/tree.zig");
+const panes = @import("panes.zig");
+const edit = @import("edit.zig");
+const config = @import("config.zig");
+const pdf_enabled = panes.Pdf.enabled;
+const dump = @import("dump.zig");
+const Pane = panes.Pane;
+const Text = panes.Text;
+const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET;
+const Pardes = pardes.Pardes;
+
+pub const pane_tail = " " ++ config.pane_builtins_str;
+pub const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str;
+pub const file_pane_tail = " " ++ config.file_pane_builtins_str;
+pub const context_pane_tail = file_pane_tail ++ " TreeContext";
+pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
+pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
+const dirty_marker = " *";
+pub const legacy_pane_tail = " Del";
+pub const legacy_file_pane_tail = " Save Del";
+// The defaults from the release before Newtty joined every tagline. Recognized
+// so a dump written then upgrades instead of keeping the old word as a tail.
+const prev_pane_tail = " New Del";
+pub const prev_file_pane_tail = " Save New Del";
+const legacy_terminal_pane_tail = " New Del Filter";
+// ...and the terminal default from the release before Save reached it, when a
+// scrollback was not yet something you could write to a path.
+pub const prev_terminal_pane_tail = " New Newtty Del Filter";
+const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter";
+const pre_column_pane_tail = " New Newtty Del";
+pub const pre_column_file_pane_tail = " Save New Newtty Del";
+const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint";
+pub const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter";
+const pre_collapse_pane_tail = " Tty Del";
+pub const pre_collapse_file_pane_tail = " Save Tty Del";
+const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint";
+pub const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter";
+pub const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse";
+pub const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse";
+// ...and the defaults from before the closing word moved to the end of every
+// tagline, where a click cannot land on it by overshooting the one before.
+const pre_close_last_pane_tail = " Tty Del Collapse";
+const pre_close_last_file_pane_tail = " Save Tty Del Collapse";
+const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse";
+const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse";
+
+/// What a column's tag says until someone edits it.
+pub const column_tag_default = "New Tty Find Grep Joincol Delcol";
+
+/// What a header says until someone edits it: for the workspace the
+/// fixed builtins, plus `Restore <path>` once a dump exists.
+pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 {
+ if (what == .column) return column_tag_default;
+ const d = p.last_dump orelse return config.topbar_str;
+ return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
+}
+
+/// the topbar line -- render and click dispatch must agree on this exact
+/// string
+pub fn topbar(p: *Pardes) []const u8 {
+ return p.global_tag.own orelse headerDefault(p, .workspace);
+}
+
+pub fn columnTag(p: *const Pardes, c: usize) []const u8 {
+ return p.col_tags[c].own orelse column_tag_default;
+}
+
+/// The header with the keyboard, when one has it.
+pub fn focusedHeader(p: *Pardes) ?*Text {
+ if (!p.header_focus) return null;
+ if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null;
+ return &p.global_tag;
+}
+
+pub fn revealHeader(p: *Pardes) void {
+ const t = focusedHeader(p) orelse {
+ p.header_scroll = 0;
+ return;
+ };
+ const text = edit.flatSurface(p, t) catch return;
+ const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row)));
+ const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w);
+ const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len));
+ if (col < p.header_scroll) p.header_scroll = col;
+ if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1);
+ var raw = panes.File.rawAtDisplay(line, p.header_scroll);
+ p.header_scroll = panes.File.rawDisplayCol(line, raw);
+ if (width > 0 and col >= p.header_scroll + width) {
+ raw = modal.nextGrapheme(line, raw);
+ p.header_scroll = panes.File.rawDisplayCol(line, raw);
+ }
+}
+
+pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
+ arena,
+ "{s} [{d}/{d}]",
+ .{ pv.path, pv.page + 1, pv.page_count },
+ );
+ if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
+ if (pane.file) |f| {
+ const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
+ if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
+ return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
+ return arena.dupe(u8, name);
+ }
+ return arena.dupe(u8, pane.cwdSlice());
+}
+
+/// The tag's own text: what the user made of it, or until then the
+/// default a pane of its kind starts with.
+pub fn curTail(pane: *Pane) []const u8 {
+ return pane.tag.own orelse defaultTail(pane);
+}
+
+pub fn defaultTail(pane: *const Pane) []const u8 {
+ if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail;
+ if (pane.file) |file| if (file.output) |out|
+ if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail;
+ if (panes.File.supportsContext(pane)) return context_pane_tail;
+ if (pane.file != null) return file_pane_tail;
+ if (pane.isTerminal()) return terminal_pane_tail;
+ return pane_tail;
+}
+
+/// The tag as it is shown, read and pointed at: its computed prefix and
+/// its own text after it.
+pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ const prefix = try tagPrefix(p, pane);
+ const tail = curTail(pane);
+ const out = try arena.alloc(u8, prefix.len + tail.len);
+ @memcpy(out[0..prefix.len], prefix);
+ @memcpy(out[prefix.len..], tail);
+ return out;
+}
+
+/// Give the keyboard to the tag in normal mode (`:`), its cursor where
+/// the tag last had it. The first time there is nothing to remember, and
+/// it starts on Save, so that `:` and the execute key write the file.
+pub fn enterTag(p: *Pardes, pane: *Pane) void {
+ const t = &pane.tag;
+ const tail = curTail(pane);
+ if (!t.cur_pinned) {
+ var words = std.mem.tokenizeAny(u8, tail, " \t\n");
+ const at = while (words.next()) |word| {
+ if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr);
+ } else tail.len - std.mem.trimStart(u8, tail, " ").len;
+ const cursor = modal.positionAt(tail, @min(at, tail.len));
+ t.cur_row = @intCast(cursor.row);
+ t.cur_col = @intCast(cursor.col);
+ t.cur_pinned = true;
+ // Reveal the suffix together with its commands, not merely the
+ // first command letter at the viewport's far right edge.
+ if (p.paneIdOf(pane)) |id| {
+ const tag = tagText(p, p.scratch.allocator(), pane) catch return;
+ const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len];
+ const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
+ pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
+ }
+ }
+ // The text may have changed since (a 9P write, a rename), so the
+ // remembered cursor is pulled back inside it. It is the cursor that
+ // is remembered, not a selection.
+ const rows = modal.cursorLineCount(tail);
+ t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
+ const line = modal.lineSlice(tail, @intCast(t.cur_row));
+ t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
+ t.vsel.active = false;
+ t.msel.active = false;
+ t.nsel = 0;
+ t.select = false;
+ t.append_at = null;
+ t.sticky_col = -1;
+ t.mode = .normal;
+ t.normal.clear();
+ pane.focus = .tag;
+}
+
+/// Where display column `col` of tag line `row` (tag scroll included)
+/// lands in the tag's own text, or null when it is on the prefix, which
+/// only the first line has.
+pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
+ const width = if (row == 0) panes.File.displayWidth(tagPrefix(p, pane) catch return null) else 0;
+ if (col < width) return null;
+ const line = modal.lineSlice(curTail(pane), row);
+ return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) };
+}
+
+/// Start a name draft for a file that can take one, its caret at display
+/// column `col` of the path. Nothing changes until it is typed into.
+pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void {
+ const f = pane.file orelse return;
+ if (!panes.Output.fileTraits(f.output).saves) return;
+ const name = p.gpa.dupe(u8, f.path) catch return;
+ pane.closePrompt();
+ pane.prompt = .name;
+ pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) };
+}
+
+/// Commit a drafted buffer name, never a disk rename or write. Keep the
+/// draft visible on failure so the user can correct it or cancel with Esc.
+pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
+ if (pane.prompt != .name) return true;
+ const id = p.paneIdOf(pane) orelse return false;
+ const name = pane.input.own orelse "";
+ if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) {
+ p.reportError(id, "name", error.InvalidName);
+ return false;
+ }
+ for (name) |byte| if (byte < ' ') {
+ p.reportError(id, "name", error.InvalidName);
+ return false;
+ };
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
+ p.reportError(id, "name", err);
+ return false;
+ };
+ ctlfs.pane.nameBuffer(p, id, full, true) catch |err| {
+ p.reportError(id, "name", err);
+ return false;
+ };
+ pane.closePrompt();
+ return true;
+}
+
+pub fn exitHeader(p: *Pardes) void {
+ p.header_focus = false;
+ p.header_column = null;
+ p.header_drag = false;
+ p.header_scroll = 0;
+}
+
+/// Give the keyboard to a column's tag, or the workspace's for null, in
+/// normal mode, its cursor where that header last had it.
+pub fn enterHeader(p: *Pardes, column: ?usize) void {
+ exitHeader(p);
+ if (p.panes[p.active]) |pane| pane.focus = .body;
+ p.header_focus = true;
+ p.header_column = column;
+ const t = focusedHeader(p) orelse return exitHeader(p);
+ const text = edit.flatSurface(p, t) catch "";
+ const rows = modal.cursorLineCount(text);
+ t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
+ const line = modal.lineSlice(text, @intCast(t.cur_row));
+ t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
+ t.cur_pinned = true;
+ t.vsel.active = false;
+ t.msel.active = false;
+ t.nsel = 0;
+ t.select = false;
+ t.append_at = null;
+ t.mode = .normal;
+ t.normal.clear();
+}
+
+pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
+ if (src.tag_tail) |tail| {
+ pane.tag.own = p.gpa.dupe(u8, tail) catch return;
+ return;
+ }
+ const tail_class = tailClass(pane);
+ if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) {
+ if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix|
+ return restoreTailAt(p, pane, src.tag, prefix, .generic);
+ }
+ if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) {
+ const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return;
+ if (std.mem.startsWith(u8, src.tag, legacy))
+ return restoreTailAt(p, pane, src.tag, legacy, .terminal);
+ }
+ const current = tagPrefix(p, pane) catch return;
+ if (std.mem.startsWith(u8, src.tag, current))
+ return restoreTailAt(p, pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class);
+ if (src.kind != .image) return;
+ const saved = src.image.?;
+ if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy|
+ return restoreTailAt(p, pane, src.tag, legacy, .generic);
+ if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy|
+ restoreTailAt(p, pane, src.tag, legacy, .generic);
+}
+
+pub fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
+ const pfx = tagPrefix(p, pane) catch return;
+ const class = tailClass(pane);
+ restoreTailAt(p, pane, saved_tag, savedPrefix(saved_tag, pfx, class), class);
+}
+
+const TailClass = enum { generic, file, output, terminal };
+
+fn tailClass(pane: *const Pane) TailClass {
+ if (pane.file) |f| return if (f.output == null) .file else .output;
+ if (pane.isTerminal()) return .terminal;
+ return .generic;
+}
+
+fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 {
+ if (class == .generic or class == .terminal) return live;
+ if (!std.mem.startsWith(u8, saved_tag, live)) return live;
+ const marked_len = live.len + dirty_marker.len;
+ if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker))
+ return saved_tag[0..marked_len];
+ return live;
+}
+
+pub fn restoreTailAt(
+ p: *Pardes,
+ pane: *Pane,
+ saved_tag: []const u8,
+ pfx: []const u8,
+ class: TailClass,
+) void {
+ if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
+ const rest = saved_tag[pfx.len..];
+ const said = std.mem.trimStart(u8, rest, " ");
+ const defaults: []const []const u8 = switch (class) {
+ .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
+ .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
+ .output => &.{
+ locations_pane_tail,
+ file_pane_tail,
+ pre_close_last_file_pane_tail,
+ pre_close_last_pane_tail,
+ prev_file_pane_tail,
+ legacy_file_pane_tail,
+ pane_tail,
+ prev_pane_tail,
+ legacy_pane_tail,
+ pre_column_file_pane_tail,
+ pre_column_pane_tail,
+ pre_collapse_file_pane_tail,
+ pre_collapse_pane_tail,
+ },
+ .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
+ };
+ for (defaults) |default|
+ if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
+ pane.tag.own = p.gpa.dupe(u8, rest) catch return;
+}
+
+/// The span a header's selection covers on line `row` of its text, in
+/// that line's bytes: up to the caret while typing, and over the
+/// cursor's own cell too in normal mode, where a cursor is a cell.
+pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } {
+ if (!t.vsel.active) return null;
+ const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
+ const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const lo = @min(anchor, cursor);
+ const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor));
+ const start = modal.lineStartOffset(text, row);
+ const end = modal.lineEndOffset(text, row);
+ if (hi <= start or lo >= end) return null;
+ return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start };
+}