const std = @import("std"); const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const layout = @import("layout.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const builtins = @import("builtins.zig"); const effect_sources = @import("effect_sources.zig"); const build_options = @import("pardes_config"); const lsp = @import("lsp/lsp.zig"); const image = @import("image.zig"); const look = @import("look.zig"); const locations = @import("locations.zig"); const Key = pardes.Key; /// An owned editable buffer and the absolute surface row of its first line. /// Files use row zero; terminal overlays may begin anywhere in scrollback. pub const EditText = struct { text: []u8, row0: i32 }; pub const Pane = struct { /// The words that throw unsaved text away (builtins.zig warnModified). pub const Discarding = enum { Exit, Restore }; /// One mouse selection (block-shaped), per button. c/r are text-area relative; /// r counts from the tag's first row (the body starts at Pane.tag_rows). pub const Sel = struct { state: enum { none, dragging, done } = .none, source_id: u64 = 0, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; /// A rectangle keeps one source span per rendered row. Wrapped segments /// remain separate rows when copied, even when they share a source line. pub const PointerRow = struct { row: i32, lo: usize, hi: usize, prefix: [32]u8 = @splat(0), prefix_len: u8 = 0, raw_terminal: bool = false, prompt_bytes: usize = 0, }; pub const PointerSelection = struct { gesture: Sel, rows: []PointerRow, raw_text: ?[]u8 = null, }; pub const LookSpot = struct { row: i32, col0: i32, col1: i32, }; const PdfSlot = if (Pdf.enabled) ?Pdf.State else void; /// What an open prompt is asking. The answer is typed into `input`. pub const Prompt = union(enum) { none, search, pipe: modal.Normal.PipeBehavior, /// Save on a scratch buffer or a terminal: the answer is a path to write to. save, /// Del from the keyboard with open panes both above and below: which /// one takes the rows. Nothing is typed; one key answers. del_side, /// An exec from a file's body with several REPLs bound for its /// language: which one takes it (exec.zig routeToRepl). One key /// answers, the question being in `question`. repl_choice, /// A new name for the file, drafted in place of the path on the /// tagline after a click there; Enter commits it, as a buffer name /// and never a disk rename. name, }; pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; pub const Focus = enum { body, tag }; terminal: ?*Terminal.State = null, gpa: std.mem.Allocator, serial: u32 = 0, /// What the control filesystem keeps of this pane -- addr and limit, the /// event queue and its readers, a run waiting on the shell -- lives and /// dies with it, as acme keeps the same on its Window /// (editors/acme/dat.h:226-266). fs: pardes.ctlfs.pane.State = .{}, /// The body: what the pane shows and edits, its cursor, selections, /// mode and edit-buffer undo. body: Text = .{}, vweight: f32 = 1, /// A folded pane keeps its expansion weight, but occupies only its tag row. collapsed: bool = false, cols: u16, rows: u16, greet: bool = false, pending_command: Terminal.PendingCommand = .{}, /// The last command line pardes typed into this terminal (an exec, a /// middle click, a pty/run): its first word, which Kill matches, and /// the shell's command count its start (C mark) reaches. sent_command: ?struct { word: [64]u8 = undefined, len: u8 = 0, seq: u32 = 0 } = null, /// A command pane (exec.zig runCommand): the line its child runs as /// `$SHELL -c`, owned, where an interactive terminal has null. command: ?[]u8 = null, /// The command's child has exited, and with what status (null: unknown, /// shown `exit ?`). command_done: bool = false, command_status: ?u8 = null, /// Its pty is still open: a job the command left in the background may /// be printing to it, and a reuse would hang it up. command_pty: bool = false, /// The shell a terminal runs when it is not the configured one (`Tty /// fish`), owned. shell: ?[]u8 = null, /// An interactive terminal's tag text naming its shell, ` Tty+fish ...` /// (Pardes.acknowledgeShell); empty until the host says which it ran. shell_tail: [96]u8 = undefined, shell_tail_len: u8 = 0, /// A terminal bound as a language's REPL (`Repl python`): an exec from /// the body of a file in that language is sent to it (exec.zig /// routeToRepl). `letter` makes its id, `python-a`, with the language's /// name from grammar_manifest. repl: ?struct { lang: u8, letter: u8 } = null, /// It has been told that its program takes no bracketed paste. repl_warned: bool = false, /// A question with its answers in it (`repl_choice`). question: [96]u8 = undefined, question_len: u8 = 0, /// The newest message says where an exec was sent: it is tinted. msg_sent: bool = false, /// The revision an Exit or a Restore last warned this pane was modified /// at, and which of them: only that word goes ahead on the warning /// (builtins.zig warnModified). discard_warned: ?struct { revision: u32, by: Discarding } = null, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, file: ?File.State = null, image: ?Image.State = null, pdf: PdfSlot = if (Pdf.enabled) null else {}, prompt: Prompt = .none, /// The text an open prompt answers for: `s`/`S` select in it and `|` /// pipes it. A header's is whichever has the keyboard; `/` and Save's /// path always answer for the body. prompt_for: Text.What = .body, /// The answer to an open prompt: its marker and what is typed after it, /// edited in insert mode and shown on the notice band, or a name draft /// shown on the tagline in place of the path. It is its own text so a /// prompt never touches the tag. input: Text = .{ .what = .input }, search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, /// `gw`'s words, labelled in order while it waits for a label to be /// typed (gpa-owned); `jump_first` is the label's first letter once typed. jump: std.ArrayList(modal.Selection) = .empty, jump_first: ?u8 = null, sel_snap: [Text.max_selections]modal.Selection = undefined, nsel_snap: u16 = 0, sel_snap_pri: u16 = 0, sel_snap_expl: bool = false, /// The tag: what the user keeps after the computed prefix (the path, the /// dirty marker, a PDF's page), its cursor, selections and undo. The /// prefix is never stored in it, so it never moves the tag's cursor. tag: Text = .{ .what = .tag }, /// Which of the pane's texts the keyboard types into. focus: Focus = .body, /// Display columns hidden to reveal the tag caret; tag addresses stay whole. tag_scroll: u16 = 0, /// Rows the tag takes on screen: one per line of it, as many as the pane /// has room for with a body row to spare. Set with the geometry in sync. /// Pointer rows count from the tag's first, so a body starts at this one. tag_rows: u16 = 1, /// The tag's first line on screen. A tag with more lines than rows /// scrolls to keep its caret in view while it has the keyboard; pointer /// rows count from this line, not the tag's first. tag_top: u16 = 0, /// How long the tag's computed prefix was when its positions were last /// placed. They count from the start of the prefix, so when it grows or /// shrinks (a rename, the dirty marker, a PDF's page) what follows it /// moves with it. tag_lead: usize = 0, cwd: Cwd = .none, hscroll: i32 = 0, // Visible rows map to source lines and raw/display column origins. wrap_line: [limits.wrap_rows]i32 = undefined, wrap_col: [limits.wrap_rows]i32 = undefined, wrap_n: u16 = 0, /// A row map also exists for sticky headers when soft wrapping is off. body_wrapped: bool = false, context_rows: u16 = 0, body_rows: u16 = 0, body_visible_rows: u16 = 0, context_row_limit: ?u16 = null, sel: [3]Sel = @splat(.{}), pointer_selections: [3]?PointerSelection = @splat(null), next_pointer_selection: u64 = 0, /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Terminal.EditBuffer = null, tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, /// Where the newest message line is in its life. msg_life: MessageLife = .{}, /// When it was said, in monotonic ms (Messages.expireUnattended). msg_said_ms: i64 = 0, /// The row (among the notice rows) the newest line holds for its life. msg_slot: u8 = 0, /// Earlier message lines still on screen, oldest first, each running its /// own life: a new message stacks under them instead of replacing them. msg_older: [message_stack]OlderMessage = undefined, msg_older_len: u8 = 0, /// The newest line is a builtin announcing itself in this step: what /// the step says next replaces it rather than stacking under it. msg_announcement: bool = false, /// The newest line is a status (progress, a server's state): the next /// line replaces it rather than stacking under it. msg_status: bool = false, /// The transient lines this pane shows between its body and its tagline: /// a builtin's message, the pending leader chord, a prompt waiting for /// input. Rebuilt every frame from the state that owns each one, so a /// line that goes away is simply not added again and the rest close the /// gap -- there is no second copy of the truth to fall out of step. The /// body layer reserves exactly `len` rows for them, the way it already /// reserves rows for sticky context headers. notices: Notices = .{}, pub const MessagePhase = enum { entering, shown, lingering, leaving }; /// One message line's animation: falling in, shown, lingering after the /// key or click that dismissed it, dissolving. `frame` counts animation /// frames within the phase. pub const MessageLife = struct { phase: MessagePhase = .shown, frame: u16 = 0, /// Dismissed while still falling: it lingers once it has landed. dismissed: bool = false, }; /// Older message lines kept on screen under a new one. pub const message_stack = 3; pub const OlderMessage = struct { text: [256]u8 = undefined, len: u16 = 0, life: MessageLife = .{}, slot: u8 = 0, pub fn slice(line: *const OlderMessage) []const u8 { return line.text[0..line.len]; } }; /// Move the newest line onto the stack of older ones, dropping the oldest /// when it is full; the newest slot is left empty for what arrives. pub fn pushOlderMessage(pane: *Pane) void { if (pane.msg_older_len == message_stack) { std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]); pane.msg_older_len -= 1; } pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot }; pane.msg_older_len += 1; pane.msg_len = 0; pane.msg_life = .{}; } /// The first row no older line holds. A line keeps its row until it /// leaves, and a new one fills the first gap: nothing on screen moves. pub fn freeMessageSlot(pane: *const Pane) u8 { var used: u8 = 0; for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot); var slot: u8 = 0; while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1; return slot; } pub fn removeOlderMessage(pane: *Pane, i: usize) void { std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]); pane.msg_older_len -= 1; } pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest /// the tagline that it has always had, and its cursor with it. /// `older` is an earlier message line still on screen; `index` says /// which (`Pane.msg_older`). pub const Kind = enum { older, message, leader, prompt }; pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack; kinds: [max]Kind = undefined, index: [max]u8 = @splat(0), /// Each band's row below the body's top: message lines keep theirs, /// so a band that leaves leaves a gap rather than moving the rest. row: [max]u8 = @splat(0), /// Where each band's chip starts, as an absolute grid column. A chip /// is only as wide as its own text, so the rest of the row underneath /// is ordinary body text and a click there has to reach it. left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, /// A band on the next row after every band so far. pub fn push(n: *Notices, kind: Kind) void { n.pushAt(kind, 0, n.span()); } pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void { if (n.len >= max) return; n.kinds[n.len] = kind; n.index[n.len] = index; n.row[n.len] = row; n.left[n.len] = std.math.maxInt(u16); n.len += 1; } /// Is this cell inside the chip drawn for band `i`? Nothing else on /// that row belongs to the notice. pub fn covers(n: *const Notices, row: usize, col: u16) bool { for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i]; return false; } /// Rows from the body's top down to the last band's. pub fn span(n: *const Notices) u8 { var rows: u8 = 0; for (n.row[0..n.len]) |row| rows = @max(rows, row + 1); return rows; } pub fn slice(n: *const Notices) []const Kind { return n.kinds[0..n.len]; } }; pub fn clearPointerSelection(pane: *Pane, slot: usize) void { if (pane.pointer_selections[slot]) |selection| { pane.gpa.free(selection.rows); if (selection.raw_text) |text| pane.gpa.free(text); } pane.pointer_selections[slot] = null; } pub fn rawPointerText(pane: *const Pane, gesture: Sel) ?[]const u8 { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.raw_text; } return null; } pub fn pointerSelection(pane: *const Pane, gesture: Sel) ?[]PointerRow { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.rows; } return null; } /// Add `text` to the end of the tag's own text, which starts out as the /// default a pane of its kind shows. pub fn appendTag(pane: *Pane, text: []const u8) !void { const had = pane.tag.own orelse pardes.tagline.defaultTail(pane); const grown = try std.mem.concat(pane.gpa, u8, &.{ had, text }); if (pane.tag.own) |old| pane.gpa.free(old); pane.tag.own = grown; } /// Close an open prompt, answered or not. Its answer goes with it, and an /// s/S preview keeps whatever it last previewed. pub fn closePrompt(pane: *Pane) void { pane.input.deinit(pane.gpa); pane.prompt = .none; pane.prompt_for = .body; pane.nsel_snap = 0; } /// What an open prompt shows on its notice band: the one answer every /// painter of the band reads. pub fn promptText(p: *const Pane) ?[]const u8 { return switch (p.prompt) { .none, .name => null, .del_side => config.del_question, .repl_choice => p.question[0..p.question_len], else => p.input.own orelse "", }; } pub fn cwdSlice(p: *const Pane) []const u8 { return switch (p.cwd) { .none => "", .owned => |dir| dir, .inherited => |src| src.cwdSlice(), }; } pub fn clearCwd(pane: *Pane) void { if (pane.cwd == .owned) pane.gpa.free(pane.cwd.owned); pane.cwd = .none; } pub fn setOwnedCwd(pane: *Pane, dir: []const u8) !void { if (dir.len > limits.host_path_cap) return error.PathTooLong; const copy = try pane.gpa.dupe(u8, dir); pane.clearCwd(); pane.cwd = .{ .owned = copy }; } pub fn isTerminal(pane: *const Pane) bool { const no_pdf = if (comptime Pdf.enabled) pane.pdf == null else true; return pane.file == null and pane.image == null and no_pdf; } /// The one coloring choice keyed on what a pane IS, so the highlight /// producer (refreshHighlights) and the render pass agree on the algorithm. pub const ColorAlgo = enum { none, tty, source, diff, locations }; pub fn colorAlgo(pane: *const Pane) ColorAlgo { if (pane.isTerminal()) return .tty; if (pane.file) |f| { if (f.mini != null) return .source; if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; if (f.output != null) { const tr = Output.fileTraits(f.output); if (tr.locations) return .locations; if (!tr.saves) return .none; } return .source; } return .none; } pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.path; return null; } pub fn pdfPage(pane: *const Pane) ?usize { if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.page; return null; } pub fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); if (g >= o.row + o.rows) return g + lines - o.rows; return @min(g, o.row + lines - 1); // inside the buffer: its own rows } /// the inverse; every surface row inside the edit buffer maps to its anchor pub fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); if (s < o.row + lines) return o.row; return s - lines + o.rows; } /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(Terminal.gridOffset(pane)); } pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; // unwrapped: rows ARE lines, and the byte column a row starts at is the // horizontal scroll (always 0 on a terminal, which never has one) return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; } pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; var i: u16 = 0; while (i < pane.wrap_n) : (i += 1) { if (pane.wrap_line[i] != line) continue; // the LAST row of a line owns every column past its start, so a // cursor parked on the trailing newline still has somewhere to draw if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; } return .{ .row = -1, .at = 0 }; } pub fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { const max: i64 = @intCast(File.nlines(pane.gpa, f) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); const next: usize = @intCast(n); if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } else if (hasPdf(pane)) { if (comptime Pdf.enabled) { const pv = &pane.pdf.?; const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); pv.text_scroll = @intCast(std.math.clamp( @as(i64, @intCast(pv.text_scroll)) + delta, 0, max, )); } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off = Terminal.gridOffset(pane); Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); } } pub fn ensureCursorVisible(pane: *Pane) void { const off = pane.scroll(); var in_context = false; // Maps survive until the next render. A jump may have changed scroll // already, so only treat an ancestor as pinned in the matching view. if (pane.context_rows < pane.wrap_n and pane.wrap_line[pane.context_rows] == off) { for (pane.wrap_line[0..pane.context_rows]) |row| { if (row == pane.body.cur_row) { in_context = true; break; } } } // Scrolloff is measured in the ordinary body, excluding sticky rows. var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); var last = off + @as(i32, pane.rows) - 1; const visible_rows = if (pane.body_visible_rows > 0) @min(pane.wrap_n, pane.body_visible_rows) else pane.wrap_n; if (pane.context_rows < visible_rows) { const lines_shown = pane.wrap_line[visible_rows - 1] - pane.wrap_line[pane.context_rows]; last = off + lines_shown; margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); } if (!in_context) { if (pane.body.cur_row < off + margin) { pane.scrollBy(pane.body.cur_row - margin - off); } else if (pane.body.cur_row > last - margin) { var to = pane.body.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1))); if (comptime Pdf.enabled) { if (pane.pdf) |pv| to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); } pane.scrollBy(@max(0, to - last)); } } if (pane.file) |f| { if (pane.body_wrapped) return; const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane))); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); const line = modal.lineSlice(f.content, @intCast(@max(0, pane.body.cur_row))); const raw_cur: usize = @intCast(@max(0, pane.body.cur_col)); const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); const cur = @as(i32, @intCast(File.rawDisplayCol(line, raw_cur))); const visual_scroll = @as(i32, @intCast(File.rawDisplayCol(line, raw_scroll))); var target = visual_scroll; if (cur < visual_scroll + hmargin) target = @max(0, cur - hmargin) else if (cur > visual_scroll + w - 1 - hmargin) target = cur - (w - 1 - hmargin); if (target != visual_scroll) pane.hscroll = @intCast(File.rawAtDisplay(line, @intCast(target))); } } pub fn pinCursor(pane: *Pane) void { if (pane.body.cur_pinned) return; if (pane.file != null or hasPdf(pane)) { pane.body.cur_row = pane.scroll(); pane.body.cur_col = 0; } else { const cur = Terminal.gridCursor(pane); pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); pane.body.cur_col = @intCast(cur.x); } pane.body.cur_pinned = true; } pub fn hasPdf(pane: *const Pane) bool { return if (comptime Pdf.enabled) pane.pdf != null else false; } pub fn hasPdfSelection(pane: *const Pane) bool { return if (comptime Pdf.enabled) if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false else false; } }; pub const Text = @import("Text.zig"); pub const File = @import("File.zig"); pub const Output = @import("Output.zig"); pub const Mini = @import("Mini.zig"); pub const Image = @import("image.zig"); pub const Pdf = @import("pdf_view.zig"); pub const Terminal = @import("Terminal.zig"); test { _ = Pane; _ = Text; _ = File; _ = Output; _ = Mini; _ = Image; _ = Pdf; _ = Terminal; }