diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 00:42:04 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 425d9beb9c977df364ed83743cb2efdef9429478 (patch) | |
| tree | eaba2e20ff041b50539d1b47fdeb498e59237cf0 | |
| parent | 1912878e753ce12fdc1ac72ae2e83ddfc18df002 (diff) | |
| download | pardes-425d9beb9c977df364ed83743cb2efdef9429478.tar.gz pardes-425d9beb9c977df364ed83743cb2efdef9429478.zip | |
File the tag code into tagline.zig and draw tags beside bodies
With tags reduced to Texts, what is left of them is the computed prefix, the
default and saved tails, entering and leaving a tag and the headers: that goes
to tagline.zig, as acme keeps the tag half of a window in wind.c. Tag and
header drawing moves next to body drawing in body_layer.zig, and the tag hit
helpers go to tag_layer.zig with the Hit they read, where sameCell now also
tells the lines of a taller tag apart. The docs describe the tag as a Text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | docs/design.typ | 44 | ||||
| -rw-r--r-- | docs/fs.md | 7 | ||||
| -rw-r--r-- | docs/helix-keys.md | 8 | ||||
| -rw-r--r-- | docs/tags.md | 60 | ||||
| -rw-r--r-- | src/body_layer.zig | 457 | ||||
| -rw-r--r-- | src/builtins.zig | 15 | ||||
| -rw-r--r-- | src/colors.zig | 3 | ||||
| -rw-r--r-- | src/dump.zig | 5 | ||||
| -rw-r--r-- | src/edit.zig | 14 | ||||
| -rw-r--r-- | src/exec.zig | 5 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/fs.zig | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/layout.zig | 9 | ||||
| -rw-r--r-- | src/look.zig | 7 | ||||
| -rw-r--r-- | src/mouse.zig | 62 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 11 | ||||
| -rw-r--r-- | src/normal.zig | 7 | ||||
| -rw-r--r-- | src/panes.zig | 2 | ||||
| -rw-r--r-- | src/pardes.zig | 1025 | ||||
| -rw-r--r-- | src/tag_layer.zig | 25 | ||||
| -rw-r--r-- | src/tagline.zig | 360 | ||||
| -rw-r--r-- | test/column_drag.zig | 18 | ||||
| -rw-r--r-- | test/fs_bench.zig | 2 | ||||
| -rw-r--r-- | test/mode.zig | 6 | ||||
| -rw-r--r-- | test/panes.zig | 50 | ||||
| -rw-r--r-- | test/pdf.zig | 10 |
27 files changed, 1148 insertions, 1070 deletions
diff --git a/docs/design.typ b/docs/design.typ index 82bafb49..8e5f7e6e 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -530,9 +530,9 @@ Each pane kind keeps its storage and operations together in its own file image pane in `image.zig`), reached through `panes.zig` beside `Pane`. `layout.zig` owns placement and presentation state. `pardes.zig` handles input and cross-pane state directly, without a pane vtable; the rest of the editor -is split by thing as acme is: `edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, -`mouse.zig`, `Messages.zig`, `Pipe.zig`, `colors.zig`, `surface.zig`, -`body_layer.zig` and `dump.zig`. Integration fixtures live +is split by thing as acme is: `Text.zig`, `edit.zig`, `normal.zig`, `tagline.zig`, +`look.zig`, `exec.zig`, `mouse.zig`, `Messages.zig`, `Pipe.zig`, `colors.zig`, +`surface.zig`, `body_layer.zig`, `tag_layer.zig` and `dump.zig`. Integration fixtures live in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`. = State @@ -770,33 +770,35 @@ pair per theme, and the three per-button tints and the dimmed extra cursors are mixed off it, so what stays fixed is the distinction between buttons and not the colours. -`Drag` is a `union(enum)` with six arms (`pardes.Drag`): `none`, -`border_v`, `border_h`, `move`, `tag`, `select`. `border_v` carries an optional +`Drag` is a `union(enum)` with six arms (`mouse.Drag`): `none`, +`border_v`, `border_h`, `move`, `column_move`, `select`. `border_v` carries an optional `corner`: when the press lands on a cell that is both a column's vertical border and one of the two adjoining columns' own horizontal borders, the one drag moves *both* boundaries — never three, and when both columns happen to be split at the grabbed row the left one wins, so the gesture that existed before is bit-for-bit unchanged. -== The tag is a command line +== The tag is a text -A tag is one line: a live prefix (mode indicator, cwd or path) plus an editable -tail, and the tail gets the full modal editor. The coordinate space is UTF-8 byte -offsets into the *whole* rendered tag, prefix ++ tail, always on grapheme -boundaries (`Pane.tag_col`). The prefix is live chrome, so it is -selectable, yankable and executable but READ-ONLY: every edit op measures from -`edit0` — the first editable byte — and does nothing left of it. +A tag is a `Text` (`Text.zig`), as a body is: acme's `Text`, one per +`what` — the body, the pane's tag, the prompt line, a column's tag and the +workspace tag. Each holds its own cursor, selections, mode and undo, so tags +are edited by the same `edit.zig` and `normal.zig` code as bodies, and the one +key they do not share is `:`, which moves between a pane's body and its tag. +A tag holds any number of lines; a pane's tag takes a row per line up to +`MAX_TAG_ROWS`, each drawn as a tag layer of its own. -`tag_tail` is a fixed `[max_tag_tail]u8`, and the bound IS the storage -(`limits.max_tag_tail`): every writer — `appendTag`, `tagInsert`, -`restoreDumpTail`, the 9P `tag` file — refuses input that does not fit -rather than truncating it. The schema limit and the buffer therefore can never -disagree, which is what lets a dump reader reject data before copying it into a -pane. +A pane's tag shows a live prefix (path, dirty marker, PDF page) before the text +it owns, and that prefix is computed at every read, never stored +(`tagline.tagPrefix`). The Text's coordinates are offsets into its own text +alone, so a rename or a save never moves its cursor. The one translation +point is the whole line as shown: render, the mouse, Look, Exec and the 9P +`tag` file see prefix ++ text, while keyboard motions and edits see only the +text. Editing the path is a separate draft (`Pane.prompt = .name`) committed +by Enter, since a buffer's name is not text it owns. -A tag edit is always insert mode, so it hijacks the body's mode; `tag_mode` -remembers what it hijacked and terminals restore it on exit, which is why -clicking a tag never changes a pane's mode. +The tag's text is bounded by `limits.max_tag_tail`: the 9P `tag` file and a +dump reader refuse input that does not fit rather than truncating it. = Diffing and presenting a frame @@ -180,7 +180,12 @@ back evaluates it, which is what acme(4) promises of its own `addr`. The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the buffer differs from its file, whether a write pushes an undo point (writing `1` pushes one now), and whether a write scrolls the -pane. Truncating `tag` clears the part of the tag you may edit. +pane. + +`tag` reads the whole tag as the pane shows it: the computed path, dirty +marker or PDF page, then the text you may edit. A write appends to that text, +newlines included, and a tag with more than one line takes a row per line on +screen; truncating `tag` clears it, as acme's `cleartag` does. Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`, `name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 0813c193..64fa2e24 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -117,15 +117,15 @@ language-backend queries, and the shell pipe. | `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified | | `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific | | `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific | -| `:` (normal, body) | focuses the pane's tag in normal mode at its remembered cursor position (first entry starts at the commands). `h`/`l`, arrows, word motions and `0`/`$`/`^` move within the tag; `k` stays on its single line; `j` or `Esc` returns to the body. Uppercase `H`/`J`/`K`/`L` run `Left`/`Down`/`Up`/`Right` and enter the neighboring pane's tag. `K` beyond the top pane reaches the headers below. `y` yanks; Enter/Tab Look/Exec; `i`/`a`/`I`/`A` enter insert mode, where letters type normally. Explicit mouse clicks choose a new cursor position. | Each tag keeps its own cursor position during the session. Positions are UTF-8 byte offsets in the rendered prefix plus editable tail; file-name changes are staged as described in [editable tags](tags.md). | pardes-specific | -| `K` (tag normal, topmost tagline) | focuses the column tag, then another `K` reaches the workspace tag. With `ColumnTags` disabled it goes directly to the workspace. `J` walks back down. Header arrows and `h`/`l` move by grapheme; word motions and `0`/`$`/`^` work too. `j`/`Esc` returns to the panel; lowercase `k` stays put. Enter/Tab executes in normal mode; `i`/`a`/`I`/`A` enter editing, where Enter is Look and Tab is Exec. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | -| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific | +| `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, `0` meaning the start of the editable text. Enter/Tab Look/Exec the word under the cursor or the selection, and executing hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. Cursor, selections and undo are offsets in the tag's own text; the computed path/marker/page before it is seen by the mouse, Look and Exec but not by keyboard motions. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific | +| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | +| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific | | `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | | `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | | `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words | | `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus | | a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific | -| `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the tag takes the pattern, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific | +| `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the pattern is typed on a line of its own on the pane's notice band, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific | | `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific | | insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) | | insert: `Enter` | newline; keeps the current full indent levels and adds one 4-space logical tab when the text before the cursor ends in `(`, `[`, `{`, or `)` (including `})`) | plain lines match `insert_newline`; delimiter heuristic is pardes-specific | helix-verified (plain) / pardes-specific (delimiter) | diff --git a/docs/tags.md b/docs/tags.md index f299c859..ecca046a 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -44,34 +44,54 @@ asks; there bare `Del` gives the rows to the nearest document above, as it always has. A collapsed pane is never asked about, and collapsed neighbors are passed over: they only keep a weight for later. -Left-click a tag to place its caret; drag to select text, then type to -replace it. Arrow keys, Home, End, Backspace and Delete edit the line. -Tags reveal the caret horizontally when text is wider than their column. -Escape returns to the body, keeping command-text edits. The existing Exec -and Look gestures still apply; the workspace tag is no longer an inert -left-click target. Pasted text goes to the focused tag, not to the file or -embedded shell beneath it. +A tag is a text like a pane's body: pane tags, column tags and the +workspace tag all edit with the body's own normal and insert modes, undo +included, and may hold more than one line. A tag with a newline in it takes +a row per line, up to eight: a pane's tag leaves its body at least one row +and a collapsed pane shows only the first line; the column and workspace +tags take no more than a third of the screen, and the panes below move down +to make room. The path, the dirty marker and a PDF's page at the start of a +pane tag are computed, never stored: the tag's cursor, selections and undo +live in the text after them, so a rename or a save never moves the cursor. +The mouse still sees the whole line -- a sweep selects across the path and +the commands, and Look and Exec work on either -- but the keyboard moves only +in the tag's own text: `0` goes to its start, not the path's. -Keyboard entry returns to the tag's last cursor position. The first entry into -a pane tag starts at its commands; clicking always chooses a new position. -Each pane, column and workspace tag remembers its own position during the -session. Shortened text clamps the saved cursor to a valid character boundary. +Left-click a tag or a header to type into it at the click, in insert mode; +tags reveal the caret horizontally when text is wider than their column. +Esc is normal mode, where everything a body's normal mode does works, and +Enter looks up and Tab executes the word under the cursor (or an explicit +selection), in either mode. Executing gives the keyboard back to the body +first, so `Del`, `Kill` and `Restore` never return into a tag that is gone. +Pasted text goes to the focused tag, not to the file or embedded shell +beneath it. -In normal mode, `h` and `l` move within the tag, `k` stays on the single line, -and `j` returns to the panel like Escape. Uppercase `HJKL` move between pane -tags. From the top pane's `:` tag, `K` reaches the column and another `K` reaches -the workspace; `J` walks back down. Enter or Tab on a header command in -normal mode executes it, as before. `i`/`a`/`I`/`A` switch to editing the -header. In insert mode, letters type normally, Enter is Look and Tab is Exec, -as in pane tags. +`:` is the one key a tag and a body do not share: in the body's normal mode +it focuses the pane's tag in normal mode, and in the tag's normal mode it +goes back to the body; in a column or workspace tag it goes back to the +active pane. Each pane, column and workspace tag remembers its own cursor +during the session; the first `:` into a pane tag starts on its `Save`. A +tag's text changing under it (a 9P write, a shorter tag) pulls the cursor +back inside it. + +Moving between tags is the window keys' job, as it is between bodies +(`Ctrl-w` or `SPC w` with `h/j/k/l`): they move to the neighbouring pane's +body. Up from a pane with nothing above it reaches its column's tag, then +the workspace's; Down comes back the same way, and Left and Right walk the +column tags. + +Search (`/`), pipe (`|`) and Save's path prompt are not typed into the tag: +each gets a line of its own on the pane's notice band, with the body's +insert-mode keys, and leaves the tag as it was. Pane filenames and commands now have a single separator space rather than generated right-alignment padding. Intentionally customized spacing is kept. ## File names -Editing a file pane's name creates a draft. Enter confirms the new buffer -name; Escape or leaving the tag cancels the draft. Confirmation changes the +Clicking a file pane's name starts a draft of it, typed into at the click. +Enter or Tab confirms the new buffer name; Escape or leaving the pane +cancels the draft. Confirmation changes the buffer's save target and marks it unsaved. It does **not** rename, create or overwrite a disk file. A subsequent explicit `Save` writes the buffer to its committed name. Executing a pane-tag command confirms a valid name draft diff --git a/src/body_layer.zig b/src/body_layer.zig index 2b2c8a74..ab000795 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -2,7 +2,8 @@ //! outnumber its physical grid rows: hosts place the compact declaration rows //! first and clip the remaining body rows. After the layer types come the //! functions that draw a body into the surface: its text, colours, selections -//! and cursor. +//! and cursor; then the tags drawn beside it, a pane's and the column and +//! workspace ones, each line a tag layer of its own. const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); @@ -24,6 +25,20 @@ const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Rect = layout.Rect; const Pardes = pardes.Pardes; +const tagline = @import("tagline.zig"); +const Messages = @import("Messages.zig"); +const Text = panes.Text; +const MAX_PANES = pardes.MAX_PANES; +const TagKind = @import("tag_layer.zig").Kind; +const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; +const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; +const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; +const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; +const MAX_COLS = pardes.MAX_COLS; +const TOPBAR_H = pardes.TOPBAR_H; +const COLBAR_H = pardes.COLBAR_H; +const TAG_GAP = pardes.TAG_GAP; +const wordBoundsAtCol = pardes.wordBoundsAtCol; pub const Metrics = struct { body_w: u16 = 1, @@ -540,3 +555,443 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); return panes.Terminal.bodyText(arena, pane); } + +/// The workspace and column tags as the grid shows them: their text, the +/// word under the pointer and, in the one being edited, its selection and +/// caret. Their tag layers are drawn by renderTagLayers. +pub fn paintHeaders(p: *Pardes) !void { + const s = &p.surface; + const chrome = p.chromeTheme(); + const th = p.theme(); + // global tagbar: full width, top rows — unless a native shell has + // taken its commands over, in which case the rows are not ours to draw + tagline.revealHeader(p); + const global = tagline.topbar(p); + const global_rows = p.topBarHeight(); + const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; + if (p.settings.workspace_tag) { + s.fill(0, 0, s.cols, global_rows, .{ + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + for (0..global_rows) |row| { + const line = modal.lineSlice(global, row); + _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ + .fg = .{ .rgb = chrome.tag_fg }, + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + } + } + if (p.pointer_inside and p.hover_row < global_rows) { + const bar = modal.lineSlice(global, p.hover_row); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; + while (col < hi and col < s.cols) : (col += 1) { + const cell = s.at(@intCast(col), p.hover_row); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + const column_rows = p.columnBarHeight(); + if (column_rows > 0) for (0..p.ncol) |c| { + const grip = @min(config.GUTTER, p.col_w[c]); + const inset = @min(TAG_TEXT_INSET, p.col_w[c]); + const x = p.col_x[c] + inset; + const w = p.col_w[c] - inset; + const y0 = global_rows; + if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; + const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; + const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + // A grip is muted until it is grabbed; then it lights, as a pane's + // box does for the active pane, so the drag shows what it holds. + const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; + const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; + const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; + const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; + const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; + s.fill(p.col_x[c], y0, grip, column_rows, grip_style); + s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + + s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; + const tag = tagline.columnTag(p, c); + for (0..column_rows) |row| { + const line = modal.lineSlice(tag, row); + _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + } + // A carried column's pointer is aiming at a place, not a word. + if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { + const bar = modal.lineSlice(tag, p.hover_row - y0); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + }; + if (tagline.focusedHeader(p)) |t| { + const text = try edit.flatSurface(p, t); + const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; + const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; + const y: u16 = if (p.header_column != null) global_rows else 0; + const rows = if (p.header_column != null) column_rows else global_rows; + for (0..rows) |row| { + const bar = modal.lineSlice(text, row); + const span = tagline.headerSelection(t, text, row) orelse continue; + var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; + const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + } + if (t.cur_row >= 0 and t.cur_row < rows) { + const bar = modal.lineSlice(text, @intCast(t.cur_row)); + const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; + } + } +} + +fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { + if (pane.isTerminal()) { + var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); + while (words.next()) |word| { + if (!std.mem.eql(u8, word, "Tty")) continue; + const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); + return .{ .lo = lo, .hi = lo + word.len }; + } + return null; + } + var start: usize = 0; + const name = if (pane.file) |file| + if (pane.prompt == .name) pane.input.own orelse file.path else file.path + else if (pane.image) |state| blk: { + // Image tags put renderer controls before the path. + start = prefix_len -| state.path.len; + break :blk state.path; + } else if (comptime pdf_enabled) blk: { + break :blk if (pane.pdf) |state| state.path else return null; + } else return null; + const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; + if (basename == name.len) return null; + return .{ .lo = start + basename, .hi = start + name.len }; +} + +pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { + const chrome = p.chromeTheme(); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; + const tag = try tagline.tagText(p, arena, pane); + const rows = pane.tag_rows; + // As wide as the widest line it shows, and never narrower than + // its viewport. + var widest: usize = 0; + var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); + const pitch = @as(usize, columns) + TAG_TEXT_INSET; + const storage = try arena.alloc(Cell, pitch * rows); + @memset(storage, .{}); + var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; + std.mem.swap(Surface, &p.surface, &temporary); + paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { + std.mem.swap(Surface, &p.surface, &temporary); + return err; + }; + std.mem.swap(Surface, &p.surface, &temporary); + // A layer to a line: the first in the pane's own slot, the rest + // in the slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + } + } + // With the workspace tag row handed to native chrome there is no + // workspace layer to render; the menu bar carries its commands. + if (p.settings.workspace_tag) { + for (0..p.topBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line), chrome.tag_bg, chrome.tag_fg); + } + } + // Each notice is its own tagline-shaped chip over the top of the body. + // Going through the same layer machinery the pane and column tags use + // is what gives it the tagline's height, its small-font metrics, the + // band offset and the border -- none of which a body-grid row can have + // just by wearing a tagline font. The geometry is the one the grid + // pass already chose and recorded, so the two cannot disagree and the + // GUI's "skip the cells a tag layer covers" leaves no stub behind. + // A prompt is one of them too: left on the grid, a pixel shell drew + // its tagline glyphs one to a BODY cell, spaced out like a banner. + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; + const first = p.bodyTop(pane, r); + var leader_buf: [16]u8 = undefined; + for (pane.notices.slice(), 0..) |kind, i| { + const text = Messages.noticeText(p, pane, i, &leader_buf); + if (text.len == 0) continue; + const cx = pane.notices.left[i]; + if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; + const chip = r.x + r.w - cx; + // Right aligned inside the chip, a blank cell short of its + // edge: the same one the grid pass leaves for a prompt caret. + // Wider than the chip, the text loses its head, as on the grid. + const room = p.tagCapacity(chip) -| 1; + const shown = panes.File.displayWidth(text); + var kept = text; + if (shown > room) { + var start = panes.File.rawAtDisplay(text, shown - room); + if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); + kept = text[start..]; + } + const pad = room -| panes.File.displayWidth(kept); + const line = try arena.alloc(u8, pad + kept.len); + @memset(line[0..pad], ' '); + @memcpy(line[pad..], kept); + // A pixel shell slides the message in as it fades up, and + // fades it on the way out. + const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; + const fade = motion.alpha; + const page = p.theme().bg orelse chrome.tag_bg; + const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ + .x = cx, + .y = first + @as(u16, pane.notices.row[i]), + .w = chip, + .h = BOX_H, + }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + p.surface.tag_layers[layer_index].slide = motion.slide; + p.surface.tag_layers[layer_index].fade = fade; + // The caret rides the band, where the prompt's glyphs are, + // at the same place in the text the grid pass puts it. + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + const dropped = shown - panes.File.displayWidth(kept); + if (typed < dropped) continue; + p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + } + } + if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { + if (p.col_w[column] == 0) continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; + for (0..p.columnBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + } + }; +} + +/// One row of a header or a notice band as a tag layer: `text` is line +/// `line` of what it shows. +fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { + _ = arena; + const layer = &p.surface.tag_layers[index]; + if (viewport.w == 0) { + layer.cols = 0; + return; + } + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + layer.kind = kind; + layer.id = id; + layer.line = line; + layer.serial = if (kind == .column) p.col_serial[id] else 0; + layer.viewport = viewport; + layer.cols = columns; + layer.bg = bg; + layer.cursor = null; + layer.slide = 0; + layer.fade = 1; + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; + // A notice band carries a PANE id, not a column index, so it must not + // answer to the column being edited -- that would scroll and select + // its text with the column tag's offsets and give it a second caret. + const editing: ?*Text = if (p.header_focus and switch (kind) { + .workspace => p.header_column == null, + .column => p.header_column == id, + .pane, .notice => false, + }) tagline.focusedHeader(p) else null; + const scroll = if (editing != null) p.header_scroll else 0; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line)) |span| { + var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + } + const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; + if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + } else if (p.pointer_inside) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) + else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } +} + +pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + @min(TAG_TEXT_INSET, r.w); + const tw = r.w -| TAG_TEXT_INSET; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const rows = pane.tag_rows; + const page_rgb = th.bg orelse th.tag_bg; + // Focus is visible along the tag without changing its live text grid. + const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; + // Padding belongs to the same font row but keeps its default ink: a + // role must not manufacture a foreground color for blank cells. + s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); + s.fill(tx, tag_y, tw, rows, .{ + .bg = .{ .rgb = tag_bg }, + .font_role = .tagline, + }); + const whole = try tagline.tagText(p, arena, pane); + const tail = tagline.curTail(pane); + const prefix_len = whole.len - tail.len; + // A row per line: the first carries the prefix, the rest are the + // tag's own text alone. + var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); + var split = std.mem.splitScalar(u8, whole, '\n'); + for (lines[0..rows]) |*line| line.* = split.next() orelse ""; + // The keyboard's caret, as a row and a byte offset in that row's + // line: in the tag's own text when the tag has the keyboard, in the + // path while a name is drafted. + const Caret = struct { row: usize, at: usize }; + const caret: ?Caret = if (!active) + null + else if (pane.prompt == .name) + .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } + else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: { + const row: usize = @intCast(@max(0, pane.tag.cur_row)); + const lead: usize = if (row == 0) prefix_len else 0; + break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + } else null; + if (caret) |at| { + const line = lines[at.row]; + const col = panes.File.rawDisplayCol(line, at.at); + var scroll: usize = pane.tag_scroll; + if (col < scroll) scroll = col; + if (col >= scroll + capacity) scroll = col - capacity + 1; + var raw = panes.File.rawAtDisplay(line, scroll); + scroll = panes.File.rawDisplayCol(line, raw); + if (col >= scroll + capacity) { + raw = modal.nextGrapheme(line, raw); + scroll = panes.File.rawDisplayCol(line, raw); + } + pane.tag_scroll = @intCast(scroll); + } else if (pane.focus != .tag) pane.tag_scroll = 0; + for (lines[0..rows], 0..) |line, row| { + const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; + if (tagTintRange(pane, tag, prefix_len)) |name| { + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + const hi = @min(name.hi, tag.len); + if (lo < hi) { + var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + while (col < end and col < tw) : (col += 1) + s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; + } + } + if (p.look_hover_preview) |preview| { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); + paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + }; + } + // The tag's own selections, while it has the keyboard, a line at a + // time. A range of one grapheme is the block cursor, not a selection. + if (pane.focus == .tag) { + var ranges: [Text.max_selections]modal.Selection = undefined; + const got = pane.tag.ranges(tail, 0, &ranges); + for (ranges[0..got.n]) |range| { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (modal.nextGrapheme(tail, lo) >= hi) continue; + const first = modal.positionAt(tail, lo); + const last = modal.positionAt(tail, hi); + var row = first.row; + while (row <= last.row and row < rows) : (row += 1) { + const line = lines[row]; + const lead: usize = if (row == 0) prefix_len else 0; + const from = lead + if (row == first.row) first.col else 0; + const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); + var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(line, to); + const y = tag_y + @as(u16, @intCast(row)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + } + // the caret: bar while typing, block in normal mode (same rule as a body) + if (caret) |at| { + const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; + const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; + } + + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; + paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + } +} diff --git a/src/builtins.zig b/src/builtins.zig index 74645376..e9f29102 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,6 +1,7 @@ //! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); @@ -742,7 +743,7 @@ pub const Grep = struct { pub const Left = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column) |column| if (column > 0) c.p.enterHeader(column - 1); + if (c.p.header_column) |column| if (column > 0) tagline.enterHeader(c.p, column - 1); return; } _ = layout.focusDir(c.p, c.id, .left); @@ -754,10 +755,10 @@ pub const Down = struct { if (c.p.header_focus) { const column = layout.findPane(c.p, c.id); if (c.p.header_column == null and c.p.columnBarHeight() > 0) - return c.p.enterHeader(if (column) |at| at.col else 0); + return tagline.enterHeader(c.p, if (column) |at| at.col else 0); // Onto the top pane of the column the header stands over. const col = c.p.header_column orelse if (column) |at| at.col else return; - c.p.exitHeader(); + tagline.exitHeader(c.p); if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0]; return; } @@ -768,20 +769,20 @@ pub const Down = struct { pub const Up = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column != null and c.p.topBarHeight() > 0) c.p.enterHeader(null); + if (c.p.header_column != null and c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); return; } if (layout.focusDir(c.p, c.id, .up)) return; if (c.p.columnBarHeight() > 0) { - if (layout.findPane(c.p, c.id)) |at| c.p.enterHeader(at.col); - } else if (c.p.topBarHeight() > 0) c.p.enterHeader(null); + if (layout.findPane(c.p, c.id)) |at| tagline.enterHeader(c.p, at.col); + } else if (c.p.topBarHeight() > 0) tagline.enterHeader(c.p, null); } }; pub const Right = struct { pub fn run(c: Ctx) void { if (c.p.header_focus) { - if (c.p.header_column) |column| if (column + 1 < c.p.ncol) c.p.enterHeader(column + 1); + if (c.p.header_column) |column| if (column + 1 < c.p.ncol) tagline.enterHeader(c.p, column + 1); return; } _ = layout.focusDir(c.p, c.id, .right); diff --git a/src/colors.zig b/src/colors.zig index f92f67b1..bb366fc0 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -1,6 +1,7 @@ //! Colour themes: the Theme type, the curated and generated ring, chrome //! colours and their animation, theme files, and switching themes at runtime. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const panes = @import("panes.zig"); const std = @import("std"); const layout = @import("layout.zig"); @@ -602,7 +603,7 @@ test "line numbers stay quiet without a current-line tag background" { const moved = try p.render(p.scratch.allocator()); try std.testing.expect(!moved.at(x, y).style.bold); try std.testing.expect(moved.at(x, y + 1).style.bold); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_edit = try p.render(p.scratch.allocator()); try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); } diff --git a/src/dump.zig b/src/dump.zig index aeb25a90..508e7300 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -7,6 +7,7 @@ const limits = @import("memory.zig").limits; // diagnostic is the only thing that says WHICH field went bad. const log = std.log.scoped(.dump); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const layout = @import("layout.zig"); const filesystem = @import("fs.zig"); const ctlfs = @import("ninep/tree.zig"); @@ -603,7 +604,7 @@ pub fn dumpState(p: *Pardes) !void { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes_len; - const tag = try p.tagText(arena, pane); + const tag = try tagline.tagText(p, arena, pane); const body = try body_layer.bodyText(p, arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); var dp: Pane = if (pane.file) |*f| try pardes.panes.File.dumpPane( @@ -784,7 +785,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo break :restore_image try pardes.panes.Image.restore(p, i, src); }, }; - p.restoreDumpTail(pane, src); + tagline.restoreDumpTail(p, pane, src); pane.vweight = src.vweight; pane.collapsed = src.collapsed; } diff --git a/src/edit.zig b/src/edit.zig index 36544d6b..edc86dd2 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -3,6 +3,7 @@ //! operations, replace, case, join, indent, comments, numbers, textobjects //! and surround, undo, yank and paste, and the pointer selections as text. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const mouse = @import("mouse.zig"); const normal = @import("normal.zig"); const std = @import("std"); @@ -15,7 +16,8 @@ const lsp = @import("lsp/lsp.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; -const TagHit = @import("tag_layer.zig").Hit; +const tag_layer = @import("tag_layer.zig"); +const TagHit = tag_layer.Hit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Mouse = pardes.Mouse; @@ -72,7 +74,7 @@ pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) voi } pub fn applyPaste(p: *Pardes, bytes: []const u8) void { - if (p.focusedHeader()) |t| { + if (tagline.focusedHeader(p)) |t| { p.clip_pending = null; return pasteInto(p, t, bytes); } @@ -132,7 +134,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { /// (the tag's lines first, as many as it shows) pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); - const tag = try p.tagText(arena, pane); + const tag = try tagline.tagText(p, arena, pane); const cut = modal.lineStartOffset(tag, pane.tag_rows); const shown = if (cut < tag.len) tag[0 .. cut - 1] else tag; return std.fmt.allocPrint(arena, "{s}\n{s}", .{ shown, try body_layer.bodyText(p, arena, pane) }); @@ -353,9 +355,9 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void { pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { switch (t.what) { .body => {}, - .tag => return Pardes.curTail(t.pane()), + .tag => return tagline.curTail(t.pane()), .input => return t.own orelse "", - .column, .workspace => return t.own orelse p.headerDefault(t.what), + .column, .workspace => return t.own orelse tagline.headerDefault(p, t.what), } const pane = t.pane(); if (pane.file) |f| return f.content; @@ -1617,7 +1619,7 @@ pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, bod const v = @as(i32, mapped_hit.row) + pane.tag_rows; return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false }; }; - const tag_col = if (on_tag) mouse.tagColumn(p, .pane, id, tag_hit, false, row - tag_y) else null; + const tag_col = if (on_tag) tag_layer.columnAt(p, .pane, id, tag_hit, false, row - tag_y) else null; const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) + (if (on_tag) @as(i32, pane.tag_scroll) else 0); const v: i32 = if (on_tag) @as(i32, row) - @as(i32, tag_y) else @as(i32, row) - @as(i32, body_y) + @as(i32, pane.tag_rows); diff --git a/src/exec.zig b/src/exec.zig index a673e9e7..a0e3d47f 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -3,6 +3,7 @@ //! chord hands over, the terminals commands run in, placing a document, and //! saving a pane. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const std = @import("std"); const layout = @import("layout.zig"); const modal = @import("modal.zig"); @@ -352,7 +353,7 @@ pub fn replaceStillborn(p: *Pardes) void { const out = file.output orelse return; if (out.from != .cmd or out.from.cmd != .New) return; if (file.revision != 0 or file.content.len != 0) return; - if (old.tag.own) |own| if (!std.mem.eql(u8, own, Pardes.defaultTail(old))) return; + if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return; const was_active = p.active == old_id; layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); @@ -490,7 +491,7 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (p.settings.column_tags != previous_column_tags) { - if (p.header_column != null) p.exitHeader(); + if (p.header_column != null) tagline.exitHeader(p); } if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); diff --git a/src/fs-help.txt b/src/fs-help.txt index 4eecad41..efdecf46 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -39,7 +39,7 @@ Pitfalls, one each: addr, dot and limit read the same pair of offsets they take, so cp between them works. dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences. - Truncating tag clears the part you may edit; truncating dot or addr empties it. + tag reads path++text; writes append to the text (newlines too); truncating clears it, and dot or addr. A terminal's body is a history snapshot frozen per open; pty/data is the live stream. A failing command is reported in the editor and in log, not as a write error; a bad line fails the write. pane/<n>/ctl: acme's status line; takes get (reload), lock/unlock on a held fd (exec 3>ctl; echo lock >&3). @@ -36,10 +36,12 @@ else const source_files = [_]Source{ .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, + .{ .path = "src/Text.zig", .contents = @embedFile("Text.zig") }, .{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") }, .{ .path = "src/colors.zig", .contents = @embedFile("colors.zig") }, .{ .path = "src/Messages.zig", .contents = @embedFile("Messages.zig") }, .{ .path = "src/body_layer.zig", .contents = @embedFile("body_layer.zig") }, + .{ .path = "src/tagline.zig", .contents = @embedFile("tagline.zig") }, .{ .path = "src/edit.zig", .contents = @embedFile("edit.zig") }, .{ .path = "src/normal.zig", .contents = @embedFile("normal.zig") }, .{ .path = "src/mouse.zig", .contents = @embedFile("mouse.zig") }, diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7bf45676..415b21e8 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7417,7 +7417,7 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expect(x >= 20 * 8 - 0.01); try std.testing.expect(y >= 0 and y < 20); } - core.enterHeader(null); + pardes.tagline.enterHeader(core, null); builder.len = 0; appendWorkspacePet(&g, core, &surface, &builder); try std.testing.expectEqual(@as(usize, 0), builder.len); diff --git a/src/layout.zig b/src/layout.zig index 54b89081..8e44f1d9 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -1,5 +1,6 @@ const std = @import("std"); const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const config = @import("config.zig"); const panes = @import("panes.zig"); @@ -934,7 +935,7 @@ pub fn removePane(p: *Pardes, id: usize) void { while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; p.col_n[c] -= 1; if (p.col_n[c] == 0) { - p.exitHeader(); + tagline.exitHeader(p); if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; p.col_tags[c].deinit(p.gpa); var j = c; @@ -955,7 +956,7 @@ pub fn joinCol(p: *Pardes) void { const f = findPane(p, p.active) orelse return; const c = f.col; if (c + 1 >= p.ncol) return; - p.exitHeader(); + tagline.exitHeader(p); const dst = c + 1; p.col_weight[dst] +|= p.col_weight[c]; for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; @@ -1010,7 +1011,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { const new_weight = old_weight - source_weight; p.col_weight[source_col] = source_weight; const c = source_col + @intFromBool(!before); - p.exitHeader(); + tagline.exitHeader(p); var j = p.ncol; while (j > c) : (j -= 1) { p.col_panes[j] = p.col_panes[j - 1]; @@ -2262,7 +2263,7 @@ test "column reorder moves only crossed state and preserves weights" { try std.testing.expectEqual(original_weights[2], p.col_weight[1]); try std.testing.expectEqual(@as(u32, 11), p.col_serial[3]); try std.testing.expectEqual(@as(?usize, 3), p.header_column); - try std.testing.expectEqual(@as(i32, 21), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 21), tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(@as(usize, 9), p.header_scroll); try std.testing.expectEqual(outer_x, p.col_x[0]); try std.testing.expectEqual(outer_w, p.col_w[0]); diff --git a/src/look.zig b/src/look.zig index 8aa2c0a1..cbdf0bea 100644 --- a/src/look.zig +++ b/src/look.zig @@ -19,7 +19,8 @@ const Color = @import("surface.zig").Color; const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const TagHit = @import("tag_layer.zig").Hit; +const tag_layer = @import("tag_layer.zig"); +const TagHit = tag_layer.Hit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const pathUnder = pardes.pathUnder; @@ -878,9 +879,9 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return; + if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and tag_layer.sameCell(shown.tag_hit, tag_hit)) return; if (p.look_hover_wait) |waiting| - if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return; + if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return; cancelLookHover(p); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; diff --git a/src/mouse.zig b/src/mouse.zig index 4b624dca..df657ead 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -2,6 +2,7 @@ //! releases (acme's chords, border and seam drags, gestures), and the //! chrome targets a shell asks about. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); @@ -19,8 +20,7 @@ const pdf = panes.Pdf.pdf; const platform = pardes.platform; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const TagHit = @import("tag_layer.zig").Hit; -const TagKind = @import("tag_layer.zig").Kind; +const tag_layer = @import("tag_layer.zig"); const TOPBAR_H = pardes.TOPBAR_H; const BOX_H = pardes.BOX_H; const TAG_GAP = pardes.TAG_GAP; @@ -176,30 +176,6 @@ pub fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clam return p.reprojectBodyHit(mapped_hit); } -pub fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { - const first = a orelse return b == null; - const second = b orelse return false; - return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col; -} - -/// The tag column a host's pointer is at. Clamped, a point off the tag is -/// brought back onto line `line` of it, for a drag that leaves it. -pub fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool, line: u16) ?u16 { - var point = supplied orelse return null; - if (point.kind != kind or point.id != id) return null; - if (clamp) for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue; - const bw: f32 = @floatFromInt(point.metrics.body_w); - const bh: f32 = @floatFromInt(point.metrics.body_h); - const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; - point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; - break; - }; - return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; -} - pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { const first = a orelse return b == null; const second = b orelse return false; @@ -294,7 +270,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { - const t = p.focusedHeader() orelse { + const t = tagline.focusedHeader(p) orelse { p.header_drag = false; return; }; @@ -303,7 +279,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const top = if (p.header_column != null) p.topBarHeight() else 0; const line: u16 = @intCast(@min(mrow -| top, modal.lineCount(text) -| 1, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); const bar = modal.lineSlice(text, line); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + p.header_scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = line; t.cur_col = @intCast(at); @@ -338,23 +314,23 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { if (line > 0 and line >= modal.lineCount(text)) return; const bar = modal.lineSlice(text, line); const scroll = if (focused) p.header_scroll else 0; - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, line) orelse (mcol -| x)) + scroll)); if (m.button == config.select_button) { // A clicked header is typed straight into, as a clicked tag is. - if (!focused) p.enterHeader(column); + if (!focused) tagline.enterHeader(p, column); t.mode = .insert; t.cur_row = line; t.cur_col = @intCast(at); t.vsel = .{ .active = false, .row = line, .col = @intCast(at) }; p.header_drag = true; } else { - const span = if (focused) Pardes.headerSelection(t, text, line) else null; + const span = if (focused) tagline.headerSelection(t, text, line) else null; const selected = if (span) |sp| (if (at >= sp.lo and at < sp.hi) bar[sp.lo..sp.hi] else wordAtCol(bar, at)) else wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; - p.exitHeader(); + tagline.exitHeader(p); if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); @@ -366,7 +342,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { return; } if (m.kind == .press) { - p.exitHeader(); + tagline.exitHeader(p); } switch (m.button) { @@ -484,7 +460,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { pane.tag.msel.active = false; pane.tag.nsel = 0; pane.tag.cur_pinned = true; - const at = p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.r0)), @intCast(@max(0, pointed.sel.c0))) orelse modal.Cursor{ .row = 0, .col = 0 }; + const at = tagline.tagOffsetAt(p, pane, @intCast(@max(0, pointed.sel.r0)), @intCast(@max(0, pointed.sel.c0))) orelse modal.Cursor{ .row = 0, .col = 0 }; pane.tag.cur_row = @intCast(at.row); pane.tag.cur_col = @intCast(at.col); } @@ -626,7 +602,7 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo // nearest the pointer. const tag_y = p.tagTop(pane, r); const tag_line: u16 = @intCast(std.math.clamp(@as(i32, mrow) - @as(i32, tag_y), 0, tag_rows - 1)); - if (pane.sel[b].r0 < tag_rows) if (tagColumn(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| { + if (pane.sel[b].r0 < tag_rows) if (tag_layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| { pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; pane.sel[b].r1 = tag_line; return; @@ -683,7 +659,7 @@ fn reportGesture( fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { - const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; + const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; const row: usize = @intCast(@max(0, lead.r0)); @@ -847,11 +823,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { if (sl.r0 < pane.tag_rows) { // On the tag the caret goes where the button came up. A // click on the path, if it can be renamed, drafts a name. - const at = p.tagOffsetAt(pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1))); + const at = tagline.tagOffsetAt(p, pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1))); if (at) |cursor| { pane.tag.cur_row = @intCast(cursor.row); pane.tag.cur_col = @intCast(cursor.col); - } else if (!dragged) p.draftName(pane, @intCast(@max(0, sl.c1))); + } else if (!dragged) tagline.draftName(p, pane, @intCast(@max(0, sl.c1))); return; } if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; @@ -908,13 +884,13 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (clk.r0 == 0 and s.button == config.exec_button and pane.prompt == .name) { - const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const prefix_width = panes.File.displayWidth(tagline.tagPrefix(p, pane) catch return); const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; - if (!p.commitTagName(pane)) return; + if (!tagline.commitTagName(p, pane)) return; // Executing a staged filename commits it; executing its // command tail uses the new identity (especially Save). if (on_name) return; - const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const new_width = panes.File.displayWidth(tagline.tagPrefix(p, pane) catch return); const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); if (operand.expanded) |*sel| { sel.c0 += shift; @@ -962,8 +938,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { const start = if (forward) .{ sl.r0, sl.c0 } else .{ sl.r1, sl.c1 }; const end = if (forward) .{ sl.r1, sl.c1 } else .{ sl.r0, sl.c0 }; const origin: modal.Cursor = .{ .row = 0, .col = 0 }; - const from = p.tagOffsetAt(pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; - const to = p.tagOffsetAt(pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; + const from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin; + const to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin; t.cur_row = @intCast(to.row); t.cur_col = @intCast(to.col); t.cur_pinned = true; diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 282ae473..8e827fba 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -3,6 +3,7 @@ //! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); +const tagline = @import("../tagline.zig"); const exec = @import("../exec.zig"); const look = @import("../look.zig"); const panes = @import("../panes.zig"); @@ -79,7 +80,7 @@ pub fn fileOf(pane: *Pane) ?*panes.File.State { } pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; + return tagline.tagText(p, p.scratch.allocator(), pane) catch ""; } pub fn dirOf(pane: *Pane) []const u8 { @@ -422,7 +423,7 @@ fn writeTag(req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; // The tag's own text grows by what is written, newlines and all: a tag // is a text like any other (acme's tag file appends the same way). - const had = Pardes.curTail(pane).len; + const had = tagline.curTail(pane).len; const room = limits.max_tag_tail -| had; if (room == 0) return Reply.fail(req.tag, E.NOSPC); const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); @@ -746,7 +747,7 @@ test "tag reads the whole tag and writes append to the editable tail" { const before = rd(p, node, 0, 4096).bytes.len; const w = wr(p, node, " Mine"); try testing.expectEqual(@as(u32, 5), w.reply.written); - try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine")); + try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); const after = rd(p, node, 0, 4096); try testing.expectEqual(before + 5, after.bytes.len); try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); @@ -754,7 +755,7 @@ test "tag reads the whole tag and writes append to the editable tail" { // A tag is a text like the body: a newline written to it stays one, and // the tag reads back taller by it. try testing.expectEqual(@as(u32, 10), wr(p, node, "\nNext Line").reply.written); - try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine\nNext Line")); + try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine\nNext Line")); try testing.expect(std.mem.endsWith(u8, rd(p, node, 0, 4096).bytes, " Mine\nNext Line")); if (pane.tag.own) |own| p.gpa.free(own); @@ -887,7 +888,7 @@ test "truncating the tag clears its editable tail" { const pane = p.panes[0].?; const tag = Node.of(pane.serial, .tag); _ = wr(p, tag, " Mine"); - try testing.expect(Pardes.curTail(pane).len > 0); + try testing.expect(tagline.curTail(pane).len > 0); const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); try testing.expectEqual(Status.ok, cleared.reply.status); try testing.expectEqualStrings("", pane.tag.own.?); diff --git a/src/normal.zig b/src/normal.zig index 630f7689..29d6d620 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -2,6 +2,7 @@ //! modal actions, replayed across every selection, and each action is done //! to the pane; PDF panes get their own normal keys. const pardes = @import("pardes.zig"); +const tagline = @import("tagline.zig"); const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); @@ -556,8 +557,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void // keyboard between them. .command_line => switch (t.what) { .tag => pane.focus = .body, - .column, .workspace => p.exitHeader(), - .body, .input => p.enterTag(pane), + .column, .workspace => tagline.exitHeader(p), + .body, .input => tagline.enterTag(p, pane), }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), @@ -600,7 +601,7 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { p.leader_on = true; p.leader_n = 0; }, - .command_line => p.enterTag(pane), + .command_line => tagline.enterTag(p, pane), .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => look.lookWalk(p, 1), .search_backward => look.lookWalk(p, -1), diff --git a/src/panes.zig b/src/panes.zig index 6887008c..3cd66eb7 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -299,7 +299,7 @@ pub const Pane = struct { /// 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.Pardes.defaultTail(pane); + 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; diff --git a/src/pardes.zig b/src/pardes.zig index d07539ba..c802f0ea 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); @@ -445,6 +446,7 @@ pub const Loc = struct { }; test { + _ = @import("tagline.zig"); _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("mouse.zig"); @@ -459,48 +461,9 @@ test { _ = @import("tag_layer.zig"); } -const pane_tail = " " ++ config.pane_builtins_str; -const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; -const file_pane_tail = " " ++ config.file_pane_builtins_str; -const context_pane_tail = file_pane_tail ++ " TreeContext"; -const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; -const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; -const dirty_marker = " *"; -const legacy_pane_tail = " Del"; -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"; -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. -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"; -const pre_column_file_pane_tail = " Save New Newtty Del"; -const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; -const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; -const pre_collapse_pane_tail = " Tty Del"; -const pre_collapse_file_pane_tail = " Save Tty Del"; -const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; -const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; -const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; -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"; - pub const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { - return p.tagText(arena, pane); - } - pub fn runBuiltin( p: *Pardes, name: []const u8, @@ -669,11 +632,11 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; - const rendered = try p.tagText(p.scratch.allocator(), pane); + const rendered = try tagline.tagText(p, p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { @@ -738,7 +701,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" p.sync(); const height = p.rects[0].h; const revision = pane.file.?.revision; - for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail }) |tail| try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); @@ -749,7 +712,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); pane.focus = .body; @@ -761,9 +724,9 @@ test "Collapse builtin keeps content and restores the pane from its default tag" // Unedited defaults from the preceding version gain the new command. if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; - p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); + tagline.restoreTailAt(p, pane, tagline.pre_collapse_file_pane_tail, "", .file); try std.testing.expect(pane.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); } test "LocationsConfig command reports partial updates and survives restore" { @@ -850,12 +813,12 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer p.deinit(); const pane = try p.setTestFile("pub fn main() void {}\n"); try std.testing.expect(!panes.File.supportsContext(pane)); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); const path = try p.gpa.dupe(u8, "/test.zig"); p.gpa.free(pane.file.?.path); pane.file.?.path = path; if (!panes.File.supportsContext(pane)) return error.SkipZigTest; - try std.testing.expectEqualStrings(context_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.context_pane_tail, tagline.curTail(pane)); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on")); try std.testing.expect(pane.file.?.tree_context); @@ -872,14 +835,14 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer restored.deinit(); const saved = restored.panes[0].?; try std.testing.expect(saved.file.?.tree_context); - try std.testing.expectEqualStrings(" Save Keep Del", Pardes.curTail(saved)); + try std.testing.expectEqualStrings(" Save Keep Del", tagline.curTail(saved)); } test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Mode ") != null); + try std.testing.expect(std.mem.indexOf(u8, tagline.curTail(pane), " Mode ") != null); try pane.appendTag(" Togglettymode"); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); @@ -887,19 +850,19 @@ test "Togglettymode builtin and keyboard chord share mode transitions including p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); // The tag has its own mode: the body keeps tty while the tag is typed into. - p.enterTag(pane); + tagline.enterTag(p, pane); try std.testing.expect(pane.focus == .tag); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); - p.enterTag(pane); + tagline.enterTag(p, pane); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(pane.focus == .body); p.acknowledgePanelPresentation(&.{}); - const tag = try p.tagText(p.scratch.allocator(), pane); + const tag = try tagline.tagText(p, p.scratch.allocator(), pane); const x = p.rects[0].x + TAG_TEXT_INSET + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); const y = p.rects[0].y; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); @@ -965,30 +928,30 @@ test "compact tag commands stay beside their own prefix across layout changes" { const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); - const above_tag = try p.tagText(gpa, above); + const above_tag = try tagline.tagText(p, gpa, above); defer gpa.free(above_tag); - const below_tag = try p.tagText(p.scratch.allocator(), below); + const below_tag = try tagline.tagText(p, p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); - const tail = terminal_pane_tail; + const tail = tagline.terminal_pane_tail; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(tail, Pardes.curTail(below)); + try std.testing.expectEqualStrings(tail, tagline.curTail(below)); try below.appendTag(" lots and lots of typing out here"); - try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); + try std.testing.expectEqual(above_tag.len, (try tagline.tagText(p, p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { @@ -998,120 +961,120 @@ test "legacy default tag tails upgrade while custom tails remain owned" { const terminal = p.panes[0].?; const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - legacy_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.legacy_pane_tail, }); - p.restoreTail(terminal, terminal_old); + tagline.restoreTail(p, terminal, terminal_old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_previous); + tagline.restoreTail(p, terminal, terminal_previous); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - prev_terminal_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.prev_terminal_pane_tail, }); - p.restoreTail(terminal, terminal_prefilter); + tagline.restoreTail(p, terminal, terminal_prefilter); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| { - const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail }); - p.restoreTail(terminal, old); + for ([_][]const u8{ tagline.pre_mode_terminal_pane_tail, tagline.pre_tty_first_terminal_pane_tail, tagline.pre_collapse_terminal_pane_tail }) |old_tail| { + const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try tagline.tagPrefix(p, terminal), old_tail }); + tagline.restoreTail(p, terminal, old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); } - const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail }); - p.restoreTail(terminal, custom_mode); + const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try tagline.tagPrefix(p, terminal), tagline.pre_mode_terminal_pane_tail }); + tagline.restoreTail(p, terminal, custom_mode); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.pre_mode_terminal_pane_tail ++ " Keep", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_custom); + tagline.restoreTail(p, terminal, terminal_custom); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_fileish); + tagline.restoreTail(p, terminal, terminal_fileish); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Del", tagline.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w<Tab>` route is unchanged. const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(file), - legacy_file_pane_tail, + try tagline.tagPrefix(p, file), + tagline.legacy_file_pane_tail, }); - p.restoreTail(file, file_old); + tagline.restoreTail(p, file, file_old); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_custom); + tagline.restoreTail(p, file, file_custom); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Mine Del", tagline.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. if (file.tag.own) |own| p.gpa.free(own); file.tag.own = null; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_generic); + tagline.restoreTail(p, file, file_generic); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" New Del", tagline.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); - p.restoreDumpTail(image_doc, .{ + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(image_doc)); + tagline.restoreDumpTail(p, image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); try std.testing.expect(image_doc.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(image_doc)); } test "Joincol folds the active column into its right neighbor, keeping its panes" { @@ -1245,19 +1208,19 @@ test "first document keeps its originating shell and only opens a column when it test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail, config.topbar_str }) |tag| { + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail, config.topbar_str }) |tag| { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } - try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0)); + try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0)); const terminal = p.panes[0].?; - p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); + tagline.restoreTailAt(p, terminal, tagline.pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); - p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); + tagline.restoreTailAt(p, file, tagline.pre_column_file_pane_tail, "", .file); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file)); } test "an unsaved file marker sits between its path and builtins until Save" { @@ -1267,29 +1230,29 @@ test "an unsaved file marker sits between its path and builtins until Save" { while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); - const clean = try p.tagText(p.scratch.allocator(), pane); + const clean = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); - const dirty = try p.tagText(p.scratch.allocator(), pane); + const dirty = try tagline.tagText(p, p.scratch.allocator(), pane); const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - const saved = try p.tagText(p.scratch.allocator(), pane); + const saved = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); panes.File.changed(p, 0, "external\n"); - const reloaded = try p.tagText(p.scratch.allocator(), pane); + const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); - const output = try p.tagText(p.scratch.allocator(), pane); + const output = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } @@ -1422,7 +1385,7 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(restored_pane)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(restored_pane)); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try dump.dumpState(restored); @@ -1903,7 +1866,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" }); @@ -2091,17 +2054,17 @@ test "Save reaches every tagline with text behind it and no other" { const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; - try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - pane_tail, + try tagline.tagPrefix(p, out), + tagline.pane_tail, }); - p.restoreTail(out, dumped); + tagline.restoreTail(p, out, dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. @@ -2109,18 +2072,18 @@ test "Save reaches every tagline with text behind it and no other" { if (out.tag.own) |own| p.gpa.free(own); out.tag.own = null; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - prev_file_pane_tail, + try tagline.tagPrefix(p, out), + tagline.prev_file_pane_tail, }); - p.restoreTail(out, scratch_dumped); + tagline.restoreTail(p, out, scratch_dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -2545,7 +2508,7 @@ comptime { const WordBounds = struct { lo: usize, hi: usize }; -fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { +pub fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; @@ -2636,7 +2599,7 @@ test "Unicode display cells map back to body and tag byte cursors" { // A click in the wide path glyph drafts a name with its caret at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. - p.draftName(pane, 1); + tagline.draftName(p, pane, 1); try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col); edit.insertKey(p, &pane.input, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col); @@ -2644,15 +2607,15 @@ test "Unicode display cells map back to body and tag byte cursors" { try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col); pane.closePrompt(); - const before = try gpa.dupe(u8, Pardes.curTail(pane)); + const before = try gpa.dupe(u8, tagline.curTail(pane)); defer gpa.free(before); - p.enterTag(pane); + tagline.enterTag(p, pane); const insertion = pane.tag.cur_col; edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag.cur_col); edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag.cur_col); - try std.testing.expectEqualStrings(before, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(before, tagline.curTail(pane)); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { @@ -2726,23 +2689,23 @@ test "editable workspace and column tags are typed into and persist" { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "New")); p.update(.{ .paste = "Grep " }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); - try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); + try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); const empty = try dump.restore(p, p.dump_out.?); defer empty.deinit(); - try std.testing.expectEqualStrings("", empty.columnTag(0)); + try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } test "column tags follow their column through split and close" { @@ -2752,12 +2715,12 @@ test "column tags follow their column through split and close" { p.col_tags[0].own = try p.gpa.dupe(u8, "Left Find"); _ = exec.execute(p, 0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); + try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); - try std.testing.expectEqualStrings("Right Grep", p.columnTag(0)); + try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } test "column tags render edit hover and scroll from the column edge" { @@ -2780,7 +2743,7 @@ test "column tags render edit hover and scroll from the column edge" { try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); try std.testing.expectEqual(column, p.header_column.?); - try std.testing.expectEqual(@as(i32, 0), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 0), tagline.focusedHeader(p).?.cur_col); const at_start = try p.render(frame.allocator()); try std.testing.expectEqual(x, at_start.cursor.?.x); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); @@ -2790,7 +2753,7 @@ test "column tags render edit hover and scroll from the column edge" { try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help " } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help New Tty")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, column), "Help New Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 5, edited.cursor.?.x); // Only the text viewport participates in caret reveal. @@ -2844,7 +2807,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); - try std.testing.expectEqualStrings("New extended", p.columnTag(0)); + try std.testing.expectEqualStrings("New extended", tagline.columnTag(p, 0)); p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); const frame = try p.render(p.scratch.allocator()); try std.testing.expect(p.header_scroll > 0); @@ -2866,7 +2829,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } }); - try std.testing.expectEqualStrings("New\nSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0)); try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight()); const frame = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y); @@ -2884,7 +2847,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane // Taking the newline back out gives the panes their row again. p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expectEqualStrings("NewSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0)); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); } @@ -2916,7 +2879,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); try std.testing.expect(!p.header_focus and p.header_column == null); try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(p.settings.column_tags); @@ -2932,7 +2895,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expect(!p.header_focus and p.header_column == null); p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); try std.testing.expectEqual(shown, p.rects[0]); - try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } @@ -2940,7 +2903,7 @@ test "a header's execute key runs the word under its cursor and lets go of the k const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.enterHeader(null); + tagline.enterHeader(p, null); p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.update(.{ .key = .{ .cp = Key.tab } }); try std.testing.expect(!p.header_focus); @@ -4082,23 +4045,6 @@ pub const Pardes = struct { return a; } - /// 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 - fn topbar(p: *Pardes) []const u8 { - return p.global_tag.own orelse p.headerDefault(.workspace); - } - /// The workspace tag's height: a row per line of it while the setting /// keeps it, zero once a native shell takes its commands over. Layout and /// hit testing read this rather than the constant, the way they read @@ -4119,36 +4065,6 @@ pub const Pardes = struct { return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3))); } - 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; - } - - fn revealHeader(p: *Pardes) void { - const t = p.focusedHeader() 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); - } - } - fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); @@ -4834,7 +4750,7 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.fs.readers == 0) continue; - const tag = p.tagText(p.scratch.allocator(), pane) catch continue; + const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch continue; const snap = &pane.fs.tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) @@ -4846,39 +4762,6 @@ pub const Pardes = struct { // ---- tag + selection text (chord sources) ---- - 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; - } - pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; @@ -4886,168 +4769,13 @@ pub const Pardes = struct { return null; } - /// 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 p.tagPrefix(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; - } - - fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { - if (pane.isTerminal()) { - var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); - while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty")) continue; - const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); - return .{ .lo = lo, .hi = lo + word.len }; - } - return null; - } - var start: usize = 0; - const name = if (pane.file) |file| - if (pane.prompt == .name) pane.input.own orelse file.path else file.path - else if (pane.image) |state| blk: { - // Image tags put renderer controls before the path. - start = prefix_len -| state.path.len; - break :blk state.path; - } else if (comptime pdf_enabled) blk: { - break :blk if (pane.pdf) |state| state.path else return null; - } else return null; - const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; - if (basename == name.len) return null; - return .{ .lo = start + basename, .hi = start + name.len }; - } - - /// 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 = p.tagText(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(p.tagPrefix(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(), &.{ 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 { - p.exitHeader(); - if (p.panes[p.active]) |pane| pane.focus = .body; - p.header_focus = true; - p.header_column = column; - const t = p.focusedHeader() orelse return p.exitHeader(); - 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(); - } - /// Cycle only the body modes supported by this pane. pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file == null and !pane.isTerminal()) return; const previous = pane.body.mode; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); switch (previous) { @@ -5068,7 +4796,7 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (!pane.isTerminal()) return; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); if (pane.body.mode == .tty) { @@ -5089,7 +4817,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; - const header = p.focusedHeader(); + const header = tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { p.leader_on = false; p.ctrl_w_pending = false; @@ -5136,7 +4864,7 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - p.exitHeader(); + tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; @@ -5150,11 +4878,11 @@ pub const Pardes = struct { } for (config.jump_keys) |jk| { if (!hit(key, &.{jk.chord})) continue; - p.exitHeader(); + tagline.exitHeader(p); return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { - p.exitHeader(); + tagline.exitHeader(p); const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = layout.splitColumn(p, p.active, p.active, false); @@ -5170,7 +4898,7 @@ pub const Pardes = struct { // Enter or the execute key commits the draft and the keyboard // goes back to the body; Esc drops it. if (hit(key, config.look_key) or hit(key, config.exec_key)) { - if (p.commitTagName(pane)) pane.focus = .body; + if (tagline.commitTagName(p, pane)) pane.focus = .body; return; } if (hit(key, config.escape)) { @@ -5209,7 +4937,7 @@ pub const Pardes = struct { // text as it is shown: a tag's prefix is part of what a word is. const arena = p.scratch.allocator(); const own = arena.dupe(u8, edit.flatSurface(p, t) catch return) catch return; - const shown = if (t.what == .tag) p.tagText(arena, pane) catch return else own; + const shown = if (t.what == .tag) tagline.tagText(p, arena, pane) catch return else own; const range = t.primaryRange(own, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); @@ -5221,7 +4949,7 @@ pub const Pardes = struct { }; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. - if (header != null) p.exitHeader() else pane.focus = .body; + if (header != null) tagline.exitHeader(p) else pane.focus = .body; if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); return; } @@ -5615,90 +5343,6 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- - 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 p.restoreTailAt(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 p.restoreTailAt(pane, src.tag, legacy, .terminal); - } - const current = p.tagPrefix(pane) catch return; - if (std.mem.startsWith(u8, src.tag, current)) - return p.restoreTailAt(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 p.restoreTailAt(pane, src.tag, legacy, .generic); - if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| - p.restoreTailAt(pane, src.tag, legacy, .generic); - } - - fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { - const pfx = p.tagPrefix(pane) catch return; - const class = tailClass(pane); - p.restoreTailAt(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; - } - - 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; - } - /// Close a pane; its rows go to `to`, or where layout.absorbVWeight /// sends them when that is null. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { @@ -5854,7 +5498,7 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. pub fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { - p.exitHeader(); + tagline.exitHeader(p); } p.reapPanes(); layout.compute(p); @@ -5886,7 +5530,7 @@ pub const Pardes = struct { const r = p.rects[id]; // The tag takes a row per line, leaving the body one row if it // had any; a folded pane shows its first line only. - const lines = std.mem.count(u8, curTail(pane), "\n") + 1; + const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; const cols = @max(1, r.w -| config.GUTTER); @@ -5976,7 +5620,7 @@ pub const Pardes = struct { return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y; } - fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { + pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } @@ -6107,110 +5751,8 @@ pub const Pardes = struct { } } - // global tagbar: full width, top rows — unless a native shell has - // taken its commands over, in which case the rows are not ours to draw - p.revealHeader(); - const global = p.topbar(); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; - while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - const column_rows = p.columnBarHeight(); - if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; - const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; - const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - // A grip is muted until it is grabbed; then it lights, as a pane's - // box does for the active pane, so the drag shows what it holds. - const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; - const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; - const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; - const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const tag = p.columnTag(c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); - } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - }; - if (p.focusedHeader()) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row); - const span = headerSelection(t, text, row) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - if (t.cur_row >= 0 and t.cur_row < rows) { - const bar = modal.lineSlice(text, @intCast(t.cur_row)); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; - } - } - - try p.renderTagLayers(arena); + try body_layer.paintHeaders(p); + try body_layer.renderTagLayers(p, arena); switch (p.drag) { .border_v => |d| { @@ -6450,204 +5992,6 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } - fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; - const tag = try p.tagText(arena, pane); - const rows = pane.tag_rows; - // As wide as the widest line it shows, and never narrower than - // its viewport. - var widest: usize = 0; - var split = std.mem.splitScalar(u8, tag, '\n'); - for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); - const pitch = @as(usize, columns) + TAG_TEXT_INSET; - const storage = try arena.alloc(Cell, pitch * rows); - @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; - std.mem.swap(Surface, &p.surface, &temporary); - p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { - std.mem.swap(Surface, &p.surface, &temporary); - return err; - }; - std.mem.swap(Surface, &p.surface, &temporary); - // A layer to a line: the first in the pane's own slot, the rest - // in the slots kept for the lines of taller tags. - for (0..rows) |line| { - const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; - } - } - // With the workspace tag row handed to native chrome there is no - // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), line), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // GUI's "skip the cells a tag layer covers" leaves no stub behind. - // A prompt is one of them too: left on the grid, a pixel shell drew - // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; - const first = p.bodyTop(pane, r); - var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const text = Messages.noticeText(p, pane, i, &leader_buf); - if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; - // Right aligned inside the chip, a blank cell short of its - // edge: the same one the grid pass leaves for a prompt caret. - // Wider than the chip, the text loses its head, as on the grid. - const room = p.tagCapacity(chip) -| 1; - const shown = panes.File.displayWidth(text); - var kept = text; - if (shown > room) { - var start = panes.File.rawAtDisplay(text, shown - room); - if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); - kept = text[start..]; - } - const pad = room -| panes.File.displayWidth(kept); - const line = try arena.alloc(u8, pad + kept.len); - @memset(line[0..pad], ' '); - @memcpy(line[pad..], kept); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - const page = p.theme().bg orelse chrome.tag_bg; - const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), - .w = chip, - .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); - p.surface.tag_layers[layer_index].slide = motion.slide; - p.surface.tag_layers[layer_index].fade = fade; - // The caret rides the band, where the prompt's glyphs are, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - const dropped = shown - panes.File.displayWidth(kept); - if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - for (0..p.columnBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); - } - }; - } - - /// 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 }; - } - - /// One row of a header or a notice band as a tag layer: `text` is line - /// `line` of what it shows. - fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { - _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; - return; - } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - layer.kind = kind; - layer.id = id; - layer.line = line; - layer.serial = if (kind == .column) p.col_serial[id] else 0; - layer.viewport = viewport; - layer.cols = columns; - layer.bg = bg; - layer.cursor = null; - layer.slide = 0; - layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; - // A notice band carries a PANE id, not a column index, so it must not - // answer to the column being edited -- that would scroll and select - // its text with the column tag's offsets and give it a second caret. - const editing: ?*Text = if (p.header_focus and switch (kind) { - .workspace => p.header_column == null, - .column => p.header_column == id, - .pane, .notice => false, - }) p.focusedHeader() else null; - const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (headerSelection(t, try edit.flatSurface(p, t), line)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } - } - fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); @@ -6687,7 +6031,7 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); - try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. @@ -6714,127 +6058,6 @@ pub const Pardes = struct { try body_layer.renderBody(p, arena, pane, r, id, active, true); } - fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { - const s = &p.surface; - const th = p.theme(); - const chrome = p.chromeTheme(); - const tx = r.x + @min(TAG_TEXT_INSET, r.w); - const tw = r.w -| TAG_TEXT_INSET; - const tag_y = p.tagTop(pane, r); - const body_y = p.bodyTop(pane, r); - const rows = pane.tag_rows; - const page_rgb = th.bg orelse th.tag_bg; - // Focus is visible along the tag without changing its live text grid. - const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; - // Padding belongs to the same font row but keeps its default ink: a - // role must not manufacture a foreground color for blank cells. - s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); - s.fill(tx, tag_y, tw, rows, .{ - .bg = .{ .rgb = tag_bg }, - .font_role = .tagline, - }); - const whole = try p.tagText(arena, pane); - const tail = curTail(pane); - const prefix_len = whole.len - tail.len; - // A row per line: the first carries the prefix, the rest are the - // tag's own text alone. - var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); - var split = std.mem.splitScalar(u8, whole, '\n'); - for (lines[0..rows]) |*line| line.* = split.next() orelse ""; - // The keyboard's caret, as a row and a byte offset in that row's - // line: in the tag's own text when the tag has the keyboard, in the - // path while a name is drafted. - const Caret = struct { row: usize, at: usize }; - const caret: ?Caret = if (!active) - null - else if (pane.prompt == .name) - .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } - else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: { - const row: usize = @intCast(@max(0, pane.tag.cur_row)); - const lead: usize = if (row == 0) prefix_len else 0; - break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; - } else null; - if (caret) |at| { - const line = lines[at.row]; - const col = panes.File.rawDisplayCol(line, at.at); - var scroll: usize = pane.tag_scroll; - if (col < scroll) scroll = col; - if (col >= scroll + capacity) scroll = col - capacity + 1; - var raw = panes.File.rawAtDisplay(line, scroll); - scroll = panes.File.rawDisplayCol(line, raw); - if (col >= scroll + capacity) { - raw = modal.nextGrapheme(line, raw); - scroll = panes.File.rawDisplayCol(line, raw); - } - pane.tag_scroll = @intCast(scroll); - } else if (pane.focus != .tag) pane.tag_scroll = 0; - for (lines[0..rows], 0..) |line, row| { - const start = panes.File.rawAtDisplay(line, pane.tag_scroll); - _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); - } - const tag = lines[0]; - if (tagTintRange(pane, tag, prefix_len)) |name| { - const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); - const hi = @min(name.hi, tag.len); - if (lo < hi) { - var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; - const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; - while (col < end and col < tw) : (col += 1) - s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; - } - } - if (p.look_hover_preview) |preview| { - if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { - const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); - }; - } - // The tag's own selections, while it has the keyboard, a line at a - // time. A range of one grapheme is the block cursor, not a selection. - if (pane.focus == .tag) { - var ranges: [Text.max_selections]modal.Selection = undefined; - const got = pane.tag.ranges(tail, 0, &ranges); - for (ranges[0..got.n]) |range| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(tail, lo) >= hi) continue; - const first = modal.positionAt(tail, lo); - const last = modal.positionAt(tail, hi); - var row = first.row; - while (row <= last.row and row < rows) : (row += 1) { - const line = lines[row]; - const lead: usize = if (row == 0) prefix_len else 0; - const from = lead + if (row == first.row) first.col else 0; - const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); - var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(line, to); - const y = tag_y + @as(u16, @intCast(row)); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - } - // the caret: bar while typing, block in normal mode (same rule as a body) - if (caret) |at| { - const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; - const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; - } - - for (pane.sel, 0..) |selection, button| { - if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); - } - } - pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; if (s.nimages == s.images.len) { @@ -6954,7 +6177,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); pane.focus = .body; diff --git a/src/tag_layer.zig b/src/tag_layer.zig index ac7c4ac2..7cfe5bbf 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -78,3 +78,28 @@ test "tag text capacity uses its own pitch and excludes the physical grip" { try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); } + +/// Whether two pointer hits name the same cell of the same tag line. +pub fn sameCell(a: ?Hit, b: ?Hit) bool { + const first = a orelse return b == null; + const second = b orelse return false; + return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col; +} + +/// The tag column a host's pointer is at. Clamped, a point off the tag is +/// brought back onto line `line` of it, for a drag that leaves it. +pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, clamp: bool, line: u16) ?u16 { + var point = supplied orelse return null; + if (point.kind != kind or point.id != id) return null; + if (clamp) for (p.surface.tagLayers()) |*layer| { + if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue; + const bw: f32 = @floatFromInt(point.metrics.body_w); + const bh: f32 = @floatFromInt(point.metrics.body_h); + const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; + point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + break; + }; + return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; +} 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 }; +} diff --git a/test/column_drag.zig b/test/column_drag.zig index f128aa61..feea1bf7 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -37,7 +37,7 @@ fn expectOrder(p: *Pardes, before: []const Snapshot, order: []const usize) !void try std.testing.expectEqual(saved.serial, p.col_serial[column]); try std.testing.expectEqual(saved.count, p.col_n[column]); try std.testing.expectEqualSlices(usize, saved.panes[0..saved.count], p.col_panes[column][0..p.col_n[column]]); - try std.testing.expectEqualStrings(saved.tag, p.columnTag(column)); + try std.testing.expectEqualStrings(saved.tag, pardes.tagline.columnTag(p, column)); } } @@ -67,7 +67,7 @@ test "column grip drag moves only its interval and keeps tag caret with the colu _ = try p.render(frame.allocator()); var before: [4]Snapshot = undefined; for (&before, 0..) |*saved, column| { - saved.* = .{ .weight = p.col_weight[column], .serial = p.col_serial[column], .count = p.col_n[column], .tag = p.columnTag(column) }; + saved.* = .{ .weight = p.col_weight[column], .serial = p.col_serial[column], .count = p.col_n[column], .tag = pardes.tagline.columnTag(p, column) }; @memcpy(saved.panes[0..saved.count], p.col_panes[column][0..saved.count]); } const layer = try tagLayer(p, 1); @@ -75,8 +75,8 @@ test "column grip drag moves only its interval and keeps tag caret with the colu p.update(.{ .mouse = headerMouse(layer, 10, .press) }); p.update(.{ .mouse = headerMouse(layer, 10, .release) }); try std.testing.expectEqual(@as(?usize, 1), p.header_column); - const caret = p.focusedHeader().?.cur_col; - const anchor = p.focusedHeader().?.vsel; + const caret = pardes.tagline.focusedHeader(p).?.cur_col; + const anchor = pardes.tagline.focusedHeader(p).?.vsel; const scroll = p.header_scroll; const target = p.col_x[3] + p.col_w[3] - 2; const last_rect = p.rects[p.col_panes[3][0]]; @@ -93,14 +93,14 @@ test "column grip drag moves only its interval and keeps tag caret with the colu try expectOrder(p, &before, &.{ 0, 2, 3, 1 }); try std.testing.expectEqual(active, p.active); try std.testing.expectEqual(@as(?usize, 3), p.header_column); - try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col); - try std.testing.expectEqual(anchor, p.focusedHeader().?.vsel); + try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); + try std.testing.expectEqual(anchor, pardes.tagline.focusedHeader(p).?.vsel); try std.testing.expectEqual(scroll, p.header_scroll); // The old tag envelope must not edit the new occupant of its old index, // including the interval before hosts request another rendered frame. p.update(.{ .mouse = stale }); try std.testing.expectEqual(@as(?usize, 3), p.header_column); - try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col); + try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); _ = try p.render(frame.allocator()); try std.testing.expectEqual(before[1].serial, (try tagLayer(p, 3)).serial); try std.testing.expectEqual(p.col_w[3] - pardes.TAG_TEXT_INSET, (try tagLayer(p, 3)).viewport.w); @@ -110,7 +110,7 @@ test "column grip drag moves only its interval and keeps tag caret with the colu mouse(p, .release, back); try expectOrder(p, &before, &.{ 0, 1, 2, 3 }); try std.testing.expectEqual(@as(?usize, 1), p.header_column); - try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col); + try std.testing.expectEqual(caret, pardes.tagline.focusedHeader(p).?.cur_col); try std.testing.expectEqual(active, p.active); // A structural change between press and release must not retarget the // in-flight drag to whichever column now occupies the original index. @@ -165,7 +165,7 @@ test "column grip is separate from compact text and the resize seam" { try std.testing.expect(p.drag != .column_move); try std.testing.expect(p.header_drag); p.update(.{ .mouse = headerMouse(layer, 0, .release) }); - try std.testing.expectEqual(@as(i32, 0), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 0), pardes.tagline.focusedHeader(p).?.cur_col); mouse(p, .press, p.col_x[1] + config.GUTTER - 1); try std.testing.expect(p.drag == .column_move); mouse(p, .release, p.col_x[1] + config.GUTTER - 1); diff --git a/test/fs_bench.zig b/test/fs_bench.zig index a816ebbd..da9da47c 100644 --- a/test/fs_bench.zig +++ b/test/fs_bench.zig @@ -65,7 +65,7 @@ const Session = struct { .file_id = id, .file_serial = pane.serial, .body_bytes = pane.file.?.content.len, - .tag = try core.tagText(backing, pane), + .tag = try pardes.tagline.tagText(core, backing, pane), }; } diff --git a/test/mode.zig b/test/mode.zig index c429ea72..c7d7d548 100644 --- a/test/mode.zig +++ b/test/mode.zig @@ -11,7 +11,7 @@ fn mode(p: *Pardes, id: usize, expected: panes.Text.Mode) !void { fn clickModeTag(p: *Pardes) !void { const pane = p.panes[0].?; - const text = try p.tagText(p.scratch.allocator(), pane); + const text = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); const col = std.mem.indexOf(u8, text, "Mode") orelse return error.MissingModeTag; const rect = p.rects[0]; const row = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; @@ -33,7 +33,7 @@ test "Mode cycles terminal modes and keeps legacy toggle and custom tags" { while (p.nextEffect()) |_| {} const pane = p.panes[0].?; try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); - try std.testing.expect(std.mem.indexOf(u8, try p.tagText(p.scratch.allocator(), pane), " Mode") != null); + try std.testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagText(p, p.scratch.allocator(), pane), " Mode") != null); try mode(p, 0, .normal); try mode(p, 0, .insert); // A clicked tag is typed into; its execute key is a normal-mode key, as @@ -67,7 +67,7 @@ test "Mode from actual tag Exec preserves parked file and output insert transact p.presentation.enabled = false; const pane = try p.setTestFile("ab\n"); if (output) pane.file.?.output = .{ .from = .search }; - try std.testing.expect(std.mem.indexOf(u8, try p.tagText(p.scratch.allocator(), pane), " Mode") == null); + try std.testing.expect(std.mem.indexOf(u8, try pardes.tagline.tagText(p, p.scratch.allocator(), pane), " Mode") == null); try customTag(pane, " Mode"); try mode(p, 0, .insert); p.update(.{ .key = .{ .cp = '界', .text = "界" } }); diff --git a/test/panes.zig b/test/panes.zig index 4804c497..954e431b 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -15,7 +15,7 @@ const filesystem = pardes.filesystem; /// The tag's caret as a byte offset into the tag as it is shown, prefix /// included. fn tagCaret(p: *Pardes, pane: *Pane) !usize { - return (try p.tagPrefix(pane)).len + @as(usize, @intCast(pane.tag.cur_col)); + return (try pardes.tagline.tagPrefix(p, pane)).len + @as(usize, @intCast(pane.tag.cur_col)); } fn setTag(pane: *Pane, text: []const u8) !void { @@ -52,7 +52,7 @@ const TagLayerTests = struct { defer frame.deinit(); _ = try p.render(frame.allocator()); const layer = try findLayer(p, .pane, 0); - const text = try p.tagText(frame.allocator(), pane); + const text = try pardes.tagline.tagText(p, frame.allocator(), pane); const at = std.mem.indexOf(u8, text, "END").?; const col = panes.File.displayWidth(text[0..at]); try std.testing.expect(col > p.rects[0].w); @@ -70,7 +70,7 @@ const TagLayerTests = struct { try std.testing.expectEqual(at, try tagCaret(p, pane)); p.update(.{ .key = .{ .cp = 'X', .text = "X" } }); _ = try p.render(frame.allocator()); - const edited = try p.tagText(frame.allocator(), pane); + const edited = try pardes.tagline.tagText(p, frame.allocator(), pane); try std.testing.expect(std.mem.endsWith(u8, edited, "界 XEND")); try std.testing.expectEqual(@as(u16, 0), pane.tag_scroll); try std.testing.expectEqualStrings("body\n", pane.file.?.content); @@ -140,7 +140,7 @@ const TagLayerTests = struct { try setTag(left, " " ++ "a" ** 110); _ = try p.render(frame.allocator()); const left_layer = try findLayer(p, .pane, 0); - const left_text = try p.tagText(frame.allocator(), left); + const left_text = try pardes.tagline.tagText(p, frame.allocator(), left); const start_x = @as(f32, @floatFromInt(left_layer.viewport.x)) * 10 + 12.5; const beyond_x = edge + 12.5; p.update(.{ .mouse = mouse(left_layer, start_x, .press) }); @@ -161,7 +161,7 @@ const TagLayerTests = struct { const x = @as(f32, @floatFromInt(header.viewport.x)) * 10 + 62.5 * 5; p.update(.{ .mouse = mouse(header, x, .press) }); p.update(.{ .mouse = mouse(header, x, .release) }); - try std.testing.expectEqual(@as(i32, 63), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 63), pardes.tagline.focusedHeader(p).?.cur_col); if (kind == .column) try std.testing.expectEqual(@as(?usize, 1), p.header_column) else try std.testing.expect(p.header_column == null); p.update(.{ .key = .{ .cp = Key.escape } }); } @@ -184,7 +184,7 @@ test "a tag with a newline in it is as tall as its lines, and each line is typed p.update(.{ .key = .{ .cp = 'A' } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.update(.{ .key = .{ .cp = 'S', .text = "Second" } }); - try std.testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), "\nSecond")); + try std.testing.expect(std.mem.endsWith(u8, pardes.tagline.curTail(pane), "\nSecond")); try std.testing.expectEqual(@as(u16, 2), pane.tag_rows); // The pane keeps its height; the body gives the tag its row. try std.testing.expectEqual(height, p.rects[0].h); @@ -252,7 +252,7 @@ const TagNavigationTests = struct { // Nothing to remember yet: `:` lands on Save, in normal mode. key(p, ':'); try std.testing.expect(first.focus == .tag and first.tag.mode == .normal); - const tail = Pardes.curTail(first); + const tail = pardes.tagline.curTail(first); try std.testing.expectEqualStrings("Save", tail[@intCast(first.tag.cur_col)..][0..4]); key(p, Key.home); for (0..3) |_| key(p, Key.right); @@ -278,7 +278,7 @@ const TagNavigationTests = struct { _ = try p.render(frame.allocator()); const rect = p.rects[0]; const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; - const prefix: u16 = @intCast(panes.File.displayWidth(try p.tagPrefix(first))); + const prefix: u16 = @intCast(panes.File.displayWidth(try pardes.tagline.tagPrefix(p, first))); ClickCursorTests.click(p, .left, rect.x + pardes.TAG_TEXT_INSET + prefix + 6, y); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expect(first.focus == .tag and first.tag.mode == .insert); @@ -316,7 +316,7 @@ const TagNavigationTests = struct { try std.testing.expectEqual(chord == Key.tab, saved); // ...and the tag still knows where it was. key(p, ':'); - try std.testing.expectEqualStrings("Save", Pardes.curTail(pane)[@intCast(pane.tag.cur_col)..][0..4]); + try std.testing.expectEqualStrings("Save", pardes.tagline.curTail(pane)[@intCast(pane.tag.cur_col)..][0..4]); } } @@ -403,14 +403,14 @@ const TagNavigationTests = struct { key(p, ':'); key(p, 'i'); key(p, Key.end); - const before = Pardes.curTail(pane).len; + const before = pardes.tagline.curTail(pane).len; try std.testing.expect(pane.focus == .tag and pane.tag.mode == .insert and pane.body.mode == .normal); for ("hjklHJKL:") |letter| { const text = [_]u8{letter}; p.update(.{ .key = .{ .cp = letter, .text = &text } }); try std.testing.expect(pane.focus == .tag and pane.tag.mode == .insert); } - try std.testing.expectEqualStrings("hjklHJKL:", Pardes.curTail(pane)[before..]); + try std.testing.expectEqualStrings("hjklHJKL:", pardes.tagline.curTail(pane)[before..]); try std.testing.expectEqualStrings("body\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), p.active); } @@ -1187,7 +1187,7 @@ const TagNameTintTests = struct { var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); - const text = try p.tagText(arena.allocator(), pane); + const text = try pardes.tagline.tagText(p, arena.allocator(), pane); const base = std.mem.lastIndexOfScalar(u8, name, '/') orelse return error.MissingDirectory; const rect = p.rects[id]; const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; @@ -1235,7 +1235,7 @@ const TagNameTintTests = struct { configure(p); defer p.custom_theme = null; const name = "/a/very/long/directory/that/extends/past/the/tag/界e\u{301}name-with-a-long-tail.zig"; - p.draftName(pane, 0); + pardes.tagline.draftName(p, pane, 0); redraft(pane, name); pane.input.cur_col = @intCast(std.mem.indexOf(u8, name, "界").?); try std.testing.expect((try check(p, 0, name)) > 0); @@ -1304,7 +1304,7 @@ test "pane filename drafts commit and cancel without changing body or command ta const original = try p.gpa.dupe(u8, pane.file.?.path); defer p.gpa.free(original); try pane.appendTag(" SyntaxBold"); - const tail = try p.gpa.dupe(u8, Pardes.curTail(pane)); + const tail = try p.gpa.dupe(u8, pardes.tagline.curTail(pane)); defer p.gpa.free(tail); for ([_]bool{ false, true }) |commit| { clickPathEnd(p, pane); @@ -1312,13 +1312,13 @@ test "pane filename drafts commit and cancel without changing body or command ta p.update(.{ .paste = "/new name.zig" }); try std.testing.expectEqualStrings(original, pane.file.?.path); try std.testing.expectEqualStrings("/new name.zig", pane.input.own.?); - try std.testing.expectEqualStrings(tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tail, pardes.tagline.curTail(pane)); try std.testing.expectEqualStrings("original body\n", pane.file.?.content); p.update(.{ .key = .{ .cp = if (commit) Key.enter else Key.escape } }); try std.testing.expect(pane.focus == .body); try std.testing.expect(pane.prompt == .none); try std.testing.expectEqualStrings(if (commit) "/new name.zig" else original, pane.file.?.path); - try std.testing.expectEqualStrings(tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tail, pardes.tagline.curTail(pane)); while (p.nextEffect()) |effect| try std.testing.expect(std.meta.activeTag(effect) != .save_file); } try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); @@ -1351,13 +1351,13 @@ test "leaving a pane or entering a header cancels an uncommitted filename" { const pane = try p.setTestFile("untouched\n"); for ([_]bool{ false, true }) |header| { p.active = 0; - p.exitHeader(); + pardes.tagline.exitHeader(p); p.update(.tick); clickPathEnd(p, pane); p.update(.{ .paste = "/draft" }); try std.testing.expect(pane.prompt == .name); if (header) { - p.enterHeader(null); + pardes.tagline.enterHeader(p, null); p.update(.tick); } else { try std.testing.expect(p.executeBuiltinLine(0, "New")); @@ -1379,7 +1379,7 @@ test "middle clicking Save commits the drafted filename before issuing a save" { var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); _ = try p.render(arena.allocator()); - const text = try p.tagText(arena.allocator(), pane); + const text = try pardes.tagline.tagText(p, arena.allocator(), pane); const save_at = std.mem.indexOf(u8, text, "Save").?; const x: u16 = @intCast(p.rects[0].x + pardes.TAG_TEXT_INSET + save_at - pane.tag_scroll); const y = p.rects[0].y; @@ -1421,15 +1421,15 @@ test "paste into pane commands and prompts never inserts into the body" { p.update(.{ .key = .{ .cp = 'A' } }); // A tag takes a paste as a body does, newline and all. p.update(.{ .paste = " Custom\nCommand" }); - try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); p.update(.{ .key = .{ .cp = Key.escape } }); p.startPrompt(pane, .{ .search = config.search_marker }); p.update(.{ .paste = "t\r\nwo" }); try std.testing.expectEqualStrings(config.search_marker ++ "two", pane.promptText().?); - try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); try std.testing.expectEqualStrings("one two one\n", pane.file.?.content); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); } test "long pane tags reveal commands and map visible clicks to whole tag addresses" { @@ -1446,7 +1446,7 @@ test "long pane tags reveal commands and map visible clicks to whole tag address const surface = try p.render(arena.allocator()); try std.testing.expect(pane.tag_scroll > 0); try std.testing.expect(surface.cursor.?.x < p.rects[0].x + p.rects[0].w); - const text = try p.tagText(p.gpa, pane); + const text = try pardes.tagline.tagText(p, p.gpa, pane); defer p.gpa.free(text); const offset = std.mem.indexOf(u8, text, "SyntaxBold").?; const x: u16 = @intCast(p.rects[0].x + pardes.TAG_TEXT_INSET + panes.File.rawDisplayCol(text, offset) - pane.tag_scroll); @@ -1455,7 +1455,7 @@ test "long pane tags reveal commands and map visible clicks to whole tag address p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } }); p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } }); try std.testing.expectEqual(!before, p.settings.syntax_bold); - try std.testing.expectEqualStrings(text, try p.tagText(p.scratch.allocator(), pane)); + try std.testing.expectEqualStrings(text, try pardes.tagline.tagText(p, p.scratch.allocator(), pane)); } test "terminal overlay recoloring never materializes unrelated history" { @@ -4155,7 +4155,7 @@ test "cancelled normal gestures leave no count or subprefix after mouse and focu const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; const body_y = p.bodyTop(pane, rect); const x = if (gesture == .tag) - rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(try p.tagPrefix(pane)))) + 3 + rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(try pardes.tagline.tagPrefix(p, pane)))) + 3 else rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3; const y = if (gesture == .tag) tag_y else body_y; diff --git a/test/pdf.zig b/test/pdf.zig index 745ecb1a..3a592451 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -612,7 +612,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); - const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + const initial_tag = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); const expected_tag = try std.fmt.allocPrint(p.scratch.allocator(), "{s} [1/{d}]{s}", .{ pane.pdf.?.path, pane.pdf.?.page_count, pane_tail }); try std.testing.expectEqualStrings(expected_tag, initial_tag); try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); @@ -833,7 +833,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { revision_before_toggle, panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, ); - const height_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + const height_tag = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); const expected_height_tag = try std.fmt.allocPrint(p.scratch.allocator(), "{s} [{d}/{d}]{s}", .{ pane.pdf.?.path, pane.pdf.?.page + 1, pane.pdf.?.page_count, pane_tail }); try std.testing.expectEqualStrings(expected_height_tag, height_tag); for (p.panes) |slot| { @@ -1250,7 +1250,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { return error.MissingPdfTintBuiltin; try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + const initial_tag = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.startsWith(u8, initial_tag, pv.path)); try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse Del")); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "filtered") == null); @@ -1263,7 +1263,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { null, )); try std.testing.expectEqual(PdfTintMode.full, pv.tint); - const full_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + const full_tag = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expectEqualStrings(initial_tag, full_tag); try std.testing.expect(std.mem.indexOf(u8, full_tag, "full") == null); @@ -1291,7 +1291,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { null, )); try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - const disabled_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + const disabled_tag = try pardes.tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expectEqualStrings(initial_tag, disabled_tag); try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled") == null); try std.testing.expect(pardes.test_api.runBuiltin( |
