diff options
Diffstat (limited to 'src/tagline.zig')
| -rw-r--r-- | src/tagline.zig | 360 |
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 }; +} |
