//! 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; /// A file with changes it has not saved. pub fn dirty(pane: *const panes.Pane) bool { const f = pane.file orelse return false; return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone); } /// 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); } } /// A terminal's directory as its tag shows it: one line, as /index shows /// it (a newline `\n`), never a raw newline breaking the tag. fn shownDir(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { const dir = pane.cwdSlice(); if (std.mem.indexOfAny(u8, dir, "\n\\\x7f") == null and for (dir) |c| { if (c < ' ') break false; } else true) return dir; const out = try arena.alloc(u8, dir.len * 4 + 4); return pardes.ctlfs.events.shown(dir, out); } /// 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}", .{ (try shownDir(arena, pane)), shown, more, gap, id }); if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ (try shownDir(arena, pane)), shown, more, n, gap, id }); return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ (try shownDir(arena, pane)), 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}", .{ (try shownDir(arena, pane)), exec.replId(&idbuf, pane) }); } // Unsaved text shows on the grip, as acme's modbutton does, not as a // mark in the text: the prefix is the name alone. if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path; return (try shownDir(arena, pane)); } 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; } // 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.restore_cursor = false; t.sticky_col = -1; t.mode = .normal; t.normal.clear(); pane.focus = .tag; } /// A pane's tag as it is shown: its lines, each wrapped onto more rows at /// the tag's width as acme's frame wraps a tag at its right edge (plan9port /// acme wind.c:140, wintaglines, counts the frame's lines). `soft` are the /// offsets in `text` of the newlines the wrapping put in. pub const Shown = struct { text: []u8, soft: []const usize, /// The tag's own offset of a shown one. pub fn toReal(s: Shown, off: usize) usize { var n: usize = 0; for (s.soft) |b| { if (b < off) n += 1 else break; } return off - n; } /// The shown offset of the tag's own one. pub fn fromReal(s: Shown, real: usize) usize { var off = real; for (s.soft) |b| { if (b <= off) off += 1 else break; } return off; } pub fn rows(s: Shown) usize { return modal.cursorLineCount(s.text); } }; /// `whole` wrapped at `width` display columns. pub fn wrapTag(arena: std.mem.Allocator, whole: []const u8, width: usize) !Shown { var out: std.ArrayList(u8) = .empty; var soft: std.ArrayList(usize) = .empty; const w = @max(1, width); var lines = std.mem.splitScalar(u8, whole, '\n'); var first = true; while (lines.next()) |line| { if (!first) try out.append(arena, '\n'); first = false; var rest = line; while (panes.File.displayWidth(rest) > w) { var cut = modal.graphemeStart(rest, panes.File.rawAtDisplay(rest, w)); if (cut == 0) cut = modal.nextGrapheme(rest, 0); try out.appendSlice(arena, rest[0..cut]); try soft.append(arena, out.items.len); try out.append(arena, '\n'); rest = rest[cut..]; } try out.appendSlice(arena, rest); } return .{ .text = out.items, .soft = soft.items }; } /// The tag's width for wrapping: all its columns. A row the text fills /// exactly stays one row, as acme's frame wraps a box only when it is wider /// than what is left (libframe frutil.c _frcklinewrap), and the caret past /// its end sits at the edge (frptofchar.c clamps it to r.max.x). pub fn tagWidth(p: *const Pardes, id: usize) usize { return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET))); } pub fn shownTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) !Shown { const whole = try tagText(p, arena, pane); const id = p.paneIdOf(pane) orelse return .{ .text = whole, .soft = &.{} }; return wrapTag(arena, whole, tagWidth(p, id)); } /// Where display column `col` of the tag's shown row `row` puts its /// cursor: the tag as shown, prefix and all, wrapped rows included. pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { const arena = p.scratch.allocator(); const shown = shownTag(p, arena, pane) catch return null; const whole = tagText(p, arena, pane) catch return null; const at = row + pane.tag_top; const line = modal.lineSlice(shown.text, at); const off = modal.lineStartOffset(shown.text, at) + modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)); return modal.positionAt(whole, @min(shown.toReal(off), whole.len)); } /// 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; }; var home_buf: [4096]u8 = undefined; const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), pardes.filesystem.expandHome(name, &home_buf) }) 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; if (column) |c| p.setActiveColumn(c); 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.restore_cursor = false; t.mode = .normal; t.normal.clear(); } /// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on /// writes it, and an older one's saved tag is not read. pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { const tail = src.tag_tail orelse return; pane.tag.own = p.gpa.dupe(u8, tail) 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 }; } /// Column and workspace tags are one line, as acme's: a newline typed, /// pasted or restored from a dump into one becomes a space, the caret and /// the selection kept on the same characters. pub fn flattenHeaders(p: *Pardes) void { for (p.col_tags[0..p.ncol]) |*t| flatten(t); flatten(&p.global_tag); } fn flatten(t: *Text) void { const own = t.own orelse return; if (std.mem.indexOfScalar(u8, own, '\n') == null) return; const cur = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); const anchor = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }); std.mem.replaceScalar(u8, own, '\n', ' '); t.cur_row = 0; t.cur_col = @intCast(cur); t.vsel.row = 0; t.vsel.col = @intCast(anchor); t.nsel = 0; } test "a column or workspace tag is one line: a newline in it is a space" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); defer p.deinit(); p.col_tags[0].own = try p.gpa.dupe(u8, "New\nSnarf"); p.col_tags[0].cur_row = 1; p.col_tags[0].cur_col = 2; p.global_tag.own = try p.gpa.dupe(u8, "Newcol\nExit"); p.sync(); try std.testing.expectEqualStrings("New Snarf", columnTag(p, 0)); try std.testing.expectEqualStrings("Newcol Exit", p.global_tag.own.?); try std.testing.expectEqual(@as(i32, 0), p.col_tags[0].cur_row); try std.testing.expectEqual(@as(i32, 6), p.col_tags[0].cur_col); try std.testing.expectEqual(pardes.COLBAR_H, p.columnBarHeight()); try std.testing.expectEqual(pardes.TOPBAR_H, p.topBarHeight()); } test "a pane tag wraps a long line onto rows, and offsets map both ways" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const shown = try wrapTag(arena_state.allocator(), "/a/b.txt Del Snarf Undo Put\nMore", 10); try std.testing.expectEqualStrings("/a/b.txt D\nel Snarf U\nndo Put\nMore", shown.text); try std.testing.expectEqual(@as(usize, 4), shown.rows()); try std.testing.expectEqual(@as(usize, 10), shown.toReal(11)); // the 'e' of Del try std.testing.expectEqual(@as(usize, 11), shown.fromReal(10)); }