//! 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 exec = @import("exec.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; pub const command_pane_tail = " " ++ config.command_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 ` 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; p.header_top = 0; return; }; const text = edit.flatSurface(p, t) catch return; const cur: usize = @intCast(@max(0, t.cur_row)); const rows: usize = if (p.header_column != null) p.columnBarHeight() else p.topBarHeight(); if (cur < p.header_top) p.header_top = cur; if (rows > 0 and cur >= p.header_top + rows) p.header_top = cur + 1 - rows; const line = modal.lineSlice(text, cur); 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); } } /// The computed start of a pane's tag -- its path and dirty marker, a /// PDF's page, a terminal's directory -- without the space that parts it /// from the tag's own text. It is never stored, and never edited. pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { 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); // A command pane: its directory, the line it runs, and how that went. // Its first line only, cut to 48 bytes at a character's start: a tag's // first row is where the prefix ends, and a line that ran over to a // second (acme runs a multi-line selection as one command) would move // it (body_layer.zig tagTintRange). if (pane.command) |line| { const first = line[0 .. std.mem.indexOfAny(u8, line, "\r\n") orelse line.len]; var cut = @min(first.len, 48); while (cut < first.len and cut > 0 and first[cut] & 0xC0 == 0x80) cut -= 1; const shown = first[0..cut]; const more = if (shown.len < line.len) "..." else ""; var idbuf: [48]u8 = undefined; const id = exec.replId(&idbuf, pane); const gap = if (id.len > 0) " " else ""; if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ pane.cwdSlice(), shown, more, n, gap, id }); return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); } // A bound REPL says what it is bound as, beside its Tty word. if (pane.repl != null) { var idbuf: [48]u8 = undefined; return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) }); } 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 name; } return pane.cwdSlice(); } pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { return prefixIn(p.scratch.allocator(), pane); } /// The prefix as the tag shows it, built in `arena`. pub fn prefixIn(arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try pathPrefix(arena, pane); // The space between the prefix and the tag's own text is the text's // (every default starts with one), but the prefix is computed, so it // cannot trust the text to keep it: with it deleted, Look and Exec // would see the path and the first word as one. The prefix brings its // own then. const tail = curTail(pane); if (tail.len > 0 and std.mem.indexOfScalar(u8, " \t\n", tail[0]) == null) return std.fmt.allocPrint(arena, "{s} ", .{prefix}); return arena.dupe(u8, prefix); } /// 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.command != null) return command_pane_tail; if (pane.isTerminal()) return if (pane.shell_tail_len > 0) pane.shell_tail[0..pane.shell_tail_len] else 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 whole = tagText(p, p.scratch.allocator(), pane) catch return; if (!t.cur_pinned) { // Save is looked for in the tag's own text, after the prefix. const lead = whole.len - curTail(pane).len; const tail = whole[lead..]; var words = std.mem.tokenizeAny(u8, tail, " \t\n"); const at = lead + 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(whole, @min(at, whole.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 line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.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(whole); t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); const line = modal.lineSlice(whole, @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 the tag's shown row `row` (tag scroll /// included) puts its cursor: the tag as shown, prefix and all. pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { const at = row + pane.tag_top; const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at); return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) }; } /// Whether display column `col` of the tag's shown row `row` is on a name /// a click drafts: a saving file's path. pub fn onName(pane: *Pane, row: usize, col: usize) bool { if (row + pane.tag_top != 0) return false; const f = pane.file orelse return false; if (!panes.Output.fileTraits(f.output).saves) return false; const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; return col < panes.File.displayWidth(name); } /// A key typed into a saving file's path, with the tag's cursor there, /// drafts a new name as a click on it does: the path is computed, so it is /// never the tag's own text to edit, but a buffer's name can be drafted. /// True when it did. pub fn typedIntoName(p: *Pardes, pane: *Pane, key: pardes.Key) bool { const f = pane.file orelse return false; if (!panes.Output.fileTraits(f.output).saves or pane.tag.cur_row != 0) return false; const at: usize = @intCast(@max(0, pane.tag.cur_col)); const back = key.cp == pardes.Key.backspace or pardes.hit(key, config.insert_backspace_alias); const forward = key.cp == pardes.Key.delete or pardes.hit(key, config.insert_delete_alias); const typed = key.text.len > 0 and !key.ctrl and !key.alt; const inside = if (back) at > 0 and at <= f.path.len else if (forward or typed) at < f.path.len else false; if (!inside) return false; draftName(p, pane, panes.File.displayWidth(f.path[0..at])); edit.handleInsert(p, &pane.input, key); return true; } /// 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; p.header_top = 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 }; }