summaryrefslogtreecommitdiff
path: root/src/tagline.zig
diff options
context:
space:
mode:
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 };
+}