From 35771c9c45e000d2d8408d3db9dd7eb224ab8f2d Mon Sep 17 00:00:00 2001 From: Fernando Dobladez Date: Thu, 15 Oct 2026 22:48:03 -0300 Subject: [PATCH 1/4] CLI: view draws PDF pages in the terminal through the kitty graphics protocol `pdfcraft-cli view [--pages 1,3-5] [--fit page|width] [--pager|--no-pager] [--compression auto|fast|small] [--protocol auto|kitty] [--password PW]` draws pages in kitty, Ghostty, WezTerm and other terminals that speak the kitty graphics protocol: directly, over SSH, and inside tmux 3.3 or later. Unix only (macOS, Linux, FreeBSD); `render` without --out points to it. - Paging: more than one page on a terminal opens a pager on the alternate screen, like less: one page, or two side by side when the window is wide enough, turning like a book; less-like keys, fit page or width, a status line. A single page, --no-pager or output that isn't a terminal prints the pages at the cursor instead, like cat. - Turning a page is instant: two render threads work ahead (the next two screens and the one before), each page goes to the terminal as soon as it is ready, and turning to it only places it. The terminal keeps up to 12 pages and 192 MiB of them: past kitty's 320 MB quota it drops nearly every image without a placement at once. A page it drops anyway is answered (ENOENT) when placed, and sent again. - Transport: on this machine, pages go through temporary files of raw pixels (t=t, f=32) once a one-pixel probe shows the terminal takes them: kitty takes a page that way in about 10 ms, against 50-65 ms as a PNG in the stream. Otherwise PNGs, compressed fast on this machine and small over SSH (pdfcraft-engine's encode_png_with takes a PngEffort; Fast is 5-7 times faster for about twice the bytes). The files live in a 0700 directory of the user's own inside the temporary directory, are created new with mode 0600, and go once read, when stale, at exit, or by a later run once their process is gone. They are worked on by a thread of their own: a filesystem that stalls never stalls the keys. - Cell size: docker exec and kubectl exec pass rows and columns but no pixel size. view then asks the terminal once (CSI 16 t, with DA1 behind it so a terminal that doesn't know the query ends the wait at once), and assumes 10 x 20 px cells if it doesn't say; inside tmux, tmux says. - tmux: escape codes through passthrough (allow-passthrough turned on for view's own pane when off), pages as Unicode placeholders that tmux keeps as text. The pager asks tmux after writing and every second, and sends again what tmux threw away and what a hidden pane or a newly attached terminal missed. With more than one terminal showing the pane, no files. - Hostile input: nothing a document, the terminal or tmux sends can panic or hang it. Terminal answers are never read as keys; control characters are replaced in file names, messages and warnings; pages are capped at 8192 px a side and 16 MP; the terminal is restored on every exit path, panics included. Page lists (shared with edit, extract and split) are capped at 1,000,000 pages in all. Documented in docs/cli-view.md (usage) and apps/pdfcraft-cli/src/view/README.md (how it works, with measurements). Tests: unit and property tests per module; end to end on script(1) pseudo-terminals (paging, two-up, temporary files, terminal answers) and in private tmux servers (flips, a hidden pane, a second client), skipped with a warning where those can't run. Every fix from two security reviews was checked to fail its test with the fix reverted. Also adds my entry to contributors/people.toml. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 3 +- ROADMAP.md | 1 + apps/pdfcraft-cli/Cargo.toml | 10 +- apps/pdfcraft-cli/src/main.rs | 39 +- apps/pdfcraft-cli/src/view/README.md | 211 +++++ apps/pdfcraft-cli/src/view/detect.rs | 221 +++++ apps/pdfcraft-cli/src/view/files.rs | 766 +++++++++++++++ apps/pdfcraft-cli/src/view/fit.rs | 244 +++++ apps/pdfcraft-cli/src/view/keys.rs | 216 +++++ apps/pdfcraft-cli/src/view/kitty.rs | 477 ++++++++++ apps/pdfcraft-cli/src/view/mod.rs | 614 ++++++++++++ apps/pdfcraft-cli/src/view/pager.rs | 1241 +++++++++++++++++++++++++ apps/pdfcraft-cli/src/view/tmux.rs | 361 +++++++ apps/pdfcraft-cli/src/view/tty.rs | 128 +++ apps/pdfcraft-cli/tests/view.rs | 309 ++++++ apps/pdfcraft-cli/tests/view_pager.rs | 501 ++++++++++ apps/pdfcraft-cli/tests/view_tmux.rs | 266 ++++++ contributors/people.toml | 4 + crates/engine/src/export.rs | 69 +- docs/architecture.md | 2 +- docs/cli-view.md | 172 ++++ 22 files changed, 5847 insertions(+), 9 deletions(-) create mode 100644 apps/pdfcraft-cli/src/view/README.md create mode 100644 apps/pdfcraft-cli/src/view/detect.rs create mode 100644 apps/pdfcraft-cli/src/view/files.rs create mode 100644 apps/pdfcraft-cli/src/view/fit.rs create mode 100644 apps/pdfcraft-cli/src/view/keys.rs create mode 100644 apps/pdfcraft-cli/src/view/kitty.rs create mode 100644 apps/pdfcraft-cli/src/view/mod.rs create mode 100644 apps/pdfcraft-cli/src/view/pager.rs create mode 100644 apps/pdfcraft-cli/src/view/tmux.rs create mode 100644 apps/pdfcraft-cli/src/view/tty.rs create mode 100644 apps/pdfcraft-cli/tests/view.rs create mode 100644 apps/pdfcraft-cli/tests/view_pager.rs create mode 100644 apps/pdfcraft-cli/tests/view_tmux.rs create mode 100644 docs/cli-view.md diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..881697c62a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4022,6 +4022,7 @@ dependencies = [ "pdfcraft-engine", "pdfcraft-platform", "pdfcraft-render", + "proptest", "rustix 1.1.5", "serde_json", "winresource", diff --git a/README.md b/README.md index bd33ad23be..bb9bfecd90 100644 --- a/README.md +++ b/README.md @@ -264,11 +264,12 @@ Press ⌘K to search every tool and command, or browse the **All tool - **Native on macOS, Windows, Linux and FreeBSD**, and **in the browser** through WebAssembly, from the same Rust codebase. Windows builds come for x64, x86 and ARM64 (Windows on ARM, no emulation); every ARM64 change is tested on ARM64 hardware in CI. - **Private by design.** Documents never leave your machine. There's no account, no telemetry and no cloud processing. - **Engine first.** Parsing, rendering and editing live in reusable library crates. The interface is one swappable layer on top. -- **Scriptable.** The `pdfcraft-cli` tool (see [Built for agents, too](#built-for-agents-too)) covers inspecting, rendering, extracting text, editing, combining, extracting pages and splitting. Robustness sweeps run on the same engine as the app. +- **Scriptable.** The `pdfcraft-cli` tool (see [Built for agents, too](#built-for-agents-too)) covers inspecting, rendering, extracting text, editing, combining, extracting pages and splitting. Robustness sweeps run on the same engine as the app. In kitty, Ghostty and WezTerm, `pdfcraft-cli view` shows pages right in the terminal ([guide](docs/cli-view.md)). ```sh pdfcraft-cli info form.pdf # structure as JSON pdfcraft-cli text paper.pdf --page 3 # reading-order text +pdfcraft-cli view paper.pdf # page through it in the terminal, like less pdfcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pdf ``` diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..867a706a46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-15 (CLI, `view`):** `pdfcraft-cli view` draws PDF pages in the terminal through the kitty graphics protocol (kitty, Ghostty, WezTerm), directly, over SSH and inside tmux. More than one page opens a pager like `less`, two pages side by side when the window is wide enough. Pages are rendered ahead, so a page turn only places the next page, and a terminal on this machine takes them through temporary files, which kitty reads about five times faster than PNGs in the stream. What kitty drops past its image quota, and what tmux throws away for a hidden pane or a terminal that attaches later, is sent again. Usage: [docs/cli-view.md](docs/cli-view.md); internals and measurements: `apps/pdfcraft-cli/src/view/README.md`. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index d7b52fbd66..490e2ffad5 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-cli" -description = "PdfCraft command-line tool: inspect, render and robustness-check PDFs" +description = "PdfCraft command-line tool: inspect, render, view and robustness-check PDFs" version.workspace = true edition.workspace = true license.workspace = true @@ -23,9 +23,13 @@ pdfcraft-platform = { workspace = true } base64 = "0.22" [target.'cfg(unix)'.dependencies] -# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here. +# `geteuid` for `ui`'s control-file owner check, and the terminal's size and raw mode for `view` +# (`termios`): safe, so no `unsafe` libc call here. # Already in Cargo.lock through the GUI crates. -rustix = { version = "1", features = ["process"] } +rustix = { version = "1", features = ["process", "termios"] } + +[dev-dependencies] +proptest = { workspace = true } [build-dependencies] # VERSIONINFO for pdfcraft-cli.exe; build.rs only uses it when targeting Windows. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d39..9f0ce610b8 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -20,6 +20,8 @@ #[cfg(feature = "mcp")] mod automation_args; mod batch; +#[cfg(unix)] +mod view; use std::io::Write; use std::path::{Path, PathBuf}; @@ -58,6 +60,12 @@ const USAGE: &str = concat!( "\ pdfcraft-cli info [--password PW] document summary as JSON pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) +pdfcraft-cli view [--pages 1,3-5] [--fit page|width] [--pager | --no-pager] + [--compression auto|fast|small] [--protocol auto|kitty] [--password PW] + show pages in this terminal: kitty, Ghostty, WezTerm + (kitty graphics protocol, tmux too; macOS, Linux and FreeBSD); + more than a page opens a pager, two pages side by side when + the window is wide enough pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] [--insert-blank 1] [--title T] [--author A] [--full] @@ -94,6 +102,10 @@ fn main() -> ExitCode { None => Err(format!("usage:\n{USAGE}").into()), Some("info") => info(&args[1..]), Some("render") => render(&args[1..]), + #[cfg(unix)] + Some("view") => view::run(&args[1..]), + #[cfg(not(unix))] + Some("view") => Err("view: needs a Unix terminal (macOS, Linux or FreeBSD); `render --out page.png` writes an image file".into()), Some("text") => text(&args[1..]), Some("edit") => edit(&args[1..]), Some("combine") => combine(&args[1..]), @@ -392,7 +404,8 @@ fn text(args: &[String]) -> Result<(), CliError> { } } -/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +/// The most pages one `A-B` range, or a whole list, may name: a bound on what a typed list can +/// allocate (repeating a range must not multiply it). const MAX_RANGE: usize = 1_000_000; /// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based @@ -410,8 +423,12 @@ fn page_list(s: &str) -> Result, String> { if to - from >= MAX_RANGE { return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); } + if out.len().saturating_add(to - from + 1) > MAX_RANGE { + return Err(format!("bad page list: more than {MAX_RANGE} pages in all")); + } out.extend(from - 1..to); } + None if out.len() >= MAX_RANGE => return Err(format!("bad page list: more than {MAX_RANGE} pages in all")), None => out.push(number(item, item)? - 1), } } @@ -570,13 +587,19 @@ fn split(args: &[String]) -> Result<(), CliError> { Ok(()) } +/// `render` always writes a file; where `view` exists, the error points to it for the terminal. +#[cfg(unix)] +const RENDER_MISSING_OUT: &str = "render: missing --out (.png, .jpg, .tif or .pam); to show the page in this terminal, use `pdfcraft-cli view`"; +#[cfg(not(unix))] +const RENDER_MISSING_OUT: &str = "render: missing --out (.png, .jpg, .tif or .pam)"; + fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; let path = *positional(args).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; - let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + let out = flag(args, "--out").ok_or(RENDER_MISSING_OUT)?; // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("png") => Some(ImageFormat::Png), @@ -1063,7 +1086,17 @@ fn current_uid() -> u32 { #[cfg(test)] mod tests { - use super::control_file_problem; + use super::{MAX_RANGE, control_file_problem, page_list}; + + #[test] + fn a_page_list_is_bounded_in_all_not_just_per_range() { + assert_eq!(page_list("1,3-5"), Ok(vec![0, 2, 3, 4])); + let full = format!("1-{MAX_RANGE}"); + assert_eq!(page_list(&full).map(|pages| pages.len()), Ok(MAX_RANGE)); + // Repeating a range that is allowed on its own would multiply what it allocates. + assert!(page_list(&format!("{full},{full}")).is_err_and(|e| e.contains("in all")), "two full ranges"); + assert!(page_list(&format!("{full},7")).is_err_and(|e| e.contains("in all")), "a full range and a page"); + } #[test] fn a_control_file_must_be_yours_and_private() { diff --git a/apps/pdfcraft-cli/src/view/README.md b/apps/pdfcraft-cli/src/view/README.md new file mode 100644 index 0000000000..cbac890640 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/README.md @@ -0,0 +1,211 @@ +# `pdfcraft-cli view`: how it works + +`view` draws PDF pages in the terminal through the +[kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), directly or from +inside tmux. Usage and behaviour are in [`docs/cli-view.md`](../../../../docs/cli-view.md); this +page is for working on the code: how pages get to the terminal, what is kept there, what can go +wrong on the way, and why each choice was made. The numbers were measured on the macOS laptop it +was developed on (kitty 0.45, tmux 3.5a), by black-box observation. The same probes, run again +against kitty 0.49.2, found the same behaviour: temporary files read and deleted, directly and +through tmux, the same answers, the 320 MB quota and the cell size. + +## Modules + +| File | What it does | +|---|---| +| `mod.rs` | Arguments, the checks before drawing, printing pages without the pager (`--no-pager`, a single page), choosing PNG compression and whether to try temporary files | +| `detect.rs` | Whether the terminal speaks the protocol, from the environment and its answer to a query, and the terminal's answer about its cell size. Pure | +| `tty.rs` | `/dev/tty`: size, raw mode, and reads with a timeout through a reader thread (on macOS, `poll(2)` on `/dev/tty` answers `POLLNVAL` at once) | +| `keys.rs` | Bytes from the terminal into keys, and the terminal's answers to graphics commands. Pure, total over any bytes | +| `fit.rs` | The render scale and the cells a page covers. Pure arithmetic | +| `kitty.rs` | The protocol's escape codes, and the Unicode placeholders used inside tmux. Pure | +| `tmux.rs` | Asking tmux: its client's terminal, `allow-passthrough`, SSH, and who shows the pane | +| `pager.rs` | The pager: navigation (`Nav`, no IO), rendering and sending ahead, what is kept in the terminal, redraws | +| `files.rs` | Temporary files for a terminal on this machine, on a thread of their own | + +`view` is Unix only (macOS, Linux, FreeBSD): it needs `/dev/tty`. Nothing in it is `unsafe`. + +## Getting pages to the terminal + +A rendered page is premultiplied RGBA. The protocol offers three ways to send it: + +| Way | Escape code | Used | +|---|---|---| +| PNG in the stream | `a=t,f=100,t=d`: base64 in 4096-byte chunks | Over SSH, when printing, and until the terminal takes a temporary file | +| Raw pixels in a temporary file | `a=t,f=32,t=t`: only the file's path, base64 | The pager, on a terminal on this machine that takes files | +| Shared memory | `t=s` | Never: it needs `mmap`, and the workspace forbids `unsafe` | + +Temporary files are much faster for the terminal. kitty takes a full-window page in about 10 ms +from a file, against 52 ms as a PNG in the stream (64–66 ms through tmux). Nine pages sent back to +back through tmux took 495 ms as PNGs and 55–90 ms as files. A PNG also has to be made first: on +the same laptop, 35 ms for a text page and 110 ms for a full-screen page of pictures at the +smaller compression. Without that, everything `view` does between a key and the escape codes +takes under a millisecond; the render takes about 50 ms for a text page. + +`--compression auto` (the default) picks the way (`Compression::choose` in `mod.rs`): + +| Where the terminal is | PNG effort | Temporary files | +|---|---|---| +| This machine | fast (about twice the bytes for a text page and a third more for pictures, much less time) | tried by the pager | +| Over SSH (`SSH_CONNECTION`, `SSH_CLIENT`, `SSH_TTY`; inside tmux, `tmux show-environment SSH_CONNECTION`, which tmux updates at each attach) | small | never | + +`--compression fast` or `small` sends PNGs, without trying files. + +## Temporary files (`files.rs`) + +- **The probe.** At the start, the pager sends a one-pixel image through a file. A terminal on + this machine reads it and deletes it, as the protocol has it delete temporary files once read. + Until it does, pages go as PNGs, and for good if it hasn't within 2 s. +- **Where.** `TMPDIR`, else `/dev/shm`, else `/tmp`, and in it a directory of the user's own, + `tty-graphics-protocol-pdfcraft-`. The terminal deletes only files with + `tty-graphics-protocol` in their path. The directory must be the user's, mode 0700 and not a + symlink. The directory around it must be the user's or root's, and sticky if others can write + to it, so no one else can move it away. Otherwise there are no files. +- **Each file** is `-`, created new with mode 0600 (never an existing file, nor through a + symlink), and written as straight RGBA in 256 KiB chunks. +- **A file the terminal leaves** is removed after 5 s, and pages go as PNGs from then on: the + terminal doesn't take files after all, or the escape code never reached it. At most 8 files wait + at once; pages go as PNGs until the terminal catches up. +- **Cleaning up.** The pager removes its files when it ends. A killed pager leaves them, and a + later run removes them: files named `-`, older than 60 s, whose process is gone + (`kill(pid, 0)` says `ESRCH`), looking through at most 10,000 entries. Only the private + directory is ever listed, never `TMPDIR` itself: on one macOS account that held 326,191 entries + and took 26 s to list cold, with the pager on a black screen meanwhile. +- **A thread of their own** (`Files`). Every file operation runs on it, and the pager waits at + most 1 s for an answer. After one that doesn't come (a hung network mount), files are over for + the run: pages go as PNGs, and the pages whose files the terminal hadn't taken yet are sent + again. The keys keep working throughout. At the end, once the terminal is put back, the pager + waits up to 1 s more for the thread to remove its files, stalled or not: a loaded machine + makes a thread slow far more often than a mount hangs it. + +## The size of a cell + +Pages are rendered for the pixels they cover, so `view` needs the cell size in pixels. The tty +gives it with the window's size (`TIOCGWINSZ`), and SSH carries it. `docker exec` and +`kubectl exec` carry only rows and columns, so the pixels read 0. Taking 10 × 20 px cells then +rendered a page for a kitty with 18 × 46 px cells at under half its resolution, upscaled, and +22% too narrow. So, with no pixels, `view` asks the terminal once at the start (`ask_cell` in +`mod.rs`): `ESC [ 16 t`, which xterm's control sequences define, and kitty answers with +`ESC [ 6 ; ; t` in about 3 ms. The query is followed by the device attributes +query, which every terminal answers, so one that doesn't know the first ends the wait at once; +then the 10 × 20 assumed stands. An answer arriving late reaches the pager as an unknown CSI +sequence, which is no key. Inside tmux, `#{client_cell_width}` and `#{client_cell_height}` give +the cell instead. The answer isn't asked again, so changing the font size in a session that +reports no pixels shows only on the next start. + +## Directly, or inside tmux + +Directly, the pager transmits each page as an image of its own and shows it with a placement +(`a=p`), the cursor left where it is (`C=1`). Each place on screen has a placement id (`p=1`, and +`p=2` for the second of two pages side by side), so a page given twice shows twice. A page that +leaves the screen loses its placement (`a=d,d=i`) but keeps its data, so showing it again is only +a placement. + +Inside tmux, tmux keeps its own copy of the screen and knows nothing of images: + +- **Passthrough.** Every escape code goes inside `ESC P tmux; … ESC \`, with each ESC doubled. + tmux passes it on only when the pane's `allow-passthrough` is `on` or `all`; `view` turns it on + for its own pane when it is off. +- **Unicode placeholders.** A page is a virtual placement (`U=1`) plus rows of U+10EEEE cells that + tmux stores as text, so pages scroll with the pane and survive window switches. The image id is + in the cells' foreground colour: 24-bit when tmux reports `RGB` for the terminal, the 256-colour + palette otherwise, with the id's high byte in a third diacritic. Only the first cell of each row + carries the row and column diacritics, as the protocol allows; full marks made a screenful big + enough for tmux to throw output away. Placeholders number at most 297 rows and columns, so + pages inside tmux are never bigger. +- **The terminal.** `TERM` names tmux, so `view` asks tmux for its client's terminal + (`#{client_termname}`) and cell size (`#{client_cell_width}`, `#{client_cell_height}`). + +## What is kept in the terminal (`pager.rs`) + +The pager works ahead. Two render threads render the pages on screen first, then the next two +screens and the one before. Each page is sent as soon as it is ready, so turning to it is only a +placement. The pages on screen stay up until the next ones are ready, so the screen never goes +blank in between. + +The terminal keeps at most 12 pages, and 192 MiB of pixels (`MAX_SENT`, `BUDGET`). Rendering +ahead stops at the first page that wouldn't fit, and the least recently shown pages not on screen +are deleted (`a=d,d=I`) to make room. The byte budget matters. kitty keeps 320 MB of images per +screen (the protocol's example quota), and past it, it deletes nearly every image without a +placement at once, not just the oldest. Measured in a hidden kitty with raw images: + +| Images sent | What kitty kept | +|---|---| +| 13 × 24.6 MB (319 MB) | all 13 | +| 14 × 24.6 MB (344 MB) | 1 (3 when two had placements: those survive) | +| 30 × 12.1 MB (364 MB) | 3 | + +At 12 pages and no byte budget, fit-width pages on a 5K window (about 64 MB each) went past the +quota: kitty dropped 8 of the 12 pages the pager kept, and turning back showed them black. + +When kitty drops a page anyway (another program's images, a terminal with a smaller quota), +placements tell. They ask for errors (`q=1`; every other code says `q=2`, no answers), and kitty +answers a placement of an image it doesn't have with `ESC _ G i=,p=

;ENOENT:… ESC \`. +`keys.rs` turns that into `Key::Missing`, and the pager forgets the page, renders and sends it +again, and halves its budget, at most 8 times (`MAX_RESENDS`; `r` still sends everything again). +kitty takes a temporary file before it reads the next escape code, so a placement right after +one never gets this answer by mistake. + +Any other answer is dropped. An answer must never be read as keys: its `ESC` would be the Esc +key, which quits, and its letters keys. One cut off between reads waits for the rest, up to 512 +bytes and 1 s. At the end, the pager waits up to 100 ms after its last placements, so no late answer lands +on the shell's command line. + +## What tmux loses, and how the pager notices + +tmux delivers what goes through passthrough once, to the terminals showing the pane at that +moment, and never again: it redraws its own text, not images. The pager asks tmux about the pane +150 ms after it writes, and every second anyway, in one command: + +```sh +tmux display-message -p -t "$TMUX_PANE" '#{window_id}|#{window_zoomed_flag}|#{pane_active}' \; \ + list-clients -F '#{client_name}|#{client_pid}|#{client_discarded}|#{window_id}' +``` + +`tmux::Watch` keeps, for every client seen showing the pane, how many images had been sent when it +last did. The pager acts on what changed: + +| What tmux says | What happened | What the pager does | +|---|---|---| +| No client shows the pane (another window, or zoomed on another pane) | With `allow-passthrough on`, tmux throws passthrough away | Sends nothing until the pane shows; removes waiting files without giving files up | +| A client shows it again after images went out without it | It missed them | Sends everything again | +| A client it hasn't seen (new, or attached again: the terminal clears the alternate screen's images when tmux returns to it) | It has none of the images | Sends everything again | +| More than one client shows it | Each gets every escape code, and the first to read a file deletes it | No more files; pages go as PNGs | +| A client's `#{client_discarded}` rose | tmux threw output away because the terminal fell behind | Sends everything again, at most 8 times | + +A client is its `client_name` and `client_pid`, so attaching again from the same tty is a new +client. Temporary files start only inside a tmux that reports exactly one client showing the +pane. + +## The terminal's state + +The pager runs on the alternate screen, in raw mode, with the cursor hidden and line wrap off. +`Screen` restores all of it when it drops, on return, on an error through `?`, and while a panic +unwinds; nothing else is printed to stderr meanwhile (`QUIET`). The images are deleted before +that, and the temporary files removed after it, so a slow filesystem can't hold the terminal +in raw mode. File names, error messages and warnings are written with their control characters +replaced (`printable`), so a document can't send escape sequences through them. + +## Tests + +- **Unit tests** in each module, among them property tests for the key decoder and the fit. +- **`tests/view.rs`**: printing, through a pipe with `--protocol kitty`. +- **`tests/view_pager.rs`**: the pager on a pseudo-terminal from script(1), keys in through its + stdin. It plays a terminal that takes temporary files (it reads each file named, checks its + size, deletes it) or one that doesn't, and sends kitty's answers. +- **`tests/view_tmux.rs`**: the pager in a private tmux server (`tmux -L`, killed at the end), its + clients on script(1) pseudo-terminals. It covers flips, a hidden pane, and a second client. + +The terminal tests skip with a warning where tmux or script(1) can't run, so CI machines without +them pass. + +To try a real kitty without touching your own, run `kitty --start-as=hidden --config NONE` with +`TMUX` and `TMUX_PANE` unset (an inherited `TMUX` makes `view` think it is inside your tmux, and +changes options on your pane), and use a private `tmux -L ` server for tmux. + +## Clean room + +Behaviour comes from the kitty graphics protocol specification, the tmux manual and black-box +observation of terminals and of other programs' output. Never read the source of another +terminal-graphics program or PDF viewer (kitty, tdf, timg, chafa, viu, MuPDF and so on); most are +GPL or AGPL (`AGENTS.md` §2). diff --git a/apps/pdfcraft-cli/src/view/detect.rs b/apps/pdfcraft-cli/src/view/detect.rs new file mode 100644 index 0000000000..cf08e7406c --- /dev/null +++ b/apps/pdfcraft-cli/src/view/detect.rs @@ -0,0 +1,221 @@ +//! Does this terminal speak the kitty graphics protocol, and how big are its cells? Pure +//! decisions from what the environment says and from what the terminal answers; asking it is the +//! caller's job. + +/// What the environment says, without asking the terminal: kitty sets `TERM=xterm-kitty` and +/// `KITTY_WINDOW_ID`, Ghostty `TERM=xterm-ghostty` and `TERM_PROGRAM=ghostty`. +pub(crate) fn env_says_kitty(term: Option<&str>, term_program: Option<&str>, kitty_window_id: bool) -> bool { + kitty_window_id || term.is_some_and(term_name_says_kitty) || term_program.is_some_and(|p| p.eq_ignore_ascii_case("ghostty")) +} + +/// A terminal name (`TERM`, or tmux's `#{client_termname}`) of a terminal known to speak it. +pub(crate) fn term_name_says_kitty(name: &str) -> bool { + matches!(name.trim(), "xterm-kitty" | "xterm-ghostty") +} + +/// The query the protocol recommends: a 1×1 RGB image the terminal checks but doesn't keep +/// (`a=q`, id 31), then a Primary Device Attributes request, which every terminal answers. A +/// graphics reply before the attributes reply means the protocol is supported. +pub(crate) const QUERY: &[u8] = b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"; + +/// The most reply bytes worth reading: both replies together are a few dozen. +pub(crate) const MAX_REPLY: usize = 1024; + +/// What the terminal has answered to [`QUERY`] so far. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Reply { + /// A graphics reply for id 31 came before the device attributes reply. + pub(crate) graphics: bool, + /// The device attributes reply came: nothing more is coming. + pub(crate) done: bool, +} + +/// Read the replies in `bytes`. Anything else in there (keys the user typed, garbage) is ignored. +pub(crate) fn parse_reply(bytes: &[u8]) -> Reply { + let attributes = find_attributes(bytes); + let graphics = find_graphics(bytes).is_some_and(|g| attributes.is_none_or(|a| g < a)); + Reply { graphics, done: attributes.is_some() } +} + +/// Asks for the cell size in pixels (XTWINOPS 16, from xterm's control sequences), for a +/// terminal whose tty reports no pixel size, then for the device attributes again: a terminal +/// that doesn't know the first still answers the second, so the wait ends at once. +pub(crate) const CELL_QUERY: &[u8] = b"\x1b[16t\x1b[c"; + +/// What the terminal has answered to [`CELL_QUERY`] so far. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct CellReply { + /// The cell's width and height in pixels, from an `ESC [ 6 ; height ; width t` before the + /// device attributes reply. + pub(crate) cell: Option<(u16, u16)>, + /// The device attributes reply came: nothing more is coming. + pub(crate) done: bool, +} + +/// Read the replies to [`CELL_QUERY`] in `bytes`. Anything else in there is ignored. +pub(crate) fn parse_cell_reply(bytes: &[u8]) -> CellReply { + let attributes = find_attributes(bytes); + let before = bytes.get(..attributes.unwrap_or(bytes.len())).unwrap_or_default(); + CellReply { cell: find_cell(before), done: attributes.is_some() } +} + +/// The first complete `ESC [ 6 ; height ; width t` in `bytes`, as (width, height). A size of 0, +/// or past `u16`, is no answer. +fn find_cell(bytes: &[u8]) -> Option<(u16, u16)> { + let mut from = 0; + while let Some(start) = find(bytes, b"\x1b[6;", from) { + let params = start.saturating_add(4); + let rest = bytes.get(params..).unwrap_or_default(); + let end = rest.iter().position(|b| !(b.is_ascii_digit() || *b == b';'))?; + if rest.get(end) == Some(&b't') { + let number = |n: &[u8]| std::str::from_utf8(n).ok()?.parse::().ok().filter(|v| *v > 0); + let mut numbers = rest.get(..end).unwrap_or_default().split(|b| *b == b';'); + if let (Some(h), Some(w), None) = (numbers.next(), numbers.next(), numbers.next()) + && let (Some(h), Some(w)) = (number(h), number(w)) + { + return Some((w, h)); + } + } + from = params; + } + None +} + +/// Where a complete graphics reply for id 31 (`ESC _G … i=31 … ESC \`) starts. +fn find_graphics(bytes: &[u8]) -> Option { + let mut from = 0; + while let Some(start) = find(bytes, b"\x1b_G", from) { + let body = start.saturating_add(3); + let end = find(bytes, b"\x1b\\", body)?; + let control = bytes.get(body..end)?.split(|b| *b == b';').next().unwrap_or_default(); + if control.split(|b| *b == b',').any(|key| key == b"i=31") { + return Some(start); + } + from = end; + } + None +} + +/// Where a complete Primary Device Attributes reply (`ESC [ ? c`) starts. +fn find_attributes(bytes: &[u8]) -> Option { + let mut from = 0; + while let Some(start) = find(bytes, b"\x1b[?", from) { + let params = start.saturating_add(3); + let rest = bytes.get(params..).unwrap_or_default(); + match rest.iter().position(|b| !(b.is_ascii_digit() || *b == b';')) { + Some(i) if rest.get(i) == Some(&b'c') => return Some(start), + Some(_) => from = params, + None => return None, + } + } + None +} + +/// The first `needle` in `haystack` at or after `from`. +fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option { + let rest = haystack.get(from..)?; + rest.windows(needle.len()).position(|w| w == needle).map(|i| i.saturating_add(from)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OK: &[u8] = b"\x1b_Gi=31;OK\x1b\\"; + const ATTRIBUTES: &[u8] = b"\x1b[?62;c"; + + #[test] + fn the_environment_names_kitty_and_ghostty() { + assert!(env_says_kitty(Some("xterm-kitty"), None, false)); + assert!(env_says_kitty(Some("xterm-ghostty"), None, false)); + assert!(env_says_kitty(None, Some("ghostty"), false)); + assert!(env_says_kitty(Some("xterm-256color"), None, true)); + assert!(!env_says_kitty(Some("xterm-256color"), Some("Apple_Terminal"), false)); + assert!(!env_says_kitty(Some("tmux-256color"), Some("tmux"), false)); + assert!(!env_says_kitty(None, None, false)); + } + + #[test] + fn a_graphics_reply_before_the_attributes_means_supported() { + assert_eq!(parse_reply(&[OK, ATTRIBUTES].concat()), Reply { graphics: true, done: true }); + // An error is still an answer from a terminal that knows the protocol. + let error = b"\x1b_Gi=31;ENOTSUPPORTED:no\x1b\\"; + assert_eq!(parse_reply(&[error, ATTRIBUTES].concat()), Reply { graphics: true, done: true }); + } + + #[test] + fn the_attributes_alone_mean_unsupported() { + assert_eq!(parse_reply(ATTRIBUTES), Reply { graphics: false, done: true }); + assert_eq!(parse_reply(b"\x1b[?1;2c"), Reply { graphics: false, done: true }); + // A graphics reply after the attributes is not this terminal answering the query. + assert_eq!(parse_reply(&[ATTRIBUTES, OK].concat()), Reply { graphics: false, done: true }); + } + + #[test] + fn partial_replies_wait_for_more() { + assert_eq!(parse_reply(b""), Reply::default()); + assert_eq!(parse_reply(OK), Reply { graphics: true, done: false }); + assert_eq!(parse_reply(b"\x1b_Gi=31;O"), Reply::default()); + assert_eq!(parse_reply(&[OK, b"\x1b[?62;"].concat()), Reply { graphics: true, done: false }); + assert_eq!(parse_reply(b"\x1b[?"), Reply::default()); + } + + #[test] + fn other_bytes_are_ignored() { + // Keys typed while waiting, another image's reply, a cursor report, a stray ESC. + let noisy = [b"jk\x1b".as_slice(), b"\x1b_Gi=7;OK\x1b\\", b"\x1b[12;1R", b"\x1b[?x", OK, b"q", ATTRIBUTES].concat(); + assert_eq!(parse_reply(&noisy), Reply { graphics: true, done: true }); + let other_id = [b"\x1b_Gi=310;OK\x1b\\".as_slice(), ATTRIBUTES].concat(); + assert_eq!(parse_reply(&other_id), Reply { graphics: false, done: true }); + } + + #[test] + fn garbage_never_panics() { + for bytes in [&b"\x1b"[..], b"\x1b_", b"\x1b_G", b"\x1b_G\x1b\\", b"\x1b_G;\x1b\\", b"\x1b[", b"\x1b[?c", &[0xff; 64]] { + let _ = parse_reply(bytes); + } + assert_eq!(parse_reply(b"\x1b[?c"), Reply { graphics: false, done: true }); + } + + #[test] + fn the_cell_size_comes_before_the_attributes() { + // kitty's answer: cells 14 px wide and 25 tall. + let kitty = [b"\x1b[6;25;14t".as_slice(), b"\x1b[?62;52;c"].concat(); + assert_eq!(parse_cell_reply(&kitty), CellReply { cell: Some((14, 25)), done: true }); + // A terminal that doesn't know the question answers the attributes alone. + assert_eq!(parse_cell_reply(ATTRIBUTES), CellReply { cell: None, done: true }); + // An answer after the attributes is not this terminal answering the query. + assert_eq!(parse_cell_reply(&[ATTRIBUTES, b"\x1b[6;25;14t"].concat()), CellReply { cell: None, done: true }); + } + + #[test] + fn a_cell_size_cut_off_waits_for_more() { + assert_eq!(parse_cell_reply(b""), CellReply::default()); + assert_eq!(parse_cell_reply(b"\x1b[6;25;1"), CellReply::default()); + assert_eq!(parse_cell_reply(b"\x1b[6;25;14t"), CellReply { cell: Some((14, 25)), done: false }); + } + + #[test] + fn a_cell_size_that_makes_no_sense_is_no_answer() { + for odd in [&b"\x1b[6;0;14t"[..], b"\x1b[6;25;0t", b"\x1b[6;25t", b"\x1b[6;25;14;3t", b"\x1b[6;;14t", b"\x1b[6;65536;14t", b"\x1b[6;25;14x"] { + assert_eq!(parse_cell_reply(&[odd, ATTRIBUTES].concat()).cell, None, "{odd:?}"); + } + // Keys typed while waiting, other reports, a stray ESC: ignored, the answer still found. + let noisy = [b"j\x1b".as_slice(), b"\x1b[4;2100;3444t", b"\x1b[6;0;0t", b"\x1b[6;25;14t", b"q", ATTRIBUTES].concat(); + assert_eq!(parse_cell_reply(&noisy), CellReply { cell: Some((14, 25)), done: true }); + } + + proptest::proptest! { + #[test] + fn any_bytes_parse(bytes in proptest::collection::vec(proptest::prelude::any::(), 0..300)) { + let _ = parse_reply(&bytes); + let _ = parse_cell_reply(&bytes); + } + + #[test] + fn any_cell_size_is_read_back(w in 1u16.., h in 1u16..) { + let reply = format!("\x1b[6;{h};{w}t\x1b[?62;c"); + proptest::prop_assert_eq!(parse_cell_reply(reply.as_bytes()), CellReply { cell: Some((w, h)), done: true }); + } + } +} diff --git a/apps/pdfcraft-cli/src/view/files.rs b/apps/pdfcraft-cli/src/view/files.rs new file mode 100644 index 0000000000..b7b4ed8044 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/files.rs @@ -0,0 +1,766 @@ +//! Pages handed to a terminal on this machine through temporary files (`t=t` in the kitty +//! graphics protocol): a page's pixels go into a file of their own, which the terminal reads and +//! then deletes, and only the file's path goes through the terminal stream (and tmux). Neither +//! side makes or decodes a PNG: kitty takes a page in several times faster than as a PNG in the +//! stream, and tmux has next to nothing to pass on. +//! +//! Whether the terminal reads this machine's files is found out by trying ([`TempFiles::start`]): +//! a one-pixel image sent through a file, which the terminal deletes once it has read it. Until +//! then, and for good if it never does (a terminal across SSH, or one without temporary files), +//! pages go as PNGs in the stream. +//! +//! The protocol has the terminal delete only files in a temporary directory with +//! `tty-graphics-protocol` in their path. The files go in a directory of this user's own inside +//! the temporary directory, `tty-graphics-protocol-pdfcraft-`: no one else can create, +//! replace or read anything in it, and looking for files left behind lists only it (a shared +//! temporary directory can hold hundreds of thousands of entries, which take seconds to list). +//! Each file is created new, never an existing file nor through a symlink. A file the terminal +//! leaves is removed after [`STALE`], when the pager ends, and, when the run was killed first, +//! by a later run. +//! +//! The files are made, looked at and removed on a thread of their own ([`Files`]): a filesystem +//! that stalls (a hung network mount, say) stalls that thread, never the pager's keys. + +use std::fs::{DirBuilder, File, OpenOptions}; +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::{Duration, Instant, SystemTime}; + +use pdfcraft_engine::export::unpremultiply; +use pdfcraft_render::RenderedPage; + +use super::kitty::{self, Wrap}; + +/// The directory's name before the user id: the protocol's marker, then whose files they are. +const DIR_PREFIX: &str = "tty-graphics-protocol-pdfcraft-"; +/// How long the terminal has to take the probe. Pages go in the stream meanwhile, and for good +/// after it. +const PROBE_WAIT: Duration = Duration::from_secs(2); +/// How long a file waits for the terminal before it is removed. The terminal takes one in +/// milliseconds; one it hasn't taken by now never will (it doesn't take files after all, or tmux +/// threw the escape code away), and pages go in the stream from then on. +const STALE: Duration = Duration::from_secs(5); +/// How old a file of a run that is gone must be before another run removes it. +const LEFT_BEHIND: Duration = Duration::from_secs(60); +/// The most entries looked through for files left behind. +const MAX_SWEPT: usize = 10_000; +/// The most files waiting for the terminal at once; pages go in the stream until it catches up. +const MAX_WAITING: usize = 8; +/// Bytes converted to straight alpha and written at a time: whole pixels. +const WRITE_CHUNK: usize = 1 << 18; +/// How long the pager waits for the files' thread to answer before it gives files up for the +/// run. Writing the largest page takes tens of milliseconds. +const STALL: Duration = Duration::from_secs(1); + +/// The temporary files, for the pager: [`TempFiles`] on a thread of its own. Each call waits at +/// most [`STALL`] for the thread's answer. After one that doesn't come, files are given up for +/// the rest of the run, and the pages whose files the terminal hadn't taken yet count as lost. +/// When this drops, the thread removes the files, and this waits for that at most [`STALL`], +/// stalled or not: a thread that was only slow is done by then. The pager keeps a stalled one to +/// its end, after the terminal is put back. A run that ends first leaves them to a later run. +pub(crate) struct Files { + jobs: Sender, + answers: Receiver, + stall: Duration, + /// The images whose files the terminal hadn't taken at the last look, and the one being + /// written: the ones lost if the thread stalls. + waiting: Vec, + stalled: bool, +} + +enum Job { + Ready(Instant), + Write { id: u32, page: RenderedPage, now: Instant }, + Check(Instant), + Forget, + Stop, +} + +enum Answer { + /// The probe's escape codes, or `None` when the files can't be. + Started(Option>), + Ready(bool), + /// The page comes back, for the stream when its file failed. + Written(Result, RenderedPage), + Checked { + lost: Vec, + waiting: Vec, + }, + Stopped, +} + +/// What became of a page given to [`Files::write`]. +pub(crate) enum Written { + File(PathBuf), + /// It couldn't be written, and why; the page comes back, to go in the stream. + Failed(String, RenderedPage), + /// The thread didn't answer in time (or is gone): the page went with it, and must be + /// rendered again. + Stalled, +} + +impl Files { + /// Start trying, on a thread of its own ([`TempFiles::start`]): the files, and the probe's + /// escape codes to write before any page. `None` when there's no safe place for them, or no + /// answer in time. + pub(crate) fn start(probe_id: u32, wrap: Wrap) -> Option<(Self, Vec)> { + Self::spawn(STALL, move || TempFiles::start(probe_id, wrap, Instant::now())) + } + + fn spawn(stall: Duration, start: impl FnOnce() -> Option<(TempFiles, Vec)> + Send + 'static) -> Option<(Self, Vec)> { + let (jobs, inbox) = mpsc::channel(); + let (outbox, answers) = mpsc::channel(); + std::thread::Builder::new().name("view-files".into()).spawn(move || serve(start, &inbox, &outbox)).ok()?; + let mut files = Self { jobs, answers, stall, waiting: Vec::new(), stalled: false }; + if let Some(Answer::Started(Some(probe))) = files.answer() { + return Some((files, probe)); + } + // No files, and no second wait: a start that ends late removes its probe by itself, while + // the pager goes on. + files.stall = Duration::ZERO; + None + } + + /// Whether the next page can go through a file ([`TempFiles::ready`]). + pub(crate) fn ready(&mut self, now: Instant) -> bool { + matches!(self.ask(Job::Ready(now)), Some(Answer::Ready(true))) + } + + /// Write `page` to a new file for image `id` ([`TempFiles::write`]). + pub(crate) fn write(&mut self, id: u32, page: RenderedPage, now: Instant) -> Written { + self.waiting.push(id); + match self.ask(Job::Write { id, page, now }) { + Some(Answer::Written(Ok(path), _)) => Written::File(path), + Some(Answer::Written(Err(e), page)) => { + self.waiting.retain(|w| *w != id); + Written::Failed(e, page) + } + _ => Written::Stalled, + } + } + + /// The images whose files the terminal never took ([`TempFiles::check`]). Once the thread has + /// stalled: once, every image whose file it hadn't taken yet, as far as is known. + pub(crate) fn check(&mut self, now: Instant) -> Vec { + match self.ask(Job::Check(now)) { + Some(Answer::Checked { lost, waiting }) => { + self.waiting = waiting; + lost + } + _ => std::mem::take(&mut self.waiting), + } + } + + /// Remove the files waiting for the terminal without giving files up: it can't have them + /// (tmux threw their escape codes away), and their pages will be sent again. + pub(crate) fn forget(&mut self) { + if !self.stalled { + let _ = self.jobs.send(Job::Forget); + } + self.waiting.clear(); + } + + /// Whether the thread stopped answering: files are over for this run. + pub(crate) fn stalled(&self) -> bool { + self.stalled + } + + /// The thread's answer to `job`; `None`, for good, once it doesn't come in time. + fn ask(&mut self, job: Job) -> Option { + if self.stalled || self.jobs.send(job).is_err() { + self.stalled = true; + return None; + } + self.answer() + } + + fn answer(&mut self) -> Option { + let answer = self.answers.recv_timeout(self.stall).ok(); + self.stalled |= answer.is_none(); + answer + } +} + +impl Drop for Files { + fn drop(&mut self) { + if self.jobs.send(Job::Stop).is_err() { + return; + } + // Stopped, once the files are removed: after the answer to a job that stalled, if any. + let Some(deadline) = Instant::now().checked_add(self.stall) else { return }; + while let Ok(answer) = self.answers.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + if matches!(answer, Answer::Stopped) { + break; + } + } + } +} + +/// The files' thread: start them, then do the pager's jobs until it stops or goes. +fn serve(start: impl FnOnce() -> Option<(TempFiles, Vec)>, jobs: &Receiver, answers: &Sender) { + let Some((mut files, probe)) = start() else { + let _ = answers.send(Answer::Started(None)); + return; + }; + if answers.send(Answer::Started(Some(probe))).is_ok() { + while let Ok(job) = jobs.recv() { + let answer = match job { + Job::Ready(now) => Answer::Ready(files.ready(now)), + Job::Write { id, page, now } => Answer::Written(files.write(id, page.width, page.height, &page.rgba, now), page), + Job::Check(now) => { + let lost = files.check(now); + Answer::Checked { lost, waiting: files.waiting.iter().map(|w| w.id).collect() } + } + Job::Forget => { + files.forget(); + continue; + } + Job::Stop => break, + }; + if answers.send(answer).is_err() { + break; + } + } + } + drop(files); + let _ = answers.send(Answer::Stopped); +} + +enum Probe { + /// Sent; the terminal hasn't taken it yet. + Waiting { path: PathBuf, since: Instant }, + /// The terminal reads files from here. + Taken, + /// It didn't in time, or a file couldn't be written: pages go in the stream. + Refused, +} + +/// A page's file, written and not taken yet. +struct Waiting { + path: PathBuf, + id: u32, + since: Instant, +} + +struct TempFiles { + /// This user's directory. + dir: PathBuf, + /// This process: the first part of its files' names, `-`. + run: u32, + next: u64, + probe: Probe, + waiting: Vec, +} + +impl TempFiles { + /// Start trying, in the temporary directory: these files, and the escape codes of the probe + /// (image `probe_id`, deleted again at once) to write before any page. `None` when there's no + /// safe place for the files or the probe can't be written. + fn start(probe_id: u32, wrap: Wrap, now: Instant) -> Option<(Self, Vec)> { + let uid = rustix::process::getuid().as_raw(); + Self::start_in(&temp_dir()?, uid, std::process::id(), probe_id, wrap, now) + } + + fn start_in(parent: &Path, uid: u32, run: u32, probe_id: u32, wrap: Wrap, now: Instant) -> Option<(Self, Vec)> { + let dir = own_dir(parent, uid)?; + sweep(&dir, uid, SystemTime::now()); + let mut files = Self { dir, run, next: 0, probe: Probe::Refused, waiting: Vec::new() }; + // One opaque white pixel. + let path = files.create(1, 1, &[255; 4]).ok()?; + let mut out = Vec::new(); + kitty::transmit_file(&mut out, probe_id, &path, 1, 1, wrap); + kitty::delete(&mut out, probe_id, wrap); + files.probe = Probe::Waiting { path, since: now }; + Some((files, out)) + } + + /// Whether the next page can go through a file: the terminal took the probe, and isn't + /// behind with the files since. + fn ready(&mut self, now: Instant) -> bool { + self.settle(now); + matches!(self.probe, Probe::Taken) && self.waiting.len() < MAX_WAITING + } + + /// Write `width` × `height` premultiplied RGBA pixels, as straight RGBA, to a new file for + /// image `id`, and say where. After a failure, pages go in the stream. + fn write(&mut self, id: u32, width: u32, height: u32, premultiplied: &[u8], now: Instant) -> Result { + match self.create(width, height, premultiplied) { + Ok(path) => { + self.waiting.push(Waiting { path: path.clone(), id, since: now }); + Ok(path) + } + Err(e) => { + self.probe = Probe::Refused; + Err(e) + } + } + } + + /// Forget the files the terminal has taken, and remove those it hasn't after [`STALE`]: the + /// ids of their images, which never arrived. Pages go in the stream after one such file, + /// whatever the probe said: something else may have removed the probe. + fn check(&mut self, now: Instant) -> Vec { + self.settle(now); + let mut lost = Vec::new(); + self.waiting.retain(|w| { + if !present(&w.path) { + return false; + } + if now.saturating_duration_since(w.since) < STALE { + return true; + } + let _ = std::fs::remove_file(&w.path); + lost.push(w.id); + false + }); + if !lost.is_empty() { + self.probe = Probe::Refused; + } + lost + } + + /// Remove the files waiting, without giving files up ([`Files::forget`]). + fn forget(&mut self) { + for w in self.waiting.drain(..) { + let _ = std::fs::remove_file(&w.path); + } + } + + /// Settle the probe once the terminal has taken it, or the wait is over. + fn settle(&mut self, now: Instant) { + if let Probe::Waiting { path, since } = &self.probe { + if !present(path) { + self.probe = Probe::Taken; + } else if now.saturating_duration_since(*since) >= PROBE_WAIT { + let _ = std::fs::remove_file(path); + self.probe = Probe::Refused; + } + } + } + + /// A new file holding the pixels, or why not; nothing is left behind on failure. + fn create(&mut self, width: u32, height: u32, premultiplied: &[u8]) -> Result { + let expected = u64::from(width).checked_mul(u64::from(height)).and_then(|n| n.checked_mul(4)); + match (expected, u64::try_from(premultiplied.len())) { + (Some(expected), Ok(len)) if expected == len => {} + _ => return Err(format!("{width} × {height} pixels in {} bytes", premultiplied.len())), + } + let path = self.dir.join(format!("{}-{}", self.run, self.next)); + self.next = self.next.wrapping_add(1); + // New, or an error: never an existing file, nor one a symlink points to. + let file = OpenOptions::new().write(true).create_new(true).mode(0o600).open(&path).map_err(|e| format!("{}: {e}", path.display()))?; + if let Err(e) = write_straight(file, premultiplied) { + let _ = std::fs::remove_file(&path); + return Err(format!("{}: {e}", path.display())); + } + Ok(path) + } +} + +impl Drop for TempFiles { + fn drop(&mut self) { + if let Probe::Waiting { path, .. } = &self.probe { + let _ = std::fs::remove_file(path); + } + for w in &self.waiting { + let _ = std::fs::remove_file(&w.path); + } + } +} + +/// The temporary directory: `TMPDIR` when set, else `/dev/shm` where there is one (memory, never +/// disk), else `/tmp`. Only an absolute path: the terminal would read a relative one from its own +/// working directory. +fn temp_dir() -> Option { + if let Some(dir) = std::env::var_os("TMPDIR").filter(|d| !d.is_empty()) { + let dir = PathBuf::from(dir); + return dir.is_absolute().then_some(dir); + } + let shm = Path::new("/dev/shm"); + Some(if shm.is_dir() { shm.into() } else { PathBuf::from("/tmp") }) +} + +/// This user's directory in `parent`, made when missing, or `None` when it isn't safe: `parent` +/// must be a directory of this user's or root's that others can't write to, or can only add +/// their own entries to (sticky, as `/tmp`), so no one else can move the directory away; and the +/// directory must be this user's, not a symlink, and closed to everyone else. +fn own_dir(parent: &Path, uid: u32) -> Option { + let meta = std::fs::metadata(parent).ok()?; + let others_write = meta.mode() & 0o022 != 0; + let sticky = meta.mode() & 0o1000 != 0; + if !meta.is_dir() || (meta.uid() != uid && meta.uid() != 0) || (others_write && !sticky) { + return None; + } + let dir = parent.join(format!("{DIR_PREFIX}{uid}")); + match DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + Err(_) => return None, + } + let meta = std::fs::symlink_metadata(&dir).ok()?; + (meta.is_dir() && meta.uid() == uid && meta.mode() & 0o077 == 0).then_some(dir) +} + +/// Whether `path` is still there (not followed if it is a symlink). A file that can't be looked +/// at counts as there: it is removed by name in the end either way. +fn present(path: &Path) -> bool { + !matches!(std::fs::symlink_metadata(path), Err(e) if e.kind() == ErrorKind::NotFound) +} + +/// `premultiplied` into `file` with straight alpha, as kitty's `f=32` has it. +fn write_straight(mut file: File, premultiplied: &[u8]) -> std::io::Result<()> { + let mut chunk = Vec::with_capacity(WRITE_CHUNK.min(premultiplied.len())); + for part in premultiplied.chunks(WRITE_CHUNK) { + chunk.clear(); + chunk.extend_from_slice(part); + unpremultiply(&mut chunk); + file.write_all(&chunk)?; + } + Ok(()) +} + +/// Remove the files that runs killed before they could remove them left in `dir`: named +/// `-`, regular files of this user's, older than [`LEFT_BEHIND`], and their run's process +/// gone. A run that is only stopped keeps its files. +fn sweep(dir: &Path, uid: u32, now: SystemTime) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.take(MAX_SWEPT).flatten() { + let Some(run) = entry.file_name().to_str().and_then(run_of) else { continue }; + // Of the entry itself, never what a symlink points to. + let Ok(meta) = entry.metadata() else { continue }; + let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= LEFT_BEHIND); + if meta.is_file() && meta.uid() == uid && old && gone(run) { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// The run (process id) a file name `-` belongs to: both parts digits, the run positive. +fn run_of(name: &str) -> Option { + let (run, n) = name.split_once('-')?; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if !digits(run) || !digits(n) { + return None; + } + run.parse::().ok().filter(|run| *run > 0) +} + +/// Whether process `pid` (positive) no longer exists. +fn gone(pid: i32) -> bool { + rustix::process::Pid::from_raw(pid).is_some_and(|pid| rustix::process::test_kill_process(pid) == Err(rustix::io::Errno::SRCH)) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::PermissionsExt as _; + + fn uid() -> u32 { + rustix::process::getuid().as_raw() + } + + /// A temporary directory of the test's own, empty. + fn scratch(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-view-files-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + dir + } + + /// The files in this user's directory in `parent`. + fn listing(parent: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(parent.join(format!("{DIR_PREFIX}{}", uid()))) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + /// The path a file transmission names. + fn named(codes: &[u8]) -> PathBuf { + let text = std::str::from_utf8(codes).unwrap(); + let payload = text.split(';').nth(1).unwrap().split('\x1b').next().unwrap(); + PathBuf::from(OsStr::from_bytes(&base64::engine::general_purpose::STANDARD.decode(payload).unwrap())) + } + + /// A rendered page of `width` × `height` opaque grey pixels. + fn page(width: u32, height: u32) -> RenderedPage { + let request = + pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }; + let rgba = pdfcraft_render::Pixels::from(vec![0xFF80_8080; (width * height) as usize]); + RenderedPage { request, width, height, rgba, error: None, text: None, warnings: Vec::new(), millis: 0 } + } + + /// Started in `parent` as run 7, the probe already taken. + fn taken(parent: &Path, now: Instant) -> TempFiles { + let (files, codes) = TempFiles::start_in(parent, uid(), 7, 99, Wrap::None, now).unwrap(); + std::fs::remove_file(named(&codes)).unwrap(); + files + } + + #[test] + fn a_terminal_that_takes_the_probe_gets_pages_as_files() { + let parent = scratch("taken"); + let now = Instant::now(); + let (mut files, codes) = TempFiles::start_in(&parent, uid(), 7, 99, Wrap::None, now).unwrap(); + // The probe: one pixel through a file, its image deleted straight after. + let text = String::from_utf8_lossy(&codes); + assert!(text.starts_with("\x1b_Ga=t,f=32,t=t,s=1,v=1,q=2,i=99;"), "{text:?}"); + assert!(text.ends_with("\x1b_Ga=d,d=I,q=2,i=99\x1b\\"), "{text:?}"); + let probe = named(&codes); + let dir = parent.join(format!("tty-graphics-protocol-pdfcraft-{}", uid())); + assert_eq!(probe, dir.join("7-0")); + assert_eq!(std::fs::read(&probe).unwrap(), [255; 4]); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700, "this user's alone"); + assert_eq!(std::fs::metadata(&probe).unwrap().permissions().mode() & 0o777, 0o600); + assert!(!files.ready(now), "not before the terminal takes the probe"); + // The terminal reads it and deletes it. + std::fs::remove_file(&probe).unwrap(); + assert!(files.ready(now)); + + // A page: straight RGBA, half-covered red made whole again. + let page = [64, 0, 0, 128, 10, 20, 30, 255]; + let path = files.write(5, 2, 1, &page, now).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), [128, 0, 0, 128, 10, 20, 30, 255]); + assert!(files.write(6, 3, 1, &page, now).is_err(), "pixels that don't fill the size"); + assert!(!files.ready(now), "after a failure, the stream"); + drop(files); + assert!(listing(&parent).is_empty(), "the files the terminal never took are removed: {:?}", listing(&parent)); + } + + #[test] + fn a_terminal_that_leaves_the_probe_gets_the_stream() { + let parent = scratch("refused"); + let now = Instant::now(); + let (mut files, _) = TempFiles::start_in(&parent, uid(), 7, 99, Wrap::Tmux, now).unwrap(); + assert!(!files.ready(now + PROBE_WAIT - Duration::from_millis(1))); + assert_eq!(listing(&parent).len(), 1, "still waiting"); + // The wait is over: the stream, for good, and the probe is removed. + assert!(!files.ready(now + PROBE_WAIT)); + assert!(listing(&parent).is_empty()); + assert!(!files.ready(now + PROBE_WAIT * 2)); + } + + #[test] + fn files_the_terminal_leaves_are_removed_and_their_images_named() { + let parent = scratch("stale"); + let now = Instant::now(); + let mut files = taken(&parent, now); + let took = files.write(1, 1, 1, &[0; 4], now).unwrap(); + let left = files.write(2, 1, 1, &[0; 4], now).unwrap(); + std::fs::remove_file(&took).unwrap(); + assert_eq!(files.check(now + STALE - Duration::from_millis(1)), Vec::::new()); + assert_eq!(files.waiting.len(), 1, "the taken one is forgotten"); + assert!(files.ready(now + STALE - Duration::from_millis(1))); + assert_eq!(files.check(now + STALE), [2]); + assert!(!left.exists()); + assert!(files.waiting.is_empty()); + // The terminal doesn't take files after all: the stream from now on. + assert!(!files.ready(now + STALE)); + } + + #[test] + fn too_many_files_waiting_send_pages_in_the_stream_until_it_catches_up() { + let parent = scratch("behind"); + let now = Instant::now(); + let mut files = taken(&parent, now); + let paths: Vec = (0..MAX_WAITING as u32).map(|id| files.write(id, 1, 1, &[0; 4], now).unwrap()).collect(); + assert!(!files.ready(now)); + std::fs::remove_file(&paths[0]).unwrap(); + files.check(now); + assert!(files.ready(now)); + } + + #[test] + fn files_tmux_threw_away_are_removed_without_giving_files_up() { + let parent = scratch("forget"); + let now = Instant::now(); + let mut files = taken(&parent, now); + let path = files.write(1, 1, 1, &[0; 4], now).unwrap(); + files.forget(); + assert!(!path.exists()); + assert_eq!(files.check(now + STALE), Vec::::new()); + assert!(files.ready(now + STALE), "pages still go through files"); + } + + #[test] + fn the_files_work_on_a_thread_of_their_own() { + let parent = scratch("thread"); + let start = { + let parent = parent.clone(); + move || TempFiles::start_in(&parent, uid(), 7, 99, Wrap::None, Instant::now()) + }; + let (mut files, codes) = Files::spawn(STALL, start).unwrap(); + let now = Instant::now(); + assert!(!files.ready(now)); + std::fs::remove_file(named(&codes)).unwrap(); + assert!(files.ready(now)); + let Written::File(path) = files.write(5, page(2, 1), now) else { panic!("no file") }; + assert_eq!(std::fs::read(&path).unwrap(), [0x80, 0x80, 0x80, 0xFF].repeat(2)); + assert_eq!(files.check(now), Vec::::new()); + assert_eq!(files.waiting, [5]); + // A page that can't be written comes back, to go in the stream. + let mut odd = page(2, 1); + odd.width = 3; + assert!(matches!(files.write(6, odd, now), Written::Failed(_, back) if back.width == 3)); + assert!(!files.ready(now)); + // Dropped: the thread removes what is left before this returns. + drop(files); + assert!(listing(&parent).is_empty(), "{:?}", listing(&parent)); + // Files that can't be: none, and no wait. + assert!(Files::spawn(STALL, || None).is_none()); + } + + #[test] + fn a_thread_that_was_only_slow_removes_its_files_before_the_pager_ends() { + let parent = scratch("slow"); + let start = { + let parent = parent.clone(); + move || TempFiles::start_in(&parent, uid(), 7, 99, Wrap::None, Instant::now()) + }; + let (mut files, codes) = Files::spawn(STALL, start).unwrap(); + std::fs::remove_file(named(&codes)).unwrap(); + let now = Instant::now(); + assert!(files.ready(now)); + // A write answered too late (a loaded machine): files are given up, while the thread + // goes on and writes the file. + files.stall = Duration::ZERO; + assert!(matches!(files.write(5, page(2048, 2048), now), Written::Stalled)); + assert!(files.stalled()); + let deadline = Instant::now() + Duration::from_secs(5); + while listing(&parent).is_empty() { + assert!(Instant::now() < deadline, "the thread never started the file"); + std::thread::sleep(Duration::from_millis(1)); + } + // The pager ends while the file is still being written: the thread still removes it + // before this returns. + files.stall = STALL; + drop(files); + assert!(listing(&parent).is_empty(), "{:?}", listing(&parent)); + } + + #[test] + fn a_stalled_thread_gives_files_up_and_never_stalls_the_pager() { + // A start that hangs: one wait, not two. + let slow = Duration::from_millis(300); + let started = Instant::now(); + let hangs = || { + std::thread::sleep(Duration::from_secs(2)); + None + }; + assert!(Files::spawn(slow, hangs).is_none()); + assert!(started.elapsed() < slow + slow / 2, "{:?}", started.elapsed()); + let stall = Duration::from_millis(50); + // The pager's end of a thread this test plays: it answers a look (one file waiting), then + // stalls in the middle of writing a page. + let (jobs, inbox) = mpsc::channel(); + let (outbox, answers) = mpsc::channel(); + let mut files = Files { jobs, answers, stall, waiting: Vec::new(), stalled: false }; + outbox.send(Answer::Checked { lost: Vec::new(), waiting: vec![4] }).unwrap(); + assert_eq!(files.check(Instant::now()), Vec::::new()); + assert!(matches!(files.write(5, page(1, 1), Instant::now()), Written::Stalled)); + assert!(files.stalled()); + // From then on nothing waits: files are over, and the pages in files the terminal hadn't + // taken are lost, once. + let after = Instant::now(); + assert!(!files.ready(Instant::now())); + assert_eq!(files.check(Instant::now()), [4, 5]); + assert_eq!(files.check(Instant::now()), Vec::::new()); + assert!(after.elapsed() < stall); + // At the end, the thread is told to stop and waited for, at most once more. + drop(files); + assert!(after.elapsed() < stall * 3); + let asked = inbox.try_iter().collect::>(); + assert!(matches!(asked.as_slice(), [Job::Check(_), Job::Write { .. }, Job::Stop]), "nothing else asked after the stall"); + drop(outbox); + } + + #[test] + fn a_planted_file_or_symlink_is_never_written_through() { + let parent = scratch("planted"); + let target = parent.join("target"); + std::fs::write(&target, b"keep").unwrap(); + // Someone guessed the probe's name and put a symlink there. + let dir = own_dir(&parent, uid()).unwrap(); + std::os::unix::fs::symlink(&target, dir.join("7-0")).unwrap(); + assert!(TempFiles::start_in(&parent, uid(), 7, 99, Wrap::None, Instant::now()).is_none()); + assert_eq!(std::fs::read(&target).unwrap(), b"keep"); + } + + #[test] + fn the_directory_must_be_safe_from_other_users() { + // This user's directory as a symlink to somewhere else: refused. + let parent = scratch("unsafe"); + let elsewhere = parent.join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::os::unix::fs::symlink(&elsewhere, parent.join(format!("{DIR_PREFIX}{}", uid()))).unwrap(); + assert_eq!(own_dir(&parent, uid()), None); + // Open to others: refused, until closed again. + let parent = scratch("open"); + let dir = own_dir(&parent, uid()).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(own_dir(&parent, uid()), None); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(own_dir(&parent, uid()), Some(dir)); + // A temporary directory anyone can write to and move things out of: refused; sticky, as + // /tmp is: fine. + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert_eq!(own_dir(&parent, uid()), None); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o1777)).unwrap(); + assert!(own_dir(&parent, uid()).is_some()); + // Another user's directory (as seen by someone else). + assert_eq!(own_dir(&parent, uid().wrapping_add(1)), None); + } + + #[test] + fn only_files_of_runs_that_are_gone_are_swept() { + let parent = scratch("sweep"); + let dir = own_dir(&parent, uid()).unwrap(); + // A process that has exited. + let mut child = std::process::Command::new("true").spawn().unwrap(); + child.wait().unwrap(); + let dead = child.id(); + let alive = std::process::id(); + for name in [format!("{dead}-0"), format!("{dead}-1"), format!("{alive}-0"), format!("{dead}-x"), "notes.txt".into(), "0-0".into()] { + std::fs::write(dir.join(name), b"x").unwrap(); + } + std::fs::create_dir(dir.join(format!("{dead}-2"))).unwrap(); + // Just written: kept, even a gone run's. + sweep(&dir, uid(), SystemTime::now()); + assert_eq!(listing(&parent).len(), 7); + // A minute on: the gone run's files go; a live run's, other names and directories stay. + sweep(&dir, uid(), SystemTime::now() + LEFT_BEHIND + Duration::from_secs(1)); + let mut kept = vec![format!("{alive}-0"), format!("{dead}-x"), "notes.txt".into(), "0-0".into(), format!("{dead}-2")]; + kept.sort(); + assert_eq!(listing(&parent), kept); + } + + #[test] + fn file_names_name_their_run() { + assert_eq!(run_of("123-0"), Some(123)); + assert_eq!(run_of("123-45"), Some(123)); + for bad in ["0-1", "-1-2", "123", "123-", "-4", "12a-1", "1-2-3", "99999999999-1", "+5-1", " 5-1"] { + assert_eq!(run_of(bad), None, "{bad}"); + } + } + + #[test] + fn large_pages_are_written_whole() { + let parent = scratch("large"); + let now = Instant::now(); + let mut files = taken(&parent, now); + // More than one chunk, and not a whole number of them. + let (w, h) = (300u32, 301u32); + let page: Vec = (0..w * h * 4).map(|i| if i % 4 == 3 { 255 } else { (i % 251) as u8 }).collect(); + let path = files.write(1, w, h, &page, now).unwrap(); + assert!(page.len() > WRITE_CHUNK && !page.len().is_multiple_of(WRITE_CHUNK)); + assert_eq!(std::fs::read(&path).unwrap(), page, "opaque pixels as they are"); + } +} diff --git a/apps/pdfcraft-cli/src/view/fit.rs b/apps/pdfcraft-cli/src/view/fit.rs new file mode 100644 index 0000000000..fc06a7fe91 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/fit.rs @@ -0,0 +1,244 @@ +//! How big a page is drawn: the render scale and the cells the image covers. Pure arithmetic, +//! total over any input (zero, negative, NaN and infinite sizes are errors, never panics). + +/// What a page is fitted to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Fit { + /// The whole page in the window. + Page, + /// The window's width; a page taller than the window runs past it. + Width, +} + +impl Fit { + pub(crate) fn parse(s: &str) -> Result { + match s { + "page" => Ok(Self::Page), + "width" => Ok(Self::Width), + other => Err(format!("view: --fit {other:?}: use page or width")), + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Page => "page", + Self::Width => "width", + } + } + + /// The other one: the pager's `f` key. + pub(crate) fn toggled(self) -> Self { + match self { + Self::Page => Self::Width, + Self::Width => Self::Page, + } + } +} + +/// A terminal cell's size in pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Cell { + pub(crate) w: f32, + pub(crate) h: f32, +} + +/// The cell assumed when the terminal doesn't report its pixel size. +pub(crate) const FALLBACK_CELL: Cell = Cell { w: 10.0, h: 20.0 }; + +impl Cell { + /// From a window's size in cells and in pixels; `None` when the terminal reports no pixels. + pub(crate) fn from_window(cols: u16, rows: u16, xpixel: u16, ypixel: u16) -> Option { + (cols > 0 && rows > 0 && xpixel > 0 && ypixel > 0) + .then(|| Self { w: f32::from(xpixel) / f32::from(cols), h: f32::from(ypixel) / f32::from(rows) }) + } +} + +/// The largest rendered side in pixels: a page fitted to a very large window stays a sane size. +pub(crate) const MAX_PIXELS: f32 = 8192.0; + +/// The most pixels a page is rendered with, 16 MP: 64 MB of RGBA, which bounds what one page +/// costs however tall a document makes it (more than a 4K screen shows at once). +pub(crate) const MAX_AREA: f64 = 16.0e6; + +/// Where a page goes and how big it is. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Layout { + /// Render scale, in pixels per point. + pub(crate) scale: f32, + /// The cells the image covers, rounded up. + pub(crate) cols: u16, + pub(crate) rows: u16, +} + +/// Fit a `page_w` × `page_h` point page into `cols` × `rows` cells of `cell` pixels, never more +/// than `max_rows` cells tall or [`MAX_PIXELS`] on a side. +pub(crate) fn layout(page_w: f32, page_h: f32, cols: u16, rows: u16, cell: Cell, fit: Fit, max_rows: u16) -> Result { + let positive = |v: f32| v.is_finite() && v > 0.0; + if !(positive(page_w) && positive(page_h)) { + return Err("the page has no size".into()); + } + if cols == 0 || rows == 0 || max_rows == 0 { + return Err("the terminal reports no size".into()); + } + if !(positive(cell.w) && positive(cell.h)) { + return Err("the terminal reports no cell size".into()); + } + let (area_w, area_h) = (f32::from(cols) * cell.w, f32::from(rows) * cell.h); + let fitted = match fit { + Fit::Page => (area_w / page_w).min(area_h / page_h), + Fit::Width => area_w / page_w, + }; + // In f64, where the area of any f32 page is finite. + let by_area = (MAX_AREA / (f64::from(page_w) * f64::from(page_h))).sqrt() as f32; + let scale = fitted.min(f32::from(max_rows) * cell.h / page_h).min(MAX_PIXELS / page_w).min(MAX_PIXELS / page_h).min(by_area); + if !positive(scale) { + return Err("the page is too large or too small to show".into()); + } + // Rounding can put an exact fit one cell over; the clamps take it back. Both bounds are at + // least 1 (checked above), so `clamp` can't panic, and the results are whole numbers in + // u16's range, so the casts are exact. + let row_limit = match fit { + Fit::Page => rows.min(max_rows), + Fit::Width => max_rows, + }; + let image_cols = (page_w * scale / cell.w).ceil().clamp(1.0, f32::from(cols)); + let image_rows = (page_h * scale / cell.h).ceil().clamp(1.0, f32::from(row_limit)); + Ok(Layout { scale, cols: image_cols as u16, rows: image_rows as u16 }) +} + +/// The cells between two pages side by side. +pub(crate) const TWO_UP_GAP: u16 = 2; + +/// How much of its size a page may give up to share the window with the next one. +pub(crate) const TWO_UP_SHARE: f32 = 0.8; + +/// The columns each of two pages side by side gets in a window `cols` wide. +pub(crate) fn two_up_cols(cols: u16) -> u16 { + cols.saturating_sub(TWO_UP_GAP) / 2 +} + +/// Whether two `page_w` × `page_h` pages go side by side in `cols` × `rows` cells: each, fitted +/// to its half, keeps at least [`TWO_UP_SHARE`] of the size it would have alone. Portrait pages +/// in a wide window do; anything in a tall window, and landscape pages, mostly don't. +pub(crate) fn two_up_fits(page_w: f32, page_h: f32, cols: u16, rows: u16, cell: Cell, max_rows: u16) -> bool { + match (layout(page_w, page_h, cols, rows, cell, Fit::Page, max_rows), layout(page_w, page_h, two_up_cols(cols), rows, cell, Fit::Page, max_rows)) + { + (Ok(alone), Ok(beside)) => beside.scale >= alone.scale * TWO_UP_SHARE, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + const LETTER: (f32, f32) = (612.0, 792.0); + const CELL: Cell = Cell { w: 10.0, h: 20.0 }; + + #[test] + fn a_portrait_page_fits_the_height_of_a_wide_window() { + let l = layout(LETTER.0, LETTER.1, 200, 50, CELL, Fit::Page, u16::MAX).unwrap(); + assert_eq!(l.scale, 1000.0 / 792.0); + assert_eq!((l.cols, l.rows), (78, 50)); + } + + #[test] + fn fit_width_fills_the_width_and_runs_past_the_height() { + let l = layout(LETTER.0, LETTER.1, 100, 30, CELL, Fit::Width, u16::MAX).unwrap(); + assert_eq!(l.scale, 1000.0 / 612.0); + assert_eq!((l.cols, l.rows), (100, 65)); + } + + #[test] + fn max_rows_and_max_pixels_cap_the_size() { + let l = layout(LETTER.0, LETTER.1, 100, 30, CELL, Fit::Width, 40).unwrap(); + assert_eq!(l.rows, 40); + assert!(l.cols < 100); + let huge = layout(LETTER.0, LETTER.1, 4000, 4000, CELL, Fit::Page, u16::MAX).unwrap(); + assert!(LETTER.1 * huge.scale <= MAX_PIXELS * 1.0001); + // A 1000 × 4000 pt page fitted to a 6000 px wide window: 8000 px tall is within the side + // cap, but 6000 × 24000 px is not within the area cap, which brings it to 2000 × 8000. + let strip = layout(1000.0, 4000.0, 600, 50, CELL, Fit::Width, u16::MAX).unwrap(); + let area = f64::from(1000.0 * strip.scale) * f64::from(4000.0 * strip.scale); + assert!(area <= MAX_AREA * 1.0001 && area > MAX_AREA * 0.99, "{area}"); + } + + #[test] + fn the_cell_comes_from_the_window() { + assert_eq!(Cell::from_window(100, 50, 1000, 1000), Some(Cell { w: 10.0, h: 20.0 })); + assert_eq!(Cell::from_window(100, 50, 0, 0), None); + assert_eq!(Cell::from_window(0, 0, 1000, 1000), None); + } + + #[test] + fn sizes_that_cannot_be_shown_are_errors() { + for (w, h) in [(0.0, 792.0), (-1.0, 792.0), (f32::NAN, 792.0), (612.0, f32::INFINITY)] { + assert!(layout(w, h, 80, 24, CELL, Fit::Page, u16::MAX).is_err(), "{w}×{h}"); + } + assert!(layout(612.0, 792.0, 0, 24, CELL, Fit::Page, u16::MAX).is_err()); + assert!(layout(612.0, 792.0, 80, 0, CELL, Fit::Page, u16::MAX).is_err()); + assert!(layout(612.0, 792.0, 80, 24, CELL, Fit::Page, 0).is_err()); + assert!(layout(612.0, 792.0, 80, 24, Cell { w: 0.0, h: 20.0 }, Fit::Page, u16::MAX).is_err()); + // A vanishingly small page needs an infinite scale. + assert!(layout(1e-38, 1e-38, 80, 24, CELL, Fit::Page, u16::MAX).is_err()); + // A huge one gets a tiny scale: the square page fills the 480 px height. + let l = layout(f32::MAX, f32::MAX, 80, 24, CELL, Fit::Page, u16::MAX).unwrap(); + assert_eq!(l.rows, 24); + assert!((48..=49).contains(&l.cols), "{l:?}"); + } + + #[test] + fn two_portrait_pages_share_a_wide_window() { + // 200 × 50 cells of 10 × 20 px: 2000 × 1000 px. A Letter page is as tall alone as beside + // another. + assert!(two_up_fits(LETTER.0, LETTER.1, 200, 50, CELL, u16::MAX)); + // 80 × 24 (800 × 480 px) is wider than tall, too. + assert!(two_up_fits(LETTER.0, LETTER.1, 80, 23, CELL, u16::MAX)); + // A tall window: beside another, a page would be half the size. + assert!(!two_up_fits(LETTER.0, LETTER.1, 80, 60, CELL, u16::MAX)); + // Landscape pages fill a wide window's width on their own. + assert!(!two_up_fits(LETTER.1, LETTER.0, 200, 50, CELL, u16::MAX)); + // Nothing to split, or nothing to show. + assert!(!two_up_fits(LETTER.0, LETTER.1, 2, 50, CELL, u16::MAX)); + assert!(!two_up_fits(0.0, LETTER.1, 200, 50, CELL, u16::MAX)); + assert_eq!((two_up_cols(200), two_up_cols(1), two_up_cols(0)), (99, 0, 0)); + } + + fn fit() -> impl Strategy { + prop_oneof![Just(Fit::Page), Just(Fit::Width)] + } + + proptest! { + /// Within one cell of the page's aspect ratio, inside the window (or its width, for + /// `width`), never past `max_rows` or `MAX_PIXELS`. + #[test] + fn a_layout_fits(w in 1f32..20_000.0, h in 1f32..20_000.0, cols in 1u16..500, rows in 1u16..300, + cw in 2f32..40.0, ch in 4f32..80.0, max_rows in 1u16..400, fit in fit()) { + let cell = Cell { w: cw, h: ch }; + let l = layout(w, h, cols, rows, cell, fit, max_rows).unwrap(); + let (px_w, px_h) = (w * l.scale, h * l.scale); + prop_assert!(px_w <= MAX_PIXELS * 1.001 && px_h <= MAX_PIXELS * 1.001); + prop_assert!(f64::from(px_w) * f64::from(px_h) <= MAX_AREA * 1.001); + prop_assert!(l.cols >= 1 && l.cols <= cols && l.rows >= 1 && l.rows <= max_rows); + if fit == Fit::Page { + prop_assert!(l.rows <= rows); + } + // The image covers its cells, and at most one more cell than it needs each way. + let slack = 1.001; + prop_assert!(px_w <= f32::from(l.cols) * cw * slack, "{px_w} > {} cols", l.cols); + prop_assert!(px_h <= f32::from(l.rows) * ch * slack, "{px_h} > {} rows", l.rows); + prop_assert!(l.cols == 1 || px_w * slack > f32::from(l.cols - 1) * cw); + prop_assert!(l.rows == 1 || px_h * slack > f32::from(l.rows - 1) * ch); + } + + #[test] + fn any_numbers_are_an_answer(w in any::(), h in any::(), cols in any::(), rows in any::(), + cw in any::(), ch in any::(), max_rows in any::(), fit in fit()) { + if let Ok(l) = layout(w, h, cols, rows, Cell { w: cw, h: ch }, fit, max_rows) { + prop_assert!(l.scale.is_finite() && l.scale > 0.0); + prop_assert!(l.cols >= 1 && l.cols <= cols && l.rows >= 1 && l.rows <= max_rows); + } + } + } +} diff --git a/apps/pdfcraft-cli/src/view/keys.rs b/apps/pdfcraft-cli/src/view/keys.rs new file mode 100644 index 0000000000..e00dbf5729 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/keys.rs @@ -0,0 +1,216 @@ +//! Keys from a terminal in raw mode: bytes in, keys out. Pure, and total over any bytes. +//! +//! The terminal's answers to graphics commands arrive among the keys, as `ESC _ G … ESC \` +//! (the kitty graphics protocol). They are never keys: one that says an image is missing is +//! [`Key::Missing`], and the rest are dropped. + +/// A key the pager knows. Anything else is dropped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Key { + Char(char), + Enter, + Escape, + Backspace, + /// Ctrl-C, which raw mode delivers as a byte instead of a signal. + Interrupt, + Up, + Down, + Left, + Right, + PageUp, + PageDown, + Home, + End, + /// Not typed: the terminal's answer that image `id`, which it was asked to place, isn't there + /// (it drops images to stay under its storage quota). + Missing(u32), +} + +/// The longest escape sequence worth waiting for; longer ones are dropped. +const MAX_SEQUENCE: usize = 16; +/// The longest graphics answer worth waiting for. kitty's are under 200 bytes. +pub(crate) const MAX_ANSWER: usize = 512; +/// How a graphics answer starts. +const ANSWER: &[u8] = b"\x1b_G"; + +/// Decode `bytes` into keys, and say how many bytes were used. An escape sequence cut off at the +/// end is left for the next read, unless `flush`: then a lone ESC is the Escape key (the user +/// pressed Esc and nothing followed). A graphics answer cut off always waits for the rest, so +/// none of it is ever taken for keys. +pub(crate) fn decode(bytes: &[u8], flush: bool) -> (Vec, usize) { + let mut keys = Vec::new(); + let mut i = 0usize; + while let Some(&b) = bytes.get(i) { + let rest = bytes.get(i..).unwrap_or_default(); + let (key, used) = match b { + 0x1b if rest.starts_with(ANSWER) => match answer(rest) { + Some(found) => found, + None => break, + }, + // The start of what may be an answer waits too, unless nothing more came. + 0x1b if ANSWER.starts_with(rest) && !flush => break, + 0x1b => match escape(rest) { + Some(found) => found, + None if flush => (Some(Key::Escape), 1), + None => break, + }, + b'\r' | b'\n' => (Some(Key::Enter), 1), + 0x7f | 0x08 => (Some(Key::Backspace), 1), + 0x03 => (Some(Key::Interrupt), 1), + // Printable ASCII; the pager's keys are all ASCII, so other bytes, UTF-8 included, are + // dropped one at a time. + 0x20..=0x7e => (Some(Key::Char(char::from(b))), 1), + 0x0c => (Some(Key::Char('\u{c}')), 1), + _ => (None, 1), + }; + keys.extend(key); + i = i.saturating_add(used); + } + (keys, i) +} + +/// The key an escape sequence at the start of `bytes` (which starts with ESC) stands for, and +/// its length. `None` when it is cut off. +fn escape(bytes: &[u8]) -> Option<(Option, usize)> { + match bytes.get(1) { + None => None, + // CSI: parameters and intermediates, then a final byte in 0x40..=0x7e. + Some(b'[') => { + let body = bytes.get(2..).unwrap_or_default(); + let Some(end) = body.iter().position(|b| (0x40..=0x7e).contains(b)) else { + return if body.len() >= MAX_SEQUENCE { Some((None, bytes.len())) } else { None }; + }; + let params = body.get(..end).unwrap_or_default(); + let key = match (params, body.get(end)) { + (b"" | b"1", Some(b'A')) => Some(Key::Up), + (b"" | b"1", Some(b'B')) => Some(Key::Down), + (b"" | b"1", Some(b'C')) => Some(Key::Right), + (b"" | b"1", Some(b'D')) => Some(Key::Left), + (b"" | b"1", Some(b'H')) | (b"1" | b"7", Some(b'~')) => Some(Key::Home), + (b"" | b"1", Some(b'F')) | (b"4" | b"8", Some(b'~')) => Some(Key::End), + (b"5", Some(b'~')) => Some(Key::PageUp), + (b"6", Some(b'~')) => Some(Key::PageDown), + _ => None, + }; + Some((key, end.saturating_add(3))) + } + // SS3, as some terminals send arrows, Home and End. + Some(b'O') => { + let key = match bytes.get(2)? { + b'A' => Some(Key::Up), + b'B' => Some(Key::Down), + b'C' => Some(Key::Right), + b'D' => Some(Key::Left), + b'H' => Some(Key::Home), + b'F' => Some(Key::End), + _ => None, + }; + Some((key, 3)) + } + // A string terminator on its own: the end of something cut off, never a key. + Some(b'\\') => Some((None, 2)), + // Esc, then another key typed quickly enough to arrive in the same read. + Some(_) => Some((Some(Key::Escape), 1)), + } +} + +/// The graphics answer at the start of `bytes` (which starts with [`ANSWER`]) and its length, +/// through its `ESC \`: [`Key::Missing`] when it says an image isn't there, nothing otherwise. +/// `None` while it is cut off; one longer than [`MAX_ANSWER`] is dropped whole. +fn answer(bytes: &[u8]) -> Option<(Option, usize)> { + let body = bytes.get(ANSWER.len()..).unwrap_or_default(); + let Some(end) = body.windows(2).position(|w| w == b"\x1b\\") else { + return (bytes.len() > MAX_ANSWER).then_some((None, bytes.len())); + }; + let used = ANSWER.len().saturating_add(end).saturating_add(2); + // `i=,…;ENOENT:`, as kitty answers a placement of an image it doesn't have. + let text = std::str::from_utf8(body.get(..end).unwrap_or_default()).unwrap_or_default(); + let (control, message) = text.split_once(';').unwrap_or((text, "")); + let id = control.split(',').find_map(|kv| kv.strip_prefix("i=")?.parse::().ok()); + Some((id.filter(|_| message.starts_with("ENOENT")).map(Key::Missing), used)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(bytes: &[u8]) -> Vec { + let (keys, used) = decode(bytes, true); + assert_eq!(used, bytes.len()); + keys + } + + #[test] + fn plain_keys() { + assert_eq!( + keys(b" bq7G\r\n\x7f\x03"), + [Key::Char(' '), Key::Char('b'), Key::Char('q'), Key::Char('7'), Key::Char('G'), Key::Enter, Key::Enter, Key::Backspace, Key::Interrupt] + ); + } + + #[test] + fn arrows_pages_home_and_end() { + assert_eq!(keys(b"\x1b[A\x1b[B\x1b[C\x1b[D\x1bOA\x1bOD"), [Key::Up, Key::Down, Key::Right, Key::Left, Key::Up, Key::Left]); + assert_eq!(keys(b"\x1b[5~\x1b[6~"), [Key::PageUp, Key::PageDown]); + assert_eq!(keys(b"\x1b[H\x1b[1~\x1b[7~\x1bOH"), [Key::Home; 4]); + assert_eq!(keys(b"\x1b[F\x1b[4~\x1b[8~\x1bOF"), [Key::End; 4]); + } + + #[test] + fn unknown_sequences_and_bytes_are_dropped() { + // F5, Shift+Up, a mouse report, UTF-8 "é", a NUL. + assert_eq!(keys(b"\x1b[15~\x1b[1;2A\x1b[<0;3;4M\xc3\xa9\0j"), [Key::Char('j')]); + } + + #[test] + fn a_sequence_cut_off_waits_for_the_next_read() { + assert_eq!(decode(b"j\x1b", false), (vec![Key::Char('j')], 1)); + assert_eq!(decode(b"j\x1b[", false), (vec![Key::Char('j')], 1)); + assert_eq!(decode(b"\x1b[6", false), (vec![], 0)); + assert_eq!(decode(b"\x1bO", false), (vec![], 0)); + // Nothing followed: it was the Esc key. + assert_eq!(decode(b"\x1b", true), (vec![Key::Escape], 1)); + // Esc typed before another key. + assert_eq!(keys(b"\x1bq"), [Key::Escape, Key::Char('q')]); + } + + #[test] + fn graphics_answers_are_never_keys() { + // kitty's answer to placing an image it dropped, between two keys. + let missing = b"j\x1b_Gi=3000,p=1;ENOENT:Put command refers to non-existent image with id: 3000 and number: 0\x1b\\k"; + assert_eq!(keys(missing), [Key::Char('j'), Key::Missing(3000), Key::Char('k')]); + // Any other answer is dropped, an OK or another error alike: its ESC isn't Esc, nor its + // letters keys (`q` would quit). + assert_eq!(keys(b"\x1b_Gi=7;OK\x1b\\\x1b_Gi=7;EINVAL:quit\x1b\\\x1b_G;ENOENT:no id\x1b\\"), []); + assert_eq!(keys(b"\x1b_Gi=x;ENOENT:\x1b\\\x1b\\"), []); + // Cut off anywhere, it waits for the rest even when nothing more came yet. + let answer = &missing[1..missing.len() - 1]; + for end in ANSWER.len()..answer.len() { + assert_eq!(decode(&answer[..end], true), (vec![], 0), "cut at {end}"); + } + // Too long to be an answer: dropped whole. + let long = [b"\x1b_Gi=1;".as_slice(), &[b'q'; MAX_ANSWER]].concat(); + assert_eq!(decode(&long, false), (vec![], long.len())); + // Its first bytes wait for the rest; with nothing more, they were Esc and _ typed together. + assert_eq!(decode(b"j\x1b_", false), (vec![Key::Char('j')], 1)); + assert_eq!(keys(b"\x1b_"), [Key::Escape, Key::Char('_')]); + } + + #[test] + fn an_endless_sequence_is_dropped() { + let long = [b"\x1b[".as_slice(), &[b'1'; 40]].concat(); + assert_eq!(decode(&long, false), (vec![], long.len())); + } + + proptest::proptest! { + #[test] + fn any_bytes_decode(bytes in proptest::collection::vec(proptest::prelude::any::(), 0..200), flush in proptest::prelude::any::()) { + let (_, used) = decode(&bytes, flush); + proptest::prop_assert!(used <= bytes.len()); + if flush { + // All of it, but for a graphics answer still arriving. + proptest::prop_assert!(used == bytes.len() || bytes[used..].starts_with(ANSWER)); + } + } + } +} diff --git a/apps/pdfcraft-cli/src/view/kitty.rs b/apps/pdfcraft-cli/src/view/kitty.rs new file mode 100644 index 0000000000..0cc79a8fe3 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/kitty.rs @@ -0,0 +1,477 @@ +//! Kitty graphics protocol escape codes (). +//! +//! Pure: every function appends bytes to a buffer and the caller writes them out. Images go as +//! PNG (`f=100`) inside the escape codes themselves (`t=d`), which works over SSH too, or, for a +//! terminal on this machine, as raw pixels in a temporary file whose path the escape code names +//! (`t=t`). The terminal is told not to answer (`q=2`), so nothing lands on the user's command +//! line; only the pager's placements have errors answered (`q=1`), which the pager reads among +//! the keys. + +use std::os::unix::ffi::OsStrExt as _; +use std::path::Path; + +use base64::Engine as _; + +/// The largest base64 payload one escape code may carry. Every chunk but the last must be a +/// multiple of 4 bytes long, which this is. +const CHUNK: usize = 4096; + +/// Where the escape codes go. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Wrap { + /// Straight to the terminal. + None, + /// Through tmux: each code inside tmux's passthrough sequence, `ESC P tmux; … ESC \`, with + /// every ESC in it doubled (tmux FAQ). tmux passes it on when the pane allows passthrough. + Tmux, +} + +/// Transmit a PNG and show it at the cursor over `cols` × `rows` cells, in one go (`a=T`). The +/// cursor ends after the image. The image gets no id: nothing refers to it again. +pub(crate) fn show_png(out: &mut Vec, png: &[u8], cols: u16, rows: u16) { + chunked(out, &format!("a=T,f=100,t=d,q=2,c={cols},r={rows}"), png, Wrap::None); +} + +/// Transmit a PNG as image `id` without showing it (`a=t`); placements show it. Transmitting an +/// id again replaces the image and deletes its placements. +pub(crate) fn transmit(out: &mut Vec, id: u32, png: &[u8], wrap: Wrap) { + chunked(out, &format!("a=t,f=100,t=d,q=2,i={id}"), png, wrap); +} + +/// Transmit image `id` from a temporary file of `width` × `height` straight RGBA pixels (`t=t`, +/// `f=32`), without showing it: the terminal reads the file, then deletes it. Only the path goes +/// in the escape code. For a terminal on this machine (`files.rs`). +pub(crate) fn transmit_file(out: &mut Vec, id: u32, path: &Path, width: u32, height: u32, wrap: Wrap) { + let path = base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_bytes()); + command(out, &format!("a=t,f=32,t=t,s={width},v={height},q=2,i={id}"), path.as_bytes(), wrap); +} + +/// A rectangle of an image's pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Rect { + pub(crate) x: u32, + pub(crate) y: u32, + pub(crate) w: u32, + pub(crate) h: u32, +} + +/// Show image `id` at the cursor over `cols` × `rows` cells, as its placement `placement`: all of +/// it, or the `src` rectangle of its pixels. Placing it again under the same placement moves it +/// instead of adding another; under two, it shows twice. The cursor stays where it is (`C=1`), so +/// drawing at the bottom never scrolls the screen. Errors are answered (`q=1`): the terminal says +/// when it no longer has the image ([`super::keys::Key::Missing`]). +pub(crate) fn place(out: &mut Vec, id: u32, placement: u32, cols: u16, rows: u16, src: Option, wrap: Wrap) { + let mut keys = format!("a=p,q=1,i={id},p={placement},C=1,c={cols},r={rows}"); + if let Some(r) = src { + keys.push_str(&format!(",x={},y={},w={},h={}", r.x, r.y, r.w, r.h)); + } + command(out, &keys, b"", wrap); +} + +/// Delete image `id` and free its data (`d=I`). +pub(crate) fn delete(out: &mut Vec, id: u32, wrap: Wrap) { + command(out, &format!("a=d,d=I,q=2,i={id}"), b"", wrap); +} + +/// Take placement `placement` of image `id` off the screen but keep the image's data (`d=i`), so +/// placing it again needs no new transmission. +pub(crate) fn hide(out: &mut Vec, id: u32, placement: u32, wrap: Wrap) { + command(out, &format!("a=d,d=i,q=2,i={id},p={placement}"), b"", wrap); +} + +/// A virtual placement of image `id` over `cols` × `rows` cells (`U=1`): it shows wherever +/// [`placeholder_row`] cells for it are drawn. +pub(crate) fn place_virtual(out: &mut Vec, id: u32, cols: u16, rows: u16, wrap: Wrap) { + command(out, &format!("a=p,U=1,q=2,i={id},c={cols},r={rows}"), b"", wrap); +} + +/// The character every placeholder cell holds. +const PLACEHOLDER: char = '\u{10EEEE}'; + +/// Row and column numbers as combining marks: entry n stands for n. This is the protocol's +/// `rowcolumn-diacritics.txt`: the combining marks of class 230 in Unicode 6.0.0 that have no +/// decomposition mapping, less a few that normalization may fuse with the character before them. +#[rustfmt::skip] +const DIACRITICS: [char; 297] = [ + '\u{0305}', '\u{030D}', '\u{030E}', '\u{0310}', '\u{0312}', '\u{033D}', '\u{033E}', '\u{033F}', '\u{0346}', '\u{034A}', + '\u{034B}', '\u{034C}', '\u{0350}', '\u{0351}', '\u{0352}', '\u{0357}', '\u{035B}', '\u{0363}', '\u{0364}', '\u{0365}', + '\u{0366}', '\u{0367}', '\u{0368}', '\u{0369}', '\u{036A}', '\u{036B}', '\u{036C}', '\u{036D}', '\u{036E}', '\u{036F}', + '\u{0483}', '\u{0484}', '\u{0485}', '\u{0486}', '\u{0487}', '\u{0592}', '\u{0593}', '\u{0594}', '\u{0595}', '\u{0597}', + '\u{0598}', '\u{0599}', '\u{059C}', '\u{059D}', '\u{059E}', '\u{059F}', '\u{05A0}', '\u{05A1}', '\u{05A8}', '\u{05A9}', + '\u{05AB}', '\u{05AC}', '\u{05AF}', '\u{05C4}', '\u{0610}', '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{0615}', + '\u{0616}', '\u{0617}', '\u{0657}', '\u{0658}', '\u{0659}', '\u{065A}', '\u{065B}', '\u{065D}', '\u{065E}', '\u{06D6}', + '\u{06D7}', '\u{06D8}', '\u{06D9}', '\u{06DA}', '\u{06DB}', '\u{06DC}', '\u{06DF}', '\u{06E0}', '\u{06E1}', '\u{06E2}', + '\u{06E4}', '\u{06E7}', '\u{06E8}', '\u{06EB}', '\u{06EC}', '\u{0730}', '\u{0732}', '\u{0733}', '\u{0735}', '\u{0736}', + '\u{073A}', '\u{073D}', '\u{073F}', '\u{0740}', '\u{0741}', '\u{0743}', '\u{0745}', '\u{0747}', '\u{0749}', '\u{074A}', + '\u{07EB}', '\u{07EC}', '\u{07ED}', '\u{07EE}', '\u{07EF}', '\u{07F0}', '\u{07F1}', '\u{07F3}', '\u{0816}', '\u{0817}', + '\u{0818}', '\u{0819}', '\u{081B}', '\u{081C}', '\u{081D}', '\u{081E}', '\u{081F}', '\u{0820}', '\u{0821}', '\u{0822}', + '\u{0823}', '\u{0825}', '\u{0826}', '\u{0827}', '\u{0829}', '\u{082A}', '\u{082B}', '\u{082C}', '\u{082D}', '\u{0951}', + '\u{0953}', '\u{0954}', '\u{0F82}', '\u{0F83}', '\u{0F86}', '\u{0F87}', '\u{135D}', '\u{135E}', '\u{135F}', '\u{17DD}', + '\u{193A}', '\u{1A17}', '\u{1A75}', '\u{1A76}', '\u{1A77}', '\u{1A78}', '\u{1A79}', '\u{1A7A}', '\u{1A7B}', '\u{1A7C}', + '\u{1B6B}', '\u{1B6D}', '\u{1B6E}', '\u{1B6F}', '\u{1B70}', '\u{1B71}', '\u{1B72}', '\u{1B73}', '\u{1CD0}', '\u{1CD1}', + '\u{1CD2}', '\u{1CDA}', '\u{1CDB}', '\u{1CE0}', '\u{1DC0}', '\u{1DC1}', '\u{1DC3}', '\u{1DC4}', '\u{1DC5}', '\u{1DC6}', + '\u{1DC7}', '\u{1DC8}', '\u{1DC9}', '\u{1DCB}', '\u{1DCC}', '\u{1DD1}', '\u{1DD2}', '\u{1DD3}', '\u{1DD4}', '\u{1DD5}', + '\u{1DD6}', '\u{1DD7}', '\u{1DD8}', '\u{1DD9}', '\u{1DDA}', '\u{1DDB}', '\u{1DDC}', '\u{1DDD}', '\u{1DDE}', '\u{1DDF}', + '\u{1DE0}', '\u{1DE1}', '\u{1DE2}', '\u{1DE3}', '\u{1DE4}', '\u{1DE5}', '\u{1DE6}', '\u{1DFE}', '\u{20D0}', '\u{20D1}', + '\u{20D4}', '\u{20D5}', '\u{20D6}', '\u{20D7}', '\u{20DB}', '\u{20DC}', '\u{20E1}', '\u{20E7}', '\u{20E9}', '\u{20F0}', + '\u{2CEF}', '\u{2CF0}', '\u{2CF1}', '\u{2DE0}', '\u{2DE1}', '\u{2DE2}', '\u{2DE3}', '\u{2DE4}', '\u{2DE5}', '\u{2DE6}', + '\u{2DE7}', '\u{2DE8}', '\u{2DE9}', '\u{2DEA}', '\u{2DEB}', '\u{2DEC}', '\u{2DED}', '\u{2DEE}', '\u{2DEF}', '\u{2DF0}', + '\u{2DF1}', '\u{2DF2}', '\u{2DF3}', '\u{2DF4}', '\u{2DF5}', '\u{2DF6}', '\u{2DF7}', '\u{2DF8}', '\u{2DF9}', '\u{2DFA}', + '\u{2DFB}', '\u{2DFC}', '\u{2DFD}', '\u{2DFE}', '\u{2DFF}', '\u{A66F}', '\u{A67C}', '\u{A67D}', '\u{A6F0}', '\u{A6F1}', + '\u{A8E0}', '\u{A8E1}', '\u{A8E2}', '\u{A8E3}', '\u{A8E4}', '\u{A8E5}', '\u{A8E6}', '\u{A8E7}', '\u{A8E8}', '\u{A8E9}', + '\u{A8EA}', '\u{A8EB}', '\u{A8EC}', '\u{A8ED}', '\u{A8EE}', '\u{A8EF}', '\u{A8F0}', '\u{A8F1}', '\u{AAB0}', '\u{AAB2}', + '\u{AAB3}', '\u{AAB7}', '\u{AAB8}', '\u{AABE}', '\u{AABF}', '\u{AAC1}', '\u{FE20}', '\u{FE21}', '\u{FE22}', '\u{FE23}', + '\u{FE24}', '\u{FE25}', '\u{FE26}', '\u{10A0F}', '\u{10A38}', '\u{1D185}', '\u{1D186}', '\u{1D187}', '\u{1D188}', '\u{1D189}', + '\u{1D1AA}', '\u{1D1AB}', '\u{1D1AC}', '\u{1D1AD}', '\u{1D242}', '\u{1D243}', '\u{1D244}', +]; + +/// The most rows or columns placeholder cells can number. +pub(crate) const MAX_CELLS: u16 = 297; +const _: () = assert!(DIACRITICS.len() == MAX_CELLS as usize); + +/// How placeholder cells name their image: the foreground colour carries the id's low bits, and +/// a third diacritic its high byte. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IdColor { + /// 24-bit colour: the low 24 bits. + Rgb, + /// The 256-colour palette, where 24-bit colours would arrive changed: the low 8 bits. + Indexed, +} + +impl IdColor { + /// The largest value the colour itself carries; colour 0 is never used. + fn max_low(self) -> u32 { + match self { + Self::Rgb => 0x00FF_FFFF, + Self::Indexed => 0xFF, + } + } + + /// The `index`th id this colour can name, wrapping after the last: every low value from 1 up, + /// under each high byte in turn. 65,280 ids in the palette, 4,278,190,080 in 24-bit colour. + pub(crate) fn id(self, index: u64) -> u32 { + let low = u64::from(self.max_low()); + let index = index % (low * 256); + // `index / low` is below 256 and `index % low + 1` at most `low`, so the id fits a u32. + u32::try_from((index / low) << 24 | (index % low + 1)).unwrap_or(1) + } +} + +/// Row `row` of image `id`'s placeholder cells, `cols` of them, coloured to name the image. +/// `false`, with nothing written, when `row` or `cols` is past [`MAX_CELLS`]. Ids that `color` +/// can't carry ([`IdColor::id`] makes the ones it can) lose bits. +/// +/// Only the first cell carries diacritics (row, column 0, and the id's high byte when it has +/// one). The protocol lets the others go bare: a placeholder with none, in the same colour as the +/// cell to its left, takes that cell's row and high byte and the next column. That makes a row +/// about a third the size, which matters inside tmux: it throws output away when the terminal +/// falls behind, and a screenful of fully marked cells was enough to make it do so. +#[must_use] +pub(crate) fn placeholder_row(out: &mut Vec, id: u32, color: IdColor, row: u16, cols: u16) -> bool { + let Some(&row_mark) = DIACRITICS.get(usize::from(row)) else { return false }; + if usize::from(cols) > DIACRITICS.len() { + return false; + } + // The high byte goes in a third diacritic, which only ids above 24 bits need. + let high = DIACRITICS.get(usize::try_from(id >> 24).unwrap_or(0)).copied().filter(|_| id >> 24 != 0); + let colour = match color { + IdColor::Rgb => format!("\x1b[38;2;{};{};{}m", (id >> 16) & 0xff, (id >> 8) & 0xff, id & 0xff), + IdColor::Indexed => format!("\x1b[38;5;{}m", id & 0xff), + }; + out.extend_from_slice(colour.as_bytes()); + let mut utf8 = [0u8; 4]; + if cols > 0 { + for c in [PLACEHOLDER, row_mark, DIACRITICS[0]].into_iter().chain(high) { + out.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes()); + } + } + for _ in 1..cols { + out.extend_from_slice(PLACEHOLDER.encode_utf8(&mut utf8).as_bytes()); + } + out.extend_from_slice(b"\x1b[39m"); + true +} + +/// The bytes that base64 to exactly one full chunk. +const RAW_CHUNK: usize = CHUNK / 4 * 3; + +/// Send `data` in base64 as one escape code per chunk: `m=1` on all but the last, `m=0` on the +/// last. Only the first carries `keys`; the protocol allows later chunks only `m` and `q`. Each +/// chunk is encoded as it goes, so no copy of the whole payload is ever held. +fn chunked(out: &mut Vec, keys: &str, data: &[u8], wrap: Wrap) { + let mut chunks = data.chunks(RAW_CHUNK).peekable(); + if chunks.peek().is_none() { + command(out, keys, b"", wrap); + return; + } + let mut first = true; + let mut encoded = String::with_capacity(CHUNK); + while let Some(chunk) = chunks.next() { + encoded.clear(); + base64::engine::general_purpose::STANDARD.encode_string(chunk, &mut encoded); + let more = u8::from(chunks.peek().is_some()); + let control = if first { format!("{keys},m={more}") } else { format!("q=2,m={more}") }; + command(out, &control, encoded.as_bytes(), wrap); + first = false; + } +} + +/// One escape code: `ESC _G ; ESC \`, wrapped for tmux when asked. Each +/// chunk gets a wrapper of its own, which keeps every passthrough sequence small. +fn command(out: &mut Vec, control: &str, payload: &[u8], wrap: Wrap) { + let mut code = Vec::with_capacity(control.len().saturating_add(payload.len()).saturating_add(6)); + code.extend_from_slice(b"\x1b_G"); + code.extend_from_slice(control.as_bytes()); + if !payload.is_empty() { + code.push(b';'); + code.extend_from_slice(payload); + } + code.extend_from_slice(b"\x1b\\"); + match wrap { + Wrap::None => out.extend_from_slice(&code), + Wrap::Tmux => { + out.extend_from_slice(b"\x1bPtmux;"); + for b in code { + if b == 0x1b { + out.push(0x1b); + } + out.push(b); + } + out.extend_from_slice(b"\x1b\\"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The escape codes in `bytes`, as (control, payload) pairs. Panics on anything else. + fn codes(bytes: &[u8]) -> Vec<(String, Vec)> { + let text = std::str::from_utf8(bytes).unwrap(); + let mut out = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + let body = rest.strip_prefix("\x1b_G").unwrap_or_else(|| panic!("not an escape code: {rest:?}")); + let end = body.find("\x1b\\").unwrap(); + let (control, payload) = body[..end].split_once(';').unwrap_or((&body[..end], "")); + out.push((control.to_string(), payload.as_bytes().to_vec())); + rest = &body[end + 2..]; + } + out + } + + fn decoded(codes: &[(String, Vec)]) -> Vec { + let payload: Vec = codes.iter().flat_map(|(_, p)| p.clone()).collect(); + base64::engine::general_purpose::STANDARD.decode(payload).unwrap() + } + + #[test] + fn a_payload_that_fills_one_chunk_is_one_code() { + // 3072 bytes are exactly 4096 base64 characters. + let data = vec![7u8; 3072]; + let mut out = Vec::new(); + show_png(&mut out, &data, 40, 20); + let codes = codes(&out); + assert_eq!(codes.len(), 1); + assert_eq!(codes[0].0, "a=T,f=100,t=d,q=2,c=40,r=20,m=0"); + assert_eq!(codes[0].1.len(), 4096); + assert_eq!(decoded(&codes), data); + } + + #[test] + fn a_longer_payload_continues_with_only_m_and_q() { + // 3073 bytes are 4100 base64 characters: a full chunk, then 4. + let data: Vec = (0..3073u32).map(|i| (i % 251) as u8).collect(); + let mut out = Vec::new(); + show_png(&mut out, &data, 3, 2); + let codes = codes(&out); + assert_eq!(codes.iter().map(|(c, _)| c.as_str()).collect::>(), ["a=T,f=100,t=d,q=2,c=3,r=2,m=1", "q=2,m=0"]); + assert_eq!(codes.iter().map(|(_, p)| p.len()).collect::>(), [4096, 4]); + assert_eq!(decoded(&codes), data); + } + + #[test] + fn every_chunk_but_the_last_is_a_multiple_of_four() { + for len in [1, 2, 3, 4, 3071, 3072, 3073, 9000, 12_288, 12_289] { + let data = vec![1u8; len]; + let mut out = Vec::new(); + show_png(&mut out, &data, 1, 1); + let codes = codes(&out); + let (last, rest) = codes.split_last().unwrap(); + assert!(last.0.ends_with("m=0"), "{len}: {}", last.0); + for (control, payload) in rest { + assert!(control.ends_with("m=1"), "{len}: {control}"); + assert_eq!(payload.len(), CHUNK, "{len}"); + } + assert_eq!(decoded(&codes), data, "{len}"); + } + } + + #[test] + fn a_file_transmission_carries_only_its_path() { + let path = std::path::Path::new("/tmp/tty-graphics-protocol-pdfcraft-7-0"); + let mut out = Vec::new(); + transmit_file(&mut out, 42, path, 1530, 1980, Wrap::None); + let codes = codes(&out); + assert_eq!(codes.len(), 1); + assert_eq!(codes[0].0, "a=t,f=32,t=t,s=1530,v=1980,q=2,i=42"); + assert_eq!(decoded(&codes), path.as_os_str().as_bytes()); + // Through tmux, the same code wrapped. + let mut tmux = Vec::new(); + transmit_file(&mut tmux, 42, path, 1530, 1980, Wrap::Tmux); + assert_eq!(unwrap_tmux(&tmux), out); + } + + #[test] + fn an_empty_payload_still_sends_its_keys_once() { + let mut out = Vec::new(); + show_png(&mut out, b"", 1, 1); + assert_eq!(out, b"\x1b_Ga=T,f=100,t=d,q=2,c=1,r=1\x1b\\"); + } + + /// The bytes inside tmux passthrough sequences, unwrapped. Panics on anything else. + fn unwrap_tmux(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut rest = bytes; + while !rest.is_empty() { + rest = rest.strip_prefix(b"\x1bPtmux;").expect("a passthrough sequence"); + let mut i = 0; + loop { + match (rest[i], rest.get(i + 1)) { + (0x1b, Some(0x1b)) => { + out.push(0x1b); + i += 2; + } + (0x1b, Some(b'\\')) => { + i += 2; + break; + } + (0x1b, _) => panic!("a lone ESC inside a passthrough sequence"), + (b, _) => { + out.push(b); + i += 1; + } + } + } + rest = &rest[i..]; + } + out + } + + #[test] + fn tmux_gets_each_code_wrapped_with_its_escapes_doubled() { + let png = vec![9u8; 5000]; + let (mut plain, mut wrapped) = (Vec::new(), Vec::new()); + transmit(&mut plain, 7, &png, Wrap::None); + transmit(&mut wrapped, 7, &png, Wrap::Tmux); + assert_eq!(unwrap_tmux(&wrapped), plain); + // 5000 bytes are two chunks, each in a wrapper of its own. + assert_eq!(wrapped.windows(7).filter(|w| w == b"\x1bPtmux;").count(), 2); + let codes = codes(&plain); + assert_eq!(codes[0].0, "a=t,f=100,t=d,q=2,i=7,m=1"); + assert_eq!(decoded(&codes), png); + } + + #[test] + fn a_placement_shows_all_or_part_of_an_image_without_moving_the_cursor() { + let mut out = Vec::new(); + place(&mut out, 9, 1, 40, 20, None, Wrap::None); + assert_eq!(out, b"\x1b_Ga=p,q=1,i=9,p=1,C=1,c=40,r=20\x1b\\"); + out.clear(); + place(&mut out, 9, 2, 40, 10, Some(Rect { x: 0, y: 300, w: 800, h: 400 }), Wrap::None); + assert_eq!(out, b"\x1b_Ga=p,q=1,i=9,p=2,C=1,c=40,r=10,x=0,y=300,w=800,h=400\x1b\\"); + out.clear(); + delete(&mut out, 9, Wrap::Tmux); + assert_eq!(out, b"\x1bPtmux;\x1b\x1b_Ga=d,d=I,q=2,i=9\x1b\x1b\\\x1b\\"); + out.clear(); + // Lowercase: the placement goes, the data stays for the next time. + hide(&mut out, 9, 2, Wrap::None); + assert_eq!(out, b"\x1b_Ga=d,d=i,q=2,i=9,p=2\x1b\\"); + } + + #[test] + fn a_virtual_placement_names_its_image_and_size() { + let mut out = Vec::new(); + place_virtual(&mut out, 42, 30, 12, Wrap::None); + assert_eq!(out, b"\x1b_Ga=p,U=1,q=2,i=42,c=30,r=12\x1b\\"); + } + + #[test] + fn placeholder_rows_are_the_specification_example_with_what_can_be_inferred_left_out() { + // The protocol's 2 × 2 example for image 42, in the 256-colour palette, marks every cell: + // row 0 is 10EEEE 0305 0305, 10EEEE 0305 030D. The second cell's marks say what the + // protocol infers for a bare cell after one in the same colour (same row, next column), + // so it goes without. + let row = |r| { + let mut out = Vec::new(); + assert!(placeholder_row(&mut out, 42, IdColor::Indexed, r, 2)); + String::from_utf8(out).unwrap() + }; + assert_eq!(row(0), "\x1b[38;5;42m\u{10EEEE}\u{0305}\u{0305}\u{10EEEE}\x1b[39m"); + assert_eq!(row(1), "\x1b[38;5;42m\u{10EEEE}\u{030D}\u{0305}\u{10EEEE}\x1b[39m"); + // A wide row stays small: 4 bytes a cell after the first. + let mut wide = Vec::new(); + assert!(placeholder_row(&mut wide, 42, IdColor::Indexed, 3, 200)); + assert_eq!(wide.len(), "\x1b[38;5;42m".len() + 4 + 2 + 2 + 199 * 4 + "\x1b[39m".len()); + let mut none = Vec::new(); + assert!(placeholder_row(&mut none, 42, IdColor::Indexed, 0, 0)); + assert_eq!(none, b"\x1b[38;5;42m\x1b[39m"); + } + + #[test] + fn a_24_bit_colour_carries_the_low_24_bits() { + let mut out = Vec::new(); + assert!(placeholder_row(&mut out, 0x12_34_56, IdColor::Rgb, 0, 1)); + assert_eq!(out, "\x1b[38;2;18;52;86m\u{10EEEE}\u{0305}\u{0305}\x1b[39m".as_bytes()); + } + + #[test] + fn a_third_diacritic_carries_the_high_byte() { + // The protocol's example: 33554474 = 42 + (2 << 24), its high byte 2 being U+030E. + let mut out = Vec::new(); + assert!(placeholder_row(&mut out, 33_554_474, IdColor::Indexed, 0, 2)); + // The second cell inherits the high byte from the first. + assert_eq!(String::from_utf8(out).unwrap(), "\x1b[38;5;42m\u{10EEEE}\u{0305}\u{0305}\u{030E}\u{10EEEE}\x1b[39m"); + } + + #[test] + fn ids_cover_every_low_value_under_every_high_byte() { + assert_eq!(IdColor::Indexed.id(0), 1); + assert_eq!(IdColor::Indexed.id(254), 255); + assert_eq!(IdColor::Indexed.id(255), (1 << 24) | 1); + assert_eq!(IdColor::Indexed.id(255 * 256 - 1), (255 << 24) | 255); + assert_eq!(IdColor::Indexed.id(255 * 256), 1, "wraps"); + assert_eq!(IdColor::Rgb.id(0xFF_FFFF), (1 << 24) | 1); + assert_eq!(IdColor::Rgb.id(u64::MAX), IdColor::Rgb.id(u64::MAX % (0xFF_FFFF * 256))); + // Never 0, and the colour part never 0, for any index. + for index in [0, 1, 254, 255, 256, 65_279, 65_280, 1 << 40, u64::MAX] { + for colour in [IdColor::Indexed, IdColor::Rgb] { + let id = colour.id(index); + assert_ne!(id & colour.max_low(), 0, "{colour:?} {index}: {id:#x}"); + } + } + let distinct: std::collections::HashSet = (0..65_280).map(|i| IdColor::Indexed.id(i)).collect(); + assert_eq!(distinct.len(), 65_280); + } + + #[test] + fn placeholders_stop_at_the_end_of_the_table() { + let mut out = Vec::new(); + assert!(placeholder_row(&mut out, 1, IdColor::Indexed, MAX_CELLS - 1, MAX_CELLS)); + assert_eq!(String::from_utf8(out.clone()).unwrap().chars().filter(|c| *c == PLACEHOLDER).count(), 297); + out.clear(); + assert!(!placeholder_row(&mut out, 1, IdColor::Indexed, MAX_CELLS, 1)); + assert!(!placeholder_row(&mut out, 1, IdColor::Indexed, 0, MAX_CELLS + 1)); + assert!(out.is_empty()); + } + + #[test] + fn the_diacritics_are_the_specifications() { + let distinct: std::collections::HashSet = DIACRITICS.into_iter().collect(); + assert_eq!(distinct.len(), DIACRITICS.len()); + // rowcolumn-diacritics.txt's first two and last entries. + assert_eq!((DIACRITICS[0], DIACRITICS[1], DIACRITICS[296]), ('\u{0305}', '\u{030D}', '\u{1D244}')); + } +} diff --git a/apps/pdfcraft-cli/src/view/mod.rs b/apps/pdfcraft-cli/src/view/mod.rs new file mode 100644 index 0000000000..e2c0578872 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/mod.rs @@ -0,0 +1,614 @@ +//! `pdfcraft-cli view`: PDF pages drawn straight into the terminal through the kitty graphics +//! protocol (kitty, Ghostty, WezTerm and others). Usage and behaviour: `docs/cli-view.md`. + +mod detect; +mod files; +mod fit; +mod keys; +mod kitty; +mod pager; +mod tmux; +mod tty; + +use std::io::{IsTerminal, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use pdfcraft_engine::export::PngEffort; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderWarning, RenderedPage, RequestKind, inspect}; + +use crate::{CliError, OptionSpec, flag, page_list, read, validate_options}; +use fit::{Cell, Fit, Layout}; +use kitty::{IdColor, Wrap}; +use tty::{Tty, WindowSize}; + +const OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--fit", takes_value: true }, + OptionSpec { name: "--protocol", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, + OptionSpec { name: "--pager", takes_value: false }, + OptionSpec { name: "--no-pager", takes_value: false }, + OptionSpec { name: "--compression", takes_value: true }, +]; + +/// The window assumed when there is no terminal to ask (no controlling terminal, as under CI): +/// the classic 80 × 24, at [`fit::FALLBACK_CELL`]. +const FALLBACK_SIZE: WindowSize = WindowSize { cols: 80, rows: 24, xpixel: 0, ypixel: 0 }; + +/// How long the terminal gets to answer a query (support, cell size). Terminals answer within +/// milliseconds, so this only matters for one that never does. +const QUERY_TIMEOUT: Duration = Duration::from_millis(500); + +const NOT_A_TERMINAL: &str = "view: stdout is not a terminal (`pdfcraft-cli render --out page.png` writes an image file; \ + --protocol kitty sends the escape codes anyway)"; +const UNSUPPORTED: &str = "view: this terminal doesn't show images through the kitty graphics protocol (kitty, Ghostty \ + and WezTerm do; `pdfcraft-cli render --out page.png` writes an image file instead)"; + +/// While the pager owns the screen, panic messages aren't printed over it (see [`run`]). +static QUIET: AtomicBool = AtomicBool::new(false); + +/// Which graphics protocol: found out by asking, or taken on trust. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Protocol { + Auto, + Kitty, +} + +impl Protocol { + fn parse(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "kitty" => Ok(Self::Kitty), + other => Err(format!("view: --protocol {other:?}: use auto or kitty")), + } + } +} + +/// How pages reach the screen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Output { + /// Shown at the cursor. + Direct, + /// Through tmux's passthrough, and drawn by placeholder cells whose colour names the image. + Tmux(IdColor), +} + +impl Output { + /// The most columns and rows a page may cover in a window `cols` wide: placeholders number + /// at most [`kitty::MAX_CELLS`] of either. + fn limits(self, cols: u16) -> (u16, u16) { + match self { + Self::Direct => (cols, u16::MAX), + Self::Tmux(_) => (cols.min(kitty::MAX_CELLS), kitty::MAX_CELLS), + } + } + + fn wrap(self) -> Wrap { + match self { + Self::Direct => Wrap::None, + Self::Tmux(_) => Wrap::Tmux, + } + } + + /// The image ids this output can draw with: any outside tmux, as many as placeholder colours + /// can name inside it. + fn ids(self) -> IdColor { + match self { + Self::Direct => IdColor::Rgb, + Self::Tmux(colour) => colour, + } + } +} + +/// Whether pages are paged through or printed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Paging { + /// The pager when there is more than a page to show, on a terminal it can read keys from; + /// otherwise printed, as `less -F` does when the text fits. + Auto, + /// `--pager`: always the pager, even for one page. + Always, + /// `--no-pager`: always printed. + Never, +} + +impl Paging { + fn from_flags(pager: bool, no_pager: bool) -> Result { + match (pager, no_pager) { + (true, true) => Err("view: give --pager or --no-pager, not both".into()), + (true, false) => Ok(Self::Always), + (false, true) => Ok(Self::Never), + (false, false) => Ok(Self::Auto), + } + } + + /// Whether `pages` pages are paged through, `interactive` being whether stdout is a terminal + /// and keys can be read from one. + fn pages(self, pages: usize, interactive: bool) -> bool { + match self { + Self::Always => true, + Self::Never => false, + Self::Auto => interactive && pages > 1, + } + } +} + +/// How hard pages are compressed on their way to the terminal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Compression { + /// `small` over SSH. On this machine, `fast`, and the pager hands pages over through + /// temporary files when the terminal takes them (`files.rs`). + Auto, + /// Least CPU: for a terminal on the same machine, where encoding is most of a page's cost. + Fast, + /// Fewest bytes: for a terminal across a network, where the bytes are. + Small, +} + +impl Compression { + fn parse(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "fast" => Ok(Self::Fast), + "small" => Ok(Self::Small), + other => Err(format!("view: --compression {other:?}: use auto, fast or small")), + } + } + + /// The PNG effort, and whether the pager may try temporary files, asking `over_ssh` only + /// when it matters. A compression asked for by name means PNGs in the stream. + fn choose(self, over_ssh: impl FnOnce() -> bool) -> (PngEffort, bool) { + match self { + Self::Fast => (PngEffort::Fast, false), + Self::Small => (PngEffort::Small, false), + Self::Auto if over_ssh() => (PngEffort::Small, false), + Self::Auto => (PngEffort::Fast, true), + } + } +} + +/// Whether the terminal is at the other end of an SSH connection, so the bytes `view` sends +/// cross a network. Inside tmux, the session knows how its client attached (`tmux_says`); the +/// pane's own environment may date from another attach. +fn over_ssh(env_ssh: bool, tmux_says: Option) -> bool { + tmux_says.unwrap_or(env_ssh) +} + +/// Whether this process's environment comes from an SSH login. +fn env_ssh() -> bool { + ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"].iter().any(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty())) +} + +/// The terminal `view` draws on. +struct Terminal { + output: Output, + /// The cell size tmux or the terminal reports, for a tty that reports no pixel size. + cell: Option, + /// How hard pages are compressed for it. + effort: PngEffort, + /// Whether the pager may hand pages over through temporary files: the terminal may be on + /// this machine. + files: bool, +} + +pub(super) fn run(args: &[String]) -> Result<(), CliError> { + // A document can make the renderer panic. The renderer catches the panic and reports the page + // as failed, but the default hook first prints the panic's message, which the document may + // have shaped, raw to the terminal. This hook prints it made safe, and not over the pager. + std::panic::set_hook(Box::new(|info| { + if !QUIET.load(Ordering::Relaxed) { + note(&format!("pdfcraft-cli: {info}")); + } + })); + view(args).map_err(|e| match e { + CliError::Message(message) => CliError::Message(printable(&message)), + other => other, + }) +} + +fn view(args: &[String]) -> Result<(), CliError> { + validate_options("view", args, OPTIONS)?; + let path = file_arg(args)?; + let fit = flag(args, "--fit").map_or(Ok(Fit::Page), Fit::parse)?; + let protocol = Protocol::parse(flag(args, "--protocol").unwrap_or("auto"))?; + let compression = Compression::parse(flag(args, "--compression").unwrap_or("auto"))?; + let doc = Doc::open(path, flag(args, "--password"))?; + let pages = match flag(args, "--pages") { + Some(list) => page_list(list).map_err(|e| format!("view: --pages: {e}"))?, + None => (0..doc.count).collect(), + }; + if let Some(page) = pages.iter().find(|p| **p >= doc.count) { + let plural = if doc.count == 1 { "" } else { "s" }; + return Err(format!("view: no page {} ({path} has {} page{plural})", page.saturating_add(1), doc.count).into()); + } + let has = |name: &str| args.iter().any(|a| a == name); + let paging = Paging::from_flags(has("--pager"), has("--no-pager"))?; + let tty = Tty::open().ok(); + let terminal = if tmux::inside() { + tmux_terminal(protocol)? + } else { + check_terminal(protocol, tty.as_ref())?; + let cell = tty.as_ref().and_then(ask_cell); + Terminal { output: Output::Direct, cell, effort: PngEffort::Fast, files: false } + }; + let (effort, files) = compression.choose(|| over_ssh(env_ssh(), tmux::inside().then(tmux::attached_over_ssh).flatten())); + let terminal = Terminal { effort, files, ..terminal }; + let first_id = fresh_index(); + if paging.pages(pages.len(), std::io::stdout().is_terminal() && tty.is_some()) { + let tty = tty.ok_or("view: --pager needs a terminal to read keys from")?; + let name = std::path::Path::new(path).file_name().map_or_else(|| path.into(), |n| n.to_string_lossy()); + return pager::run(&tty, &doc, &name, &terminal, pages, fit, first_id).map_err(|e| format!("view: {e}").into()); + } + let size = tty.as_ref().and_then(Tty::size).unwrap_or(FALLBACK_SIZE); + print_pages(&doc, &pages, fit, size, &terminal, first_id) +} + +/// The one file argument: the token that is neither an option nor an option's value. +fn file_arg(args: &[String]) -> Result<&str, String> { + let mut files = Vec::new(); + let mut i = 0usize; + while let Some(arg) = args.get(i) { + match OPTIONS.iter().find(|o| o.name == arg) { + Some(option) => i = i.saturating_add(if option.takes_value { 2 } else { 1 }), + None => { + files.push(arg.as_str()); + i = i.saturating_add(1); + } + } + } + match files.as_slice() { + [file] => Ok(file), + [] => Err("view: missing file".into()), + _ => Err(format!("view: one file at a time (got {})", files.join(", "))), + } +} + +/// Make sure stdout is a terminal that shows kitty images, unless `--protocol kitty` says to +/// trust it. The environment answers for kitty and Ghostty; any other terminal is asked. +fn check_terminal(protocol: Protocol, tty: Option<&Tty>) -> Result<(), CliError> { + if protocol == Protocol::Kitty { + return Ok(()); + } + if !std::io::stdout().is_terminal() { + return Err(NOT_A_TERMINAL.into()); + } + let var = |name| std::env::var(name).ok(); + if detect::env_says_kitty(var("TERM").as_deref(), var("TERM_PROGRAM").as_deref(), std::env::var_os("KITTY_WINDOW_ID").is_some()) { + return Ok(()); + } + let tty = tty.ok_or(UNSUPPORTED)?; + if ask_terminal(tty)? { Ok(()) } else { Err(UNSUPPORTED.into()) } +} + +/// Send the support query and read the answer. +fn ask_terminal(tty: &Tty) -> Result { + let reply = ask(tty, detect::QUERY, |reply| detect::parse_reply(reply).done)?; + Ok(detect::parse_reply(&reply).graphics) +} + +/// The cell size, from the terminal, when its tty reports no pixel size: `docker exec` and +/// `kubectl exec` pass on rows and columns only. `None` when the tty reports pixels, stdout is +/// somewhere else, or the terminal doesn't say; tmux says for its client instead +/// ([`tmux_terminal`]). +fn ask_cell(tty: &Tty) -> Option { + let size = tty.size()?; + if Cell::from_window(size.cols, size.rows, size.xpixel, size.ypixel).is_some() || !std::io::stdout().is_terminal() { + return None; + } + let reply = ask(tty, detect::CELL_QUERY, |reply| detect::parse_cell_reply(reply).done).ok()?; + detect::parse_cell_reply(&reply).cell.map(|(w, h)| Cell { w: f32::from(w), h: f32::from(h) }) +} + +/// Send `query` and read the answer until `done` says it is all there, or for [`QUERY_TIMEOUT`] +/// at most. Raw mode keeps the reply off the screen and hands it over without waiting for Enter. +fn ask(tty: &Tty, query: &[u8], done: impl Fn(&[u8]) -> bool) -> Result, String> { + let _raw = tty.raw()?; + tty.write_all(query)?; + let deadline = Instant::now() + QUERY_TIMEOUT; + let mut reply = Vec::new(); + while reply.len() < detect::MAX_REPLY { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let Some(bytes) = tty.read_timeout(left)? else { break }; + reply.extend_from_slice(&bytes); + if done(&reply) { + break; + } + } + Ok(reply) +} + +/// Inside tmux: ask tmux which terminal its client runs in (`TERM` names tmux itself), and make +/// sure the pane passes escape codes through. +fn tmux_terminal(protocol: Protocol) -> Result { + if protocol == Protocol::Auto && !std::io::stdout().is_terminal() { + return Err(NOT_A_TERMINAL.into()); + } + let client = tmux::client(); + let colour = tmux_colour(protocol, &client)?; + match tmux::ensure_passthrough() { + Ok(true) => note("view: turned on tmux's allow-passthrough for this pane, so images reach the terminal"), + Ok(false) => {} + Err(e) => note(&format!("warning: view: couldn't check tmux's allow-passthrough ({e}); images may not show")), + } + let cell = client.ok().and_then(|c| c.cell).map(|(w, h)| Cell { w: f32::from(w), h: f32::from(h) }); + Ok(Terminal { output: Output::Tmux(colour), cell, effort: PngEffort::Fast, files: false }) +} + +/// The colour placeholder cells name images with, once the terminal outside tmux is known to +/// speak the protocol (or `--protocol kitty` says to trust it): 24-bit when tmux passes it +/// through unchanged, the 256-colour palette otherwise. +fn tmux_colour(protocol: Protocol, client: &Result) -> Result { + match (protocol, client) { + (Protocol::Auto, Err(e)) => { + Err(format!("view: inside tmux, but tmux didn't say which terminal it runs in ({e}); --protocol kitty tries anyway")) + } + (Protocol::Auto, Ok(c)) if !detect::term_name_says_kitty(&c.termname) => { + let name = if c.termname.is_empty() { "unknown" } else { &c.termname }; + Err(format!( + "view: tmux runs in a terminal ({name}) not known to show images through the kitty graphics protocol \ + (kitty and Ghostty are); --protocol kitty tries anyway" + )) + } + (_, Ok(c)) if c.rgb => Ok(IdColor::Rgb), + _ => Ok(IdColor::Indexed), + } +} + +/// `text` made safe to write to a terminal: no control characters, so none of the ESC, CSI or +/// OSC introducers that start escape sequences (a clipboard write, a title change, a graphics +/// command). Line breaks and tabs become spaces, the others U+FFFD. Everything a document or a +/// file name can shape goes through this on its way to the terminal. +fn printable(text: &str) -> String { + text.chars() + .map(|c| match c { + '\n' | '\r' | '\t' => ' ', + c if c.is_control() => '\u{FFFD}', + c => c, + }) + .collect() +} + +/// A line on stderr that doesn't stop `view`, made safe for the terminal. +fn note(line: &str) { + let _ = writeln!(std::io::stderr().lock(), "{}", printable(line)); +} + +/// Draw `pages` one under another at the cursor, each fitted to the window, like `cat`. A page +/// that fails is reported at the end; the others are still drawn. +fn print_pages(doc: &Doc, pages: &[usize], fit: Fit, size: WindowSize, terminal: &Terminal, first_id: u64) -> Result<(), CliError> { + let mut renderer = doc.renderer(); + if renderer.page_count() == 0 { + return Err("view: the renderer can't read this document".into()); + } + let cell = Cell::from_window(size.cols, size.rows, size.xpixel, size.ypixel).or(terminal.cell).unwrap_or(fit::FALLBACK_CELL); + // One row stays free for the shell prompt after the last page. + let rows = size.rows.saturating_sub(1).max(1); + let (cols, max_rows) = terminal.output.limits(size.cols); + let mut failed = Vec::new(); + let mut stdout = std::io::stdout().lock(); + for (n, &page) in pages.iter().enumerate() { + let drawn = doc + .layout(page, fit, cols, rows, cell, max_rows) + .and_then(|layout| encode(&renderer.render(request(page, &layout, 0)), terminal.effort).map(|image| (layout, image))); + let (layout, image) = match drawn { + Ok(drawn) => drawn, + Err(e) => { + failed.push(e); + continue; + } + }; + if let Some(warning) = &image.warning { + note(&format!("warning: {warning}")); + } + let mut out = Vec::new(); + match terminal.output { + Output::Direct => { + kitty::show_png(&mut out, &image.png, layout.cols, layout.rows); + out.push(b'\n'); + } + Output::Tmux(colour) => { + let id = colour.id(first_id.wrapping_add(u64::try_from(n).unwrap_or(u64::MAX))); + kitty::transmit(&mut out, id, &image.png, Wrap::Tmux); + kitty::place_virtual(&mut out, id, layout.cols, layout.rows, Wrap::Tmux); + // The layout keeps rows and columns within the placeholders' table. + for row in 0..layout.rows { + if kitty::placeholder_row(&mut out, id, colour, row, layout.cols) { + out.push(b'\n'); + } + } + } + } + stdout.write_all(&out).and_then(|()| stdout.flush()).map_err(CliError::Stdout)?; + } + if failed.is_empty() { Ok(()) } else { Err(format!("view: {}", failed.join("; ")).into()) } +} + +/// Where this run's image ids start ([`IdColor::id`]): mixed from the clock and the process id, +/// so runs in the same terminal are unlikely to draw over each other's images. +fn fresh_index() -> u64 { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + now.as_secs().wrapping_mul(1_000_000_007) ^ u64::from(now.subsec_nanos()) ^ u64::from(std::process::id()).wrapping_mul(0x9E37_79B9_7F4A_7C15) +} + +/// The open document: the display copy the renderers read, and each page's displayed size. +struct Doc { + bytes: Arc>, + config: RenderConfig, + /// In points, after `/Rotate` and `/UserUnit`. + sizes: Vec<(f32, f32)>, + count: usize, +} + +/// A page rendered for the terminal, as a PNG. +struct Image { + png: Vec, + /// Something the reader should know about the page, such as content left out. + warning: Option, +} + +impl Doc { + fn open(path: &str, password: Option<&str>) -> Result { + let bytes = read(path)?; + let info = inspect(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; + let count = info.pages.len(); + if count == 0 { + return Err(format!("{path}: no pages to show").into()); + } + Ok(Self { + // The display copy, as `render` and the app use: comments missing their appearance + // streams are drawn too. + bytes: pdfcraft_engine::display_bytes_for(bytes, password), + config: RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }, + sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(), + count, + }) + } + + /// Where `page` goes in `cols` × `rows` cells, never more than `max_rows` tall. Errors name the + /// page. + fn layout(&self, page: usize, fit: Fit, cols: u16, rows: u16, cell: Cell, max_rows: u16) -> Result { + let number = page.saturating_add(1); + let (w, h) = self.sizes.get(page).copied().ok_or_else(|| format!("no page {number}"))?; + fit::layout(w, h, cols, rows, cell, fit, max_rows).map_err(|e| format!("page {number}: {e}")) + } + + /// A renderer on this thread, for pages printed one after another. + fn renderer(&self) -> PageRenderer { + PageRenderer::new(self.bytes.clone(), self.config.clone()) + } + + /// Render threads with the renderer's watchdog, for the pager: keys still work while pages + /// render, and a page that takes too long is given up on. + fn pool(&self, threads: usize) -> RenderPool { + RenderPool::new(self.bytes.clone(), threads, self.config.clone()) + } +} + +/// The render of `page` at `layout`'s scale, tagged so its result can be told from others. +fn request(page: usize, layout: &Layout, tag: u64) -> RenderRequest { + RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: layout.scale, tag, preview: false } +} + +/// A rendered page as a PNG, or why there is none. Errors name the page. +fn encode(p: &RenderedPage, effort: PngEffort) -> Result { + let warning = checked(p)?; + Ok(Image { png: png(p, effort)?, warning }) +} + +/// Why a page failed to render, or what the reader should know about it, if anything. Both name +/// the page. +fn checked(p: &RenderedPage) -> Result, String> { + let number = p.request.page.saturating_add(1); + if let Some(e) = &p.error { + return Err(format!("page {number}: {e}")); + } + Ok(p.warnings.contains(&RenderWarning::ContentTruncated).then(|| format!("page {number}: content past the page's safety budget was skipped"))) +} + +/// A rendered page's pixels as a PNG. Errors name the page. +fn png(p: &RenderedPage, effort: PngEffort) -> Result, String> { + let number = p.request.page.saturating_add(1); + pdfcraft_engine::export::encode_png_with(p.width, p.height, &p.rgba, effort).map_err(|e| format!("page {number}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn the_file_is_the_token_that_is_not_an_option_value() { + assert_eq!(file_arg(&args(&["a.pdf", "--pages", "2"])), Ok("a.pdf")); + assert_eq!(file_arg(&args(&["--pages", "2", "a.pdf"])), Ok("a.pdf")); + assert_eq!(file_arg(&args(&["--password", "a.pdf", "b.pdf"])), Ok("b.pdf")); + // `--pager` takes no value. + assert_eq!(file_arg(&args(&["--pager", "a.pdf"])), Ok("a.pdf")); + assert!(file_arg(&args(&["--fit", "page"])).is_err_and(|e| e.contains("missing file"))); + assert!(file_arg(&args(&["a.pdf", "b.pdf"])).is_err_and(|e| e.contains("one file at a time"))); + // A value left off the end is not a file. + assert!(file_arg(&args(&["--pages"])).is_err()); + } + + #[test] + fn nothing_written_out_can_start_an_escape_sequence() { + // OSC 52 (a clipboard write), a title change, a kitty graphics command, 8-bit CSI and OSC. + let hostile = "a\x1b]52;c;cHduZWQ=\x07b\x1b]0;title\x1b\\c\x1b_Ga=d\x1b\\d\u{9b}2J\u{9d}0;t\u{7}"; + let safe = printable(hostile); + assert!(!safe.chars().any(char::is_control), "{safe:?}"); + assert!(safe.starts_with("a\u{FFFD}]52;c;cHduZWQ=\u{FFFD}b"), "{safe:?}"); + assert_eq!(printable("two\nlines\tand\r"), "two lines and "); + assert_eq!(printable("rapport-été.pdf"), "rapport-été.pdf"); + } + + #[test] + fn page_ids_follow_on_from_the_runs_start() { + let first = fresh_index(); + let (a, b) = (IdColor::Indexed.id(first), IdColor::Indexed.id(first.wrapping_add(1))); + assert_ne!(a, b); + assert_ne!(a & 0xff, 0); + } + + #[test] + fn inside_tmux_the_client_terminal_decides() { + let client = |termname: &str, rgb| Ok(tmux::Client { termname: termname.into(), rgb, cell: None }); + assert_eq!(tmux_colour(Protocol::Auto, &client("xterm-kitty", true)), Ok(IdColor::Rgb)); + assert_eq!(tmux_colour(Protocol::Auto, &client("xterm-ghostty", false)), Ok(IdColor::Indexed)); + let other = tmux_colour(Protocol::Auto, &client("xterm-256color", true)); + assert!(other.is_err_and(|e| e.contains("(xterm-256color)") && e.contains("--protocol kitty"))); + let unknown = tmux_colour(Protocol::Auto, &Err("tmux: not found".into())); + assert!(unknown.is_err_and(|e| e.contains("tmux: not found"))); + // Trusted: whatever tmux says, or when it can't say. + assert_eq!(tmux_colour(Protocol::Kitty, &client("xterm-256color", true)), Ok(IdColor::Rgb)); + assert_eq!(tmux_colour(Protocol::Kitty, &Err("tmux: not found".into())), Ok(IdColor::Indexed)); + } + + #[test] + fn placeholders_limit_the_size_of_a_page() { + assert_eq!(Output::Direct.limits(400), (400, u16::MAX)); + assert_eq!(Output::Tmux(IdColor::Rgb).limits(400), (297, 297)); + assert_eq!(Output::Tmux(IdColor::Rgb).limits(80), (80, 297)); + } + + #[test] + fn more_than_a_page_on_a_terminal_opens_the_pager() { + let auto = Paging::from_flags(false, false).unwrap(); + assert!(auto.pages(18, true)); + assert!(!auto.pages(1, true), "one page is printed, as less -F does"); + assert!(!auto.pages(18, false), "not on a terminal"); + assert!(Paging::from_flags(true, false).unwrap().pages(1, true)); + assert!(!Paging::from_flags(false, true).unwrap().pages(18, true)); + assert!(Paging::from_flags(true, true).is_err_and(|e| e.contains("not both"))); + } + + #[test] + fn compression_follows_where_the_terminal_is() { + assert_eq!(Compression::parse("auto"), Ok(Compression::Auto)); + assert!(Compression::parse("best").is_err_and(|e| e.contains("auto, fast or small"))); + // Over SSH, small PNGs; on this machine, fast ones, and temporary files may be tried. + assert_eq!(Compression::Auto.choose(|| true), (PngEffort::Small, false)); + assert_eq!(Compression::Auto.choose(|| false), (PngEffort::Fast, true)); + // Chosen by hand: PNGs in the stream, and where the terminal is isn't even asked. + assert_eq!(Compression::Fast.choose(|| panic!("asked")), (PngEffort::Fast, false)); + assert_eq!(Compression::Small.choose(|| panic!("asked")), (PngEffort::Small, false)); + // tmux's session knows better than a pane's environment, which may be from another attach. + assert!(over_ssh(true, None)); + assert!(!over_ssh(false, None)); + assert!(!over_ssh(true, Some(false))); + assert!(over_ssh(false, Some(true))); + } + + #[test] + fn protocol_names() { + assert_eq!(Protocol::parse("auto"), Ok(Protocol::Auto)); + assert_eq!(Protocol::parse("kitty"), Ok(Protocol::Kitty)); + assert!(Protocol::parse("sixel").is_err_and(|e| e.contains("auto or kitty"))); + } +} diff --git a/apps/pdfcraft-cli/src/view/pager.rs b/apps/pdfcraft-cli/src/view/pager.rs new file mode 100644 index 0000000000..1db3e5e70c --- /dev/null +++ b/apps/pdfcraft-cli/src/view/pager.rs @@ -0,0 +1,1241 @@ +//! `view --pager`: one page at a time on the alternate screen, like `less`, or two side by side +//! when the window is wide enough, turning two at a time like a book's. +//! +//! [`Nav`] is where the reader is and what each key does, with no IO. [`Pager`] renders and +//! draws what `Nav` says, and [`Screen`] puts the terminal back however the pager ends. +//! +//! Turning a page should be instant, so the pager works ahead. The pages on screen render first, +//! then the next screens and the one before, on worker threads (keys keep working meanwhile, and +//! the renderer's watchdog gives up on a page that takes too long). Each page is sent to the +//! terminal as soon as it is ready, under an image id of its own, and stays there while it is +//! near: turning to it only places it. The pages on screen stay until the next ones are ready, +//! so the screen never goes blank in between. +//! +//! A terminal on this machine is handed pages through temporary files when it takes them +//! (`files.rs`): only a path goes through the stream, so a page turn never waits behind the +//! megabytes of pages sent ahead, and neither side spends time on PNG. +//! +//! What the terminal keeps can still go: kitty drops images past its storage quota, and tmux +//! throws away what it can't deliver. The pages kept stay well under the quota ([`BUDGET`]); a +//! placement of an image the terminal no longer has is answered, and the page is sent again; and +//! inside tmux, the pager asks tmux after writing whether everything reached the terminal. +//! `README.md` beside this file has the details. + +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use pdfcraft_engine::export::PngEffort; +use pdfcraft_render::{RenderPool, RenderedPage}; + +use super::files::{Files, Written}; +use super::fit::{self, Cell, FALLBACK_CELL, Fit, Layout}; +use super::keys::{self, Key}; +use super::kitty::{self, Rect}; +use super::tty::{RawMode, Tty, WindowSize}; +use super::{Doc, FALLBACK_SIZE, Output, QUIET, Terminal, checked, png, printable, request, tmux}; + +/// How often the window size is checked while no key comes. +const TICK: Duration = Duration::from_millis(200); +/// How often renders in progress are checked on. +const POLL: Duration = Duration::from_millis(10); +/// How long a cut-off escape sequence waits for the rest before it counts as the Esc key. +const ESCAPE_WAIT: Duration = Duration::from_millis(50); +/// Rows `j`, `k`, the arrows and Enter scroll. +const STEP: u16 = 3; +/// The most digits a typed page number keeps. +const MAX_TYPED: usize = 9; +/// Bytes kept from an escape sequence or a terminal's answer still arriving; past this many, or +/// [`PENDING_LIMIT`], they are dropped. +const MAX_PENDING: usize = keys::MAX_ANSWER * 2; +const PENDING_LIMIT: Duration = Duration::from_secs(1); +/// Screens rendered and sent ahead of the one on screen, and behind it. +const AHEAD: usize = 2; +const BEHIND: usize = 1; +/// Pages kept in the terminal, ready to show at once: the most recently shown stay. +const MAX_SENT: usize = 12; +/// The most bytes of pixels kept in the terminal. kitty keeps 320 MB of images per screen, and +/// past that drops nearly every image not on screen at once; inside tmux, other panes' images +/// count against the same 320 MB. +const BUDGET: u64 = 192 << 20; +/// The least the budget comes down to when the terminal drops images all the same. +const MIN_BUDGET: u64 = 32 << 20; +/// Failures remembered, so a page that fails isn't rendered again and again. +const MAX_FAILED: usize = 16; +/// Render threads: one can work ahead while the other renders what the reader asked for. +const THREADS: usize = 2; +/// Inside tmux, how long after the last write to ask tmux whether it reached the terminal. +const TMUX_AFTER_WRITE: Duration = Duration::from_millis(150); +/// Inside tmux, how often to ask anyway: whether the pane shows again, or another terminal came. +const TMUX_EVERY: Duration = Duration::from_secs(1); +/// How many times the pager sends its pages again after tmux or the terminal threw some away, +/// before it stops trying by itself (`r` still does). +const MAX_RESENDS: u32 = 8; +/// How long the pager waits at the end for the terminal's answers to its last placements, so +/// none of them lands on the command line. +const ANSWER_WAIT: Duration = Duration::from_millis(100); +/// The reminder of the keys on the status line, shorter and shorter for a narrow window. +const KEYS: [&str; 3] = ["space b j k · n p · g G · 12⏎ · f fit · d two-up · q quit", "n p · f fit · d two-up · q quit", "n p · q quit"]; + +/// Page through `pages` of `doc` until the user quits. Image ids are taken in turn from +/// `first_id` on ([`super::kitty::IdColor::id`]). +pub(super) fn run(tty: &Tty, doc: &Doc, name: &str, terminal: &Terminal, pages: Vec, fit: Fit, first_id: u64) -> Result<(), String> { + // Inside tmux: who shows the pane, before any image goes. + let mut watch = None; + let mut showing = 1; + if let Output::Tmux(_) = terminal.output + && let Some(report) = tmux::report() + { + let mut w = tmux::Watch::default(); + showing = w.update(&report, 0).showing; + watch = Some(w); + } + let mut pager = Pager { + screen: Screen::enter(tty)?, + tty, + doc, + pool: doc.pool(THREADS), + name, + output: terminal.output, + cell: terminal.cell, + effort: terminal.effort, + nav: Nav::new(pages, doc.count, fit), + sent: Vec::new(), + wanted: Vec::new(), + failed: Vec::new(), + on_screen: [None, None], + placed: Vec::new(), + size: None, + first_id, + next_id: 0, + tag: 0, + tmux_check: watch.as_ref().map(|_| Instant::now() + TMUX_EVERY), + watch, + hidden: false, + transmitted: 0, + resends: 0, + budget: BUDGET, + files: None, + stalled_files: None, + placed_at: None, + }; + // Files only where one terminal gets the escape codes: inside tmux, every terminal showing + // the pane gets them, and the first to read a file deletes it before the others can. (Inside + // a tmux that doesn't say, the pager can't know: no files.) + if terminal.files && showing == 1 && (terminal.output == Output::Direct || pager.watch.is_some()) { + let probe_id = pager.new_id(); + if let Some((files, probe)) = Files::start(probe_id, pager.output.wrap()) { + pager.wrote(1); + tty.write_all(&probe)?; + pager.files = Some(files); + } + } + pager.draw()?; + let mut size = tty.size(); + let mut pending: Vec = Vec::new(); + let mut pending_since = Instant::now(); + loop { + let wait = if !pending.is_empty() { + ESCAPE_WAIT + } else if !pager.wanted.is_empty() && !pager.hidden { + POLL + } else { + TICK + }; + let wait = pager.tmux_check.map_or(wait, |at| wait.min(at.saturating_duration_since(Instant::now()))); + let read = tty.read_timeout(wait)?; + if let Some(bytes) = &read { + if pending.is_empty() { + pending_since = Instant::now(); + } + pending.extend_from_slice(bytes); + } + let mut redraw = false; + if !pending.is_empty() { + // When nothing more arrived, a cut-off escape sequence was the Esc key. + let (keys, used) = keys::decode(&pending, read.is_none()); + pending.drain(..used.min(pending.len())); + // What is left is the start of something cut off: it gets a while to complete. + if used > 0 { + pending_since = Instant::now(); + } + if pending.len() > MAX_PENDING || pending_since.elapsed() > PENDING_LIMIT { + pending.clear(); + } + let mut quit = false; + for key in keys { + if let Key::Missing(id) = key { + redraw |= pager.missing(id); + } else if pager.nav.key(key) == Step::Quit { + quit = true; + break; + } else { + redraw = true; + } + } + if quit { + pager.await_answers(); + break; + } + } else if read.is_none() { + // Nothing typed: redraw if the window changed size. + let now = tty.size(); + redraw = now != size; + size = now; + } + let lost = pager.lost_files()?; + // Nothing goes through tmux while the pane is hidden: it would be thrown away. + let arrived = !pager.hidden && pager.receive()?; + if arrived || redraw || lost { + pager.draw()?; + } + if pager.tmux_check.is_some_and(|at| Instant::now() >= at) && pager.ask_tmux() { + pager.draw()?; + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Continue, + Quit, +} + +/// Where the reader is, and what keys do to it. +#[derive(Debug)] +struct Nav { + /// The pages to page through, in order: `--pages`, or the whole document. + pages: Vec, + /// Where in `pages` the reader is. + at: usize, + /// Pages in the document. + count: usize, + fit: Fit, + /// Two pages side by side when they fit (`d` turns it off and on). + two_up: bool, + /// From the last draw: pages on screen (1 or 2), the image rows on screen, and the most + /// `scroll` can be. + per_screen: usize, + visible: u16, + max_scroll: u16, + /// Image rows scrolled off the top. Past the bottom until the next draw clamps it. + scroll: u16, + /// A page number being typed. + typed: String, + /// Shown in the status line until the next key. + message: Option, + /// Render the pages again even if nothing changed (`r`, Ctrl-L). + rerender: bool, +} + +impl Nav { + fn new(pages: Vec, count: usize, fit: Fit) -> Self { + Self { + pages, + at: 0, + count, + fit, + two_up: true, + per_screen: 1, + visible: 1, + max_scroll: 0, + scroll: 0, + typed: String::new(), + message: None, + rerender: false, + } + } + + /// Where in `pages` the screen starts. Two pages side by side are always an even place and + /// the odd one after it, the way a book's spreads pair them. + fn first(&self) -> usize { + if self.per_screen == 2 { self.at - self.at % 2 } else { self.at } + } + + /// The pages on screen: the one alone, or the two side by side (the second missing past the + /// end). + fn on_screen(&self) -> [Option; 2] { + let first = self.first(); + let second = if self.per_screen == 2 { self.pages.get(first.saturating_add(1)).copied() } else { None }; + [self.pages.get(first).copied(), second] + } + + /// The pages worth having ready around the screen, nearest first: the next `ahead` screens, + /// then the `behind` screens before. + fn around(&self, ahead: usize, behind: usize) -> Vec { + let per = self.per_screen.max(1); + let first = self.first(); + let screen = |start: usize| (start..start.saturating_add(per)).filter_map(|i| self.pages.get(i).copied()); + // Both stop at the ends of the list, however many screens are asked for. + let next = (1..=ahead).map(|k| first.saturating_add(k.saturating_mul(per))).take_while(|start| *start < self.pages.len()).flat_map(screen); + let before = (1..=behind).map_while(|k| first.checked_sub(k.saturating_mul(per))).flat_map(screen); + next.chain(before).collect() + } + + fn key(&mut self, key: Key) -> Step { + self.message = None; + let typing = !self.typed.is_empty(); + match key { + Key::Char('q') | Key::Interrupt => return Step::Quit, + Key::Escape if !typing => return Step::Quit, + Key::Escape => self.typed.clear(), + // Past MAX_TYPED digits, more are ignored (no other arm takes a digit). + Key::Char(c @ '0'..='9') if self.typed.len() < MAX_TYPED => self.typed.push(c), + Key::Backspace => { + self.typed.pop(); + } + Key::Enter | Key::Char('g') if typing => self.go_typed(), + Key::Char(' ') | Key::PageDown => self.forward(self.visible.max(1)), + Key::Char('b') | Key::PageUp => self.back(self.visible.max(1)), + Key::Char('j') | Key::Down | Key::Enter => self.forward(STEP), + Key::Char('k') | Key::Up => self.back(STEP), + Key::Char('n') | Key::Right => self.go(self.first().saturating_add(self.per_screen)), + Key::Char('p') | Key::Left => self.go(self.first().saturating_sub(self.per_screen)), + Key::Char('g') | Key::Home => self.go(0), + Key::Char('G') | Key::End => self.go(usize::MAX), + // One page to the screen from now on: keys before the next draw step by one. + Key::Char('f') => { + self.fit = self.fit.toggled(); + self.scroll = 0; + if self.fit == Fit::Width { + self.per_screen = 1; + } + } + Key::Char('d') => { + self.two_up = !self.two_up; + self.scroll = 0; + if !self.two_up { + self.per_screen = 1; + } + } + Key::Char('r' | '\u{c}') => self.rerender = true, + _ => {} + } + Step::Continue + } + + /// Down `rows`; from the bottom of the screen's pages, on to the top of the next. + fn forward(&mut self, rows: u16) { + let next = self.first().saturating_add(self.per_screen); + if self.scroll < self.max_scroll { + self.scroll = self.scroll.saturating_add(rows).min(self.max_scroll); + } else if next < self.pages.len() { + self.at = next; + self.scroll = 0; + } + } + + /// Up `rows`; from the top of the screen's pages, back to the bottom of the ones before. + fn back(&mut self, rows: u16) { + let first = self.first(); + if self.scroll > 0 { + self.scroll = self.scroll.saturating_sub(rows); + } else if first > 0 { + self.at = first.saturating_sub(self.per_screen); + self.scroll = u16::MAX; + } + } + + /// To the top of the page at `index` in `pages`, or of the last one when past it. + fn go(&mut self, index: usize) { + self.at = index.min(self.pages.len().saturating_sub(1)); + self.scroll = 0; + } + + /// To the page whose number was typed, if it is one being paged through. + fn go_typed(&mut self) { + let wanted = self.typed.parse::().ok().and_then(|n| n.checked_sub(1)); + match wanted.map(|page| (page, self.pages.iter().position(|p| *p == page))) { + Some((_, Some(index))) => self.go(index), + Some((page, None)) if page < self.count => self.message = Some(format!("page {} isn't one of the pages given", self.typed)), + _ => self.message = Some(format!("no page {} (1–{})", self.typed, self.count)), + } + self.typed.clear(); + } + + /// After a draw: `per_screen` pages are on screen, `visible` of the first's `rows` image rows. + /// Keeps `scroll` in range. + fn shown(&mut self, per_screen: usize, visible: u16, rows: u16) { + self.per_screen = per_screen; + self.visible = visible; + self.max_scroll = rows.saturating_sub(visible); + self.scroll = self.scroll.min(self.max_scroll); + } + + /// The status line for a window `cols` wide: exactly that many characters, none of them a + /// control character (the file name and the messages may come from anywhere). The right side + /// keeps two spaces from the left: the reminder of the keys gets shorter, then goes, in a + /// narrow window, and a message cuts the file name short instead. + fn status(&self, name: &str, cols: u16, rendering: bool) -> String { + let said = match (&self.message, self.typed.is_empty(), rendering) { + (Some(message), _, _) => Some(message.clone()), + (None, false, _) => Some(format!("go to page {}", self.typed)), + (None, true, true) => Some("rendering…".into()), + (None, true, false) => None, + }; + let pages = match self.on_screen() { + [Some(a), Some(b)] => format!("pages {}–{}/{}", a.saturating_add(1), b.saturating_add(1), self.count), + [Some(a), None] => format!("page {}/{}", a.saturating_add(1), self.count), + _ => "no page".into(), + }; + let mut place = format!(" · {pages} · fit {} ", self.fit.name()); + if self.pages.len() != self.count { + place.push_str(&format!("· {} of {} ", self.first().saturating_add(1), self.pages.len())); + } + let width = usize::from(cols); + let chars = |text: &str| text.chars().count(); + // Both sides, with a space before the name, one at the end, and two between the sides + // (`place` ends with one of them). + let fits = |name: &str, right: &str| chars(name).saturating_add(chars(&place)).saturating_add(chars(right)).saturating_add(3) <= width; + let right = said.unwrap_or_else(|| KEYS.iter().find(|keys| fits(name, keys)).map_or("", |keys| keys).to_owned()); + // The file name gives way to the rest. + let around = chars(&place).saturating_add(chars(&right)).saturating_add(if right.is_empty() { 2 } else { 3 }); + let room = width.saturating_sub(around); + let name: String = if chars(name) <= room { name.to_owned() } else { name.chars().take(room.saturating_sub(1)).chain(['…']).collect() }; + let left = format!(" {name}{place}"); + let gap = width.saturating_sub(chars(&left).saturating_add(chars(&right)).saturating_add(1)); + let line = printable(&format!("{left}{}{right} ", " ".repeat(gap))); + line.chars().chain(std::iter::repeat(' ')).take(width).collect() + } +} + +/// The alternate screen, raw mode, no cursor and no line wrap, all undone when this drops (on +/// return, on an error through `?` and while a panic unwinds). +struct Screen<'a> { + tty: &'a Tty, + _raw: RawMode<'a>, +} + +impl<'a> Screen<'a> { + fn enter(tty: &'a Tty) -> Result { + // The guard comes first, so a write that fails halfway is undone too. + let screen = Self { tty, _raw: tty.raw()? }; + QUIET.store(true, Ordering::Relaxed); + tty.write_all(b"\x1b[?1049h\x1b[?25l\x1b[?7l")?; + Ok(screen) + } +} + +impl Drop for Screen<'_> { + fn drop(&mut self) { + // End synchronized output and reset the colours too, in case a redraw stopped halfway. + let _ = self.tty.write_all(b"\x1b[?2026l\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l"); + QUIET.store(false, Ordering::Relaxed); + } +} + +/// A page as drawn: which page, fitted how, into how many columns of what window. +#[derive(Clone, Copy, Debug, PartialEq)] +struct View { + page: usize, + fit: Fit, + cols: u16, + size: WindowSize, +} + +/// A page rendered for the window and sent to the terminal under an image id of its own. +struct Sent { + view: View, + id: u32, + /// The cells it covers and its size in pixels. + cols: u16, + rows: u16, + width: u32, + height: u32, + /// What the reader should know about it while it is on screen (content skipped). + warning: Option, +} + +impl Sent { + /// What it takes up in the terminal: its pixels, four bytes each. + fn bytes(&self) -> u64 { + u64::from(self.width).saturating_mul(u64::from(self.height)).saturating_mul(4) + } +} + +/// A render asked for and not back yet. +struct Wanted { + view: View, + layout: Layout, + tag: u64, +} + +struct Pager<'a> { + /// Dropped after the pager's own `drop` has deleted its images. + screen: Screen<'a>, + tty: &'a Tty, + doc: &'a Doc, + pool: RenderPool, + name: &'a str, + output: Output, + /// The cell size tmux or the terminal reports, for a tty that reports no pixel size. + cell: Option, + effort: PngEffort, + nav: Nav, + /// Pages in the terminal, ready to place, the most recently shown last. + sent: Vec, + /// Renders asked for, the most urgent first: the pages on screen, then the ones around them. + wanted: Vec, + /// Views that failed and why: not asked for again until the window changes or `r`. + failed: Vec<(View, String)>, + /// The views the reader wants on screen, from the last draw. + on_screen: [Option; 2], + /// The images placed on screen by the last full redraw, and under which placement. + placed: Vec<(u32, u32)>, + /// The window the pages in `sent` were made for. + size: Option, + first_id: u64, + next_id: u64, + tag: u64, + /// Inside tmux: which terminals show the pane and what they missed, and when to ask again. + watch: Option, + tmux_check: Option, + /// Inside tmux: no terminal shows the pane, so nothing is sent (tmux would throw it away). + hidden: bool, + /// Images sent so far. + transmitted: u64, + /// Times the pages were sent again because tmux or the terminal threw some away. + resends: u32, + /// The most bytes of pages kept in the terminal: [`BUDGET`], less once it drops images. + budget: u64, + /// Temporary files, while the terminal may take pages through them. + files: Option, + /// Temporary files whose thread stopped answering, kept to the end: dropping them waits for + /// the thread to remove its files, which is only worth it once the terminal is put back + /// (`screen` drops first). + stalled_files: Option, + /// When the last placements went out: their errors are answered. + placed_at: Option, +} + +/// A page ready to send. +enum Payload { + Png(Vec), + /// Straight RGBA pixels in a temporary file. + File(PathBuf), +} + +impl Drop for Pager<'_> { + fn drop(&mut self) { + let mut out = Vec::new(); + for sent in &self.sent { + kitty::delete(&mut out, sent.id, self.output.wrap()); + } + let _ = self.screen.tty.write_all(&out); + } +} + +impl Pager<'_> { + /// Redraw: decide what is on screen and what to work on ahead, then place the pages on + /// screen once they are all in the terminal (until then the pages before stay up), and write + /// the status line. + fn draw(&mut self) -> Result<(), String> { + let size = self.tty.size().unwrap_or(FALLBACK_SIZE); + let cell = Cell::from_window(size.cols, size.rows, size.xpixel, size.ypixel).or(self.cell).unwrap_or(FALLBACK_CELL); + // The last row is the status line. + let area_rows = size.rows.saturating_sub(1).max(1); + let (max_cols, max_rows) = self.output.limits(size.cols); + let wrap = self.output.wrap(); + let mut out = Vec::new(); + // Pages made for another window, or asked to be made again, go. + if std::mem::take(&mut self.nav.rerender) || self.size != Some(size) { + for sent in self.sent.drain(..) { + kitty::delete(&mut out, sent.id, wrap); + } + self.wanted.clear(); + self.failed.clear(); + self.placed.clear(); + self.size = Some(size); + } + // Two pages side by side when allowed, fitted to the page, and the window has room: the + // spread's first page decides. + let nav = &self.nav; + let spread_first = nav.pages.get(nav.at - nav.at % 2).and_then(|p| self.doc.sizes.get(*p)); + let pair = nav.two_up + && nav.fit == Fit::Page + && nav.pages.len() > 1 + && spread_first.is_some_and(|(w, h)| fit::two_up_fits(*w, *h, max_cols, area_rows, cell, max_rows)); + self.nav.per_screen = if pair { 2 } else { 1 }; + let cols = if pair { fit::two_up_cols(max_cols) } else { max_cols }; + let fit = self.nav.fit; + let view = |page| View { page, fit, cols, size }; + let on_screen = self.nav.on_screen().map(|page| page.map(view)); + self.on_screen = on_screen; + + // What to have ready, most urgent first: the pages on screen, then the next screens, then + // the one before, as far as they stay in the terminal together (`budget`). Renders already + // under way for any of them carry on; the rest stop. + let mut wanted = Vec::new(); + let mut near = 0u64; + for v in on_screen.into_iter().flatten().chain(self.nav.around(AHEAD, BEHIND).into_iter().map(view)) { + if self.failed.iter().any(|(f, _)| *f == v) || wanted.iter().any(|w: &Wanted| w.view == v) { + continue; + } + if let Some(s) = self.sent.iter().find(|s| s.view == v) { + near = near.saturating_add(s.bytes()); + continue; + } + let next = match self.wanted.iter().position(|w| w.view == v) { + Some(at) => Ok(self.wanted.swap_remove(at)), + None => self.doc.layout(v.page, v.fit, v.cols, area_rows, cell, max_rows).map(|layout| { + self.tag = self.tag.wrapping_add(1); + Wanted { view: v, layout, tag: self.tag } + }), + }; + match next { + Ok(w) => { + let size = self.doc.sizes.get(v.page).copied().unwrap_or_default(); + near = near.saturating_add(pixel_bytes(size, w.layout.scale)); + if near > self.budget && !on_screen.contains(&Some(v)) { + break; + } + wanted.push(w); + } + Err(e) => self.fail(v, e), + } + } + self.wanted = wanted; + self.requeue(); + + if self.nav.message.is_none() { + self.nav.message = note(&on_screen, &self.sent, &self.failed); + } + // Scrolling follows the first page on screen; two side by side always fit. + let first_rows = on_screen[0].and_then(|v| self.rows(&v)).unwrap_or(area_rows); + self.nav.shown(self.nav.per_screen, first_rows.min(area_rows), if pair { first_rows.min(area_rows) } else { first_rows }); + + // Synchronized output, where the terminal has it, shows the redraw all at once. + out.extend_from_slice(b"\x1b[?2026h"); + let ready = on_screen.iter().flatten().all(|v| self.sent.iter().any(|s| s.view == *v) || self.failed.iter().any(|(f, _)| f == v)); + if ready || self.placed.is_empty() { + self.place(&mut out, size, area_rows, cols, pair); + } + cursor(&mut out, size.rows.saturating_sub(1), 0); + out.extend_from_slice(b"\x1b[2K\x1b[7m"); + out.extend_from_slice(self.nav.status(self.name, size.cols, !ready).as_bytes()); + out.extend_from_slice(b"\x1b[0m\x1b[?2026l"); + self.wrote(0); + self.tty.write_all(&out) + } + + /// Note a write, with `images` transmissions in it: inside tmux, ask a moment later whether + /// it reached the terminal. + fn wrote(&mut self, images: u64) { + self.transmitted = self.transmitted.saturating_add(images); + if self.watch.is_some() { + self.tmux_check = Some(Instant::now() + TMUX_AFTER_WRITE); + } + } + + /// Inside tmux: ask tmux what became of what was written, and act on it. Says whether the + /// screen needs a redraw. + /// + /// - The pane is hidden: tmux throws passthrough away, so nothing is sent until it shows, and + /// files waiting for the terminal are given up on (without giving files up). + /// - A terminal showing it may lack images (it is new, attached again, or back after images + /// went out without it): everything is sent again. + /// - tmux threw output away (`#{client_discarded}` rose): images went with it (tmux redraws + /// its text, but not what went through passthrough), so everything is sent again, up to + /// [`MAX_RESENDS`] times. + /// - More than one terminal shows it: files are over, as the first terminal to read one + /// deletes it. + fn ask_tmux(&mut self) -> bool { + self.tmux_check = Some(Instant::now() + TMUX_EVERY); + let (Some(watch), Some(report)) = (self.watch.as_mut(), tmux::report()) else { return false }; + let news = watch.update(&report, self.transmitted); + if news.showing > 1 { + self.files = None; + } + let was_hidden = std::mem::replace(&mut self.hidden, news.showing == 0); + if self.hidden { + if !was_hidden && let Some(files) = self.files.as_mut() { + files.forget(); + } + return false; + } + if news.discarded { + if self.resends >= MAX_RESENDS { + self.nav.message = Some("tmux is dropping images: the terminal can't keep up; r sends them again".into()); + return true; + } + self.resends = self.resends.saturating_add(1); + } + if news.discarded || news.missed { + self.nav.rerender = true; + return true; + } + false + } + + /// The terminal says it no longer has image `id`, which it was asked to place: it dropped it + /// to stay under its quota. Forget it, so it is sent again, and keep less in the terminal from + /// now on, up to [`MAX_RESENDS`] times (`r` still sends). Says whether the screen needs a + /// redraw: past that, only to say so once, since a redraw places the image again and draws + /// the same answer. + fn missing(&mut self, id: u32) -> bool { + const DROPPING: &str = "the terminal is dropping images; r sends them again"; + if !self.sent.iter().any(|s| s.id == id) { + return false; + } + if self.resends >= MAX_RESENDS { + let said = self.nav.message.as_deref() == Some(DROPPING); + self.nav.message = Some(DROPPING.into()); + return !said; + } + self.resends = self.resends.saturating_add(1); + self.budget = (self.budget / 2).max(MIN_BUDGET); + self.sent.retain(|s| s.id != id); + self.placed.retain(|(placed, _)| *placed != id); + true + } + + /// Before the screen is put back: wait a moment for the answers to the last placements, so + /// none of them reaches the command line. + fn await_answers(&self) { + let Some(at) = self.placed_at else { return }; + let deadline = at + ANSWER_WAIT; + while let Some(left) = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) { + if self.tty.read_timeout(left).is_err() { + break; + } + } + } + + /// Put the pages on screen in place of the ones before: centred, one page or two with a gap + /// (a last page alone keeps the left place). Each place on screen has a placement of its own, + /// so a page given twice shows twice. + fn place(&mut self, out: &mut Vec, size: WindowSize, area_rows: u16, cols: u16, pair: bool) { + let wrap = self.output.wrap(); + // Erasing text never erases images (only clearing the whole screen does, by the + // protocol): placements are what show and move them. + for row in 1..=size.rows { + out.extend_from_slice(format!("\x1b[{row};1H\x1b[2K").as_bytes()); + } + let sent = |v: Option| v.and_then(|v| self.sent.iter().position(|s| s.view == v)); + let at = [sent(self.on_screen[0]), sent(self.on_screen[1])]; + let width = |i: usize| at.get(i).copied().flatten().and_then(|p| self.sent.get(p)).map_or(cols, |s| s.cols); + let widths = [width(0), if pair { width(1) } else { 0 }]; + let total = if pair { widths[0].saturating_add(fit::TWO_UP_GAP).saturating_add(widths[1]) } else { widths[0] }; + let mut left = size.cols.saturating_sub(total) / 2; + let scroll = if pair { 0 } else { self.nav.scroll }; + let mut placed = Vec::new(); + for ((position, width), placement) in at.into_iter().zip(widths).zip(1u32..) { + if let Some(s) = position.and_then(|p| self.sent.get(p)) { + let visible = s.rows.min(area_rows); + let top = area_rows.saturating_sub(visible) / 2; + match self.output { + Output::Direct => { + cursor(out, top, left); + let src = (visible < s.rows).then(|| source_rows(s, scroll, visible)); + kitty::place(out, s.id, placement, s.cols, visible, src, wrap); + self.placed_at = Some(Instant::now()); + } + Output::Tmux(colour) => { + for row in 0..visible { + cursor(out, top.saturating_add(row), left); + // Within the placeholders' table: the layout keeps it so. + let _ = kitty::placeholder_row(out, s.id, colour, scroll.saturating_add(row), s.cols); + } + } + } + placed.push((s.id, placement)); + } + left = left.saturating_add(width).saturating_add(fit::TWO_UP_GAP); + } + // The pages before leave the screen; their data stays in the terminal for later. Inside + // tmux, erasing their placeholder cells already did it. + if self.output == Output::Direct { + for (id, placement) in self.placed.iter().filter(|p| !placed.contains(p)) { + kitty::hide(out, *id, *placement, wrap); + } + } + // The pages on screen are the most recently used: the last to be dropped. + for (id, _) in &placed { + if let Some(p) = self.sent.iter().position(|s| s.id == *id) { + let s = self.sent.remove(p); + self.sent.push(s); + } + } + self.placed = placed; + } + + /// The image rows `view` covers, once its layout is known. + fn rows(&self, view: &View) -> Option { + let sent = self.sent.iter().find(|s| s.view == *view).map(|s| s.rows); + sent.or_else(|| self.wanted.iter().find(|w| w.view == *view).map(|w| w.layout.rows)) + } + + /// Take finished renders and send them to the terminal. Says whether one of the pages on + /// screen arrived (the screen needs a redraw). + fn receive(&mut self) -> Result { + let wrap = self.output.wrap(); + let (mut any, mut on_screen) = (false, false); + while let Some(page) = self.pool.try_recv() { + // Otherwise a late result for a page no longer wanted. + let Some(at) = self.wanted.iter().position(|w| w.tag == page.request.tag) else { continue }; + let wanted = self.wanted.remove(at); + any = true; + on_screen |= self.on_screen.contains(&Some(wanted.view)); + let id = self.new_id(); + let (width, height) = (page.width, page.height); + match checked(&page).and_then(|warning| Ok((warning, self.payload(id, page)?))) { + Ok((warning, Some(payload))) => { + let mut out = Vec::new(); + let bytes = u64::from(width).saturating_mul(u64::from(height)).saturating_mul(4); + for gone in make_room(&mut self.sent, bytes, self.budget, &self.on_screen) { + kitty::delete(&mut out, gone, wrap); + self.placed.retain(|(placed, _)| *placed != gone); + } + let (cols, rows) = (wanted.layout.cols, wanted.layout.rows); + match &payload { + Payload::Png(png) => kitty::transmit(&mut out, id, png, wrap), + Payload::File(path) => kitty::transmit_file(&mut out, id, path, width, height, wrap), + } + if let Output::Tmux(_) = self.output { + kitty::place_virtual(&mut out, id, cols, rows, wrap); + } + self.wrote(1); + self.tty.write_all(&out)?; + self.sent.push(Sent { view: wanted.view, id, cols, rows, width, height, warning }); + } + // The files' thread kept the page: the next draw asks for it again. + Ok((_, None)) => on_screen = true, + Err(e) => self.fail(wanted.view, e), + } + // A page the reader waits for goes on screen before any more work ahead is encoded; + // the other finished renders wait in the pool for the next round. + if on_screen { + break; + } + } + if any { + self.requeue(); + } + Ok(on_screen) + } + + /// `page`'s pixels ready to send as image `id`: in a temporary file when the terminal takes + /// them, otherwise as a PNG. A file that can't be written ends the files, and says why. + /// `None` when the files' thread stopped answering with the page. + fn payload(&mut self, id: u32, page: RenderedPage) -> Result, String> { + let now = Instant::now(); + let mut page = page; + if let Some(files) = self.files.as_mut() + && files.ready(now) + { + match files.write(id, page, now) { + Written::File(path) => return Ok(Some(Payload::File(path))), + Written::Failed(e, back) => { + self.nav.message = Some(format!("{e}; sending pages in the terminal stream instead")); + page = back; + } + Written::Stalled => return Ok(None), + } + } + png(&page, self.effort).map(|png| Some(Payload::Png(png))) + } + + /// Forget the pages whose files the terminal never took, so they are sent again when wanted, + /// and end the files if their thread stopped answering. Says whether there were any (the + /// screen needs a redraw). + fn lost_files(&mut self) -> Result { + let Some(files) = self.files.as_mut() else { return Ok(false) }; + let lost = files.check(Instant::now()); + if files.stalled() { + self.nav.message = Some("the temporary directory stopped answering; sending pages in the terminal stream instead".into()); + self.stalled_files = self.files.take(); + } + if lost.is_empty() { + return Ok(false); + } + // The terminal may have taken one after all, late: none is left behind in it. + let mut out = Vec::new(); + for id in &lost { + kitty::delete(&mut out, *id, self.output.wrap()); + } + self.wrote(0); + self.tty.write_all(&out)?; + self.sent.retain(|s| !lost.contains(&s.id)); + self.placed.retain(|(id, _)| !lost.contains(id)); + Ok(true) + } + + fn fail(&mut self, view: View, why: String) { + if self.failed.len() >= MAX_FAILED { + self.failed.remove(0); + } + self.failed.push((view, why)); + } + + /// The next image id: one never used in this run until the ids wrap (after 65,280 of them in + /// the palette, far more than the pages kept). + fn new_id(&mut self) -> u32 { + let id = self.output.ids().id(self.first_id.wrapping_add(self.next_id)); + self.next_id = self.next_id.wrapping_add(1); + id + } + + /// Tell the pool which renders are still wanted, most urgent first: it keeps those and + /// cancels the rest. + fn requeue(&self) { + self.pool.set_queue(self.wanted.iter().map(|w| request(w.view.page, &w.layout, w.tag)).collect()); + } +} + +/// Room in the terminal for a page of `incoming` bytes: the least recently shown pages not +/// `on_screen` go while keeping them would make more than [`MAX_SENT`] pages, or more than +/// `budget` bytes. The ids of the ones that go. +fn make_room(sent: &mut Vec, incoming: u64, budget: u64, on_screen: &[Option; 2]) -> Vec { + let mut gone = Vec::new(); + loop { + let bytes = sent.iter().map(Sent::bytes).fold(incoming, u64::saturating_add); + if sent.len() < MAX_SENT && bytes <= budget { + break; + } + let Some(at) = sent.iter().position(|s| !on_screen.contains(&Some(s.view))) else { break }; + gone.push(sent.remove(at).id); + } + gone +} + +/// The bytes of pixels a page `size` points large renders to at `scale` pixels per point. +fn pixel_bytes((w, h): (f32, f32), scale: f32) -> u64 { + // Finite and clamped to the largest side a layout allows, so the cast is exact. + let side = |points: f32| { + let pixels = (points * scale).ceil(); + if pixels.is_finite() { pixels.clamp(0.0, fit::MAX_PIXELS) as u64 } else { 0 } + }; + side(w).saturating_mul(side(h)).saturating_mul(4) +} + +/// What the status line says about the pages on screen, the first page's first: why one failed, +/// or what was skipped on one. +fn note(on_screen: &[Option; 2], sent: &[Sent], failed: &[(View, String)]) -> Option { + on_screen.iter().flatten().find_map(|v| { + let failure = failed.iter().find(|(f, _)| f == v).map(|(_, e)| e.clone()); + failure.or_else(|| sent.iter().find(|s| s.view == *v).and_then(|s| s.warning.clone())) + }) +} + +/// Move the cursor to 0-based `row` and `col`. +fn cursor(out: &mut Vec, row: u16, col: u16) { + out.extend_from_slice(format!("\x1b[{};{}H", u32::from(row) + 1, u32::from(col) + 1).as_bytes()); +} + +/// The pixels of `shown` that `visible` cell rows starting at row `scroll` cover. +fn source_rows(shown: &Sent, scroll: u16, visible: u16) -> Rect { + let per_row = f64::from(shown.height) / f64::from(shown.rows.max(1)); + let height = f64::from(shown.height); + // Finite, and clamped to the image's height, so the casts are exact. + let y = (f64::from(scroll) * per_row).round().clamp(0.0, height); + let h = (f64::from(visible) * per_row).round().clamp(0.0, height - y); + Rect { x: 0, y: y as u32, w: shown.width, h: h as u32 } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader at `at` in a `count`-page document, after a draw that showed `visible` of a + /// page's `rows` rows, one page to the screen. + fn reader(at: usize, count: usize, visible: u16, rows: u16) -> Nav { + let mut nav = Nav::new((0..count).collect(), count, Fit::Page); + nav.go(at); + nav.shown(1, visible, rows); + nav + } + + /// The page on screen (the first, when two are). + fn page(nav: &Nav) -> usize { + nav.on_screen()[0].unwrap() + } + + fn press(nav: &mut Nav, keys: &[Key]) -> Step { + keys.iter().map(|k| nav.key(*k)).find(|s| *s == Step::Quit).unwrap_or(Step::Continue) + } + + #[test] + fn space_turns_pages_that_fit_and_stops_at_the_last() { + let mut nav = reader(0, 3, 40, 40); + press(&mut nav, &[Key::Char(' ')]); + assert_eq!((page(&nav), nav.scroll), (1, 0)); + press(&mut nav, &[Key::PageDown, Key::Char(' '), Key::Char(' ')]); + assert_eq!(page(&nav), 2); + press(&mut nav, &[Key::Char('b')]); + assert_eq!(page(&nav), 1); + } + + #[test] + fn a_tall_page_scrolls_before_the_next_one() { + // 30 of 100 rows on screen: space scrolls 30, 30, then the last 10, then turns the page. + let mut nav = reader(0, 2, 30, 100); + for expected in [30, 60, 70] { + press(&mut nav, &[Key::Char(' ')]); + assert_eq!((page(&nav), nav.scroll), (0, expected)); + } + press(&mut nav, &[Key::Char(' ')]); + assert_eq!((page(&nav), nav.scroll), (1, 0)); + // Back from the top of page 2 lands on the bottom of page 1, once the draw clamps it. + press(&mut nav, &[Key::Char('b')]); + assert_eq!(page(&nav), 0); + nav.shown(1, 30, 100); + assert_eq!(nav.scroll, 70); + press(&mut nav, &[Key::Char('k')]); + assert_eq!(nav.scroll, 70 - STEP); + press(&mut nav, &[Key::Char('j')]); + assert_eq!((page(&nav), nav.scroll), (0, 70)); + // At the bottom, a row more is the next page. + press(&mut nav, &[Key::Down]); + assert_eq!((page(&nav), nav.scroll), (1, 0)); + nav.shown(1, 30, 100); + press(&mut nav, &[Key::Enter]); + assert_eq!((page(&nav), nav.scroll), (1, STEP)); + } + + #[test] + fn page_keys_jump() { + let mut nav = reader(4, 10, 40, 40); + press(&mut nav, &[Key::Char('n')]); + assert_eq!(page(&nav), 5); + press(&mut nav, &[Key::Left, Key::Char('p')]); + assert_eq!(page(&nav), 3); + press(&mut nav, &[Key::Char('G')]); + assert_eq!(page(&nav), 9); + press(&mut nav, &[Key::Right]); + assert_eq!(page(&nav), 9, "past the last page stays on it"); + press(&mut nav, &[Key::Home]); + assert_eq!(page(&nav), 0); + press(&mut nav, &[Key::Left]); + assert_eq!(page(&nav), 0); + } + + #[test] + fn two_pages_side_by_side_turn_like_a_book() { + let mut nav = reader(0, 5, 40, 40); + nav.shown(2, 40, 40); + assert_eq!(nav.on_screen(), [Some(0), Some(1)]); + for (keys, expected) in [ + (&[Key::Char(' ')][..], [Some(2), Some(3)]), + (&[Key::Char('n')], [Some(4), None]), + (&[Key::Char('n')], [Some(4), None]), + (&[Key::Char('b')], [Some(2), Some(3)]), + (&[Key::Char('p')], [Some(0), Some(1)]), + (&[Key::Char('p')], [Some(0), Some(1)]), + (&[Key::Char('G')], [Some(4), None]), + ] { + press(&mut nav, keys); + assert_eq!(nav.on_screen(), expected, "{keys:?}"); + } + // A typed page shows the spread it is on: page 4 is the right page of 3–4. + press(&mut nav, &[Key::Char('4'), Key::Enter]); + assert_eq!(nav.on_screen(), [Some(2), Some(3)]); + // One to a screen again from where the reader is, at once: a key before the next draw + // steps by one page. + press(&mut nav, &[Key::Char('d')]); + assert!(!nav.two_up); + assert_eq!(nav.on_screen(), [Some(3), None]); + press(&mut nav, &[Key::Char('p')]); + assert_eq!(nav.on_screen(), [Some(2), None]); + // Fitting the width shows one page too. + press(&mut nav, &[Key::Char('d')]); + nav.shown(2, 40, 40); + press(&mut nav, &[Key::Char('f'), Key::Char('n')]); + assert_eq!(nav.on_screen(), [Some(3), None]); + } + + #[test] + fn the_pages_worked_on_ahead_are_the_next_screens_then_the_one_before() { + let mut nav = reader(4, 10, 40, 40); + assert_eq!(nav.around(2, 1), [5, 6, 3]); + // Two to a screen, 4–5 on it. + nav.shown(2, 40, 40); + assert_eq!(nav.around(2, 1), [6, 7, 8, 9, 2, 3]); + // At the ends, only what exists. + assert_eq!(reader(9, 10, 40, 40).around(2, 1), [8]); + assert!(reader(0, 1, 40, 40).around(2, 1).is_empty()); + // However many screens are asked for, the ends stop it. + assert!(Nav::new(Vec::new(), 0, Fit::Page).around(usize::MAX, usize::MAX).is_empty()); + assert_eq!(reader(1, 3, 40, 40).around(usize::MAX, usize::MAX), [2, 0]); + } + + #[test] + fn pages_given_are_the_only_ones_paged_through() { + let mut nav = Nav::new(vec![6, 2, 9], 12, Fit::Page); + nav.shown(1, 40, 40); + assert_eq!(page(&nav), 6); + press(&mut nav, &[Key::Char(' ')]); + assert_eq!(page(&nav), 2); + press(&mut nav, &[Key::Char('G')]); + assert_eq!(page(&nav), 9); + press(&mut nav, &[Key::Char('7'), Key::Enter]); + assert_eq!(page(&nav), 6); + press(&mut nav, &[Key::Char('5'), Key::Enter]); + assert_eq!(page(&nav), 6); + assert_eq!(nav.message.as_deref(), Some("page 5 isn't one of the pages given")); + assert!(nav.status("a.pdf", 120, false).contains(" page 7/12 · fit page · 1 of 3 ")); + } + + #[test] + fn a_typed_number_goes_to_that_page() { + let mut nav = reader(0, 12, 40, 40); + press(&mut nav, &[Key::Char('1'), Key::Char('2'), Key::Enter]); + assert_eq!((page(&nav), nav.typed.as_str()), (11, "")); + press(&mut nav, &[Key::Char('3'), Key::Char('g')]); + assert_eq!(page(&nav), 2); + press(&mut nav, &[Key::Char('9'), Key::Char('9'), Key::Enter]); + assert_eq!(page(&nav), 2); + assert_eq!(nav.message.as_deref(), Some("no page 99 (1–12)")); + press(&mut nav, &[Key::Char('0'), Key::Enter]); + assert_eq!(page(&nav), 2); + // Backspace and Esc edit and cancel; Esc with nothing typed quits. + press(&mut nav, &[Key::Char('5'), Key::Char('1'), Key::Backspace, Key::Enter]); + assert_eq!(page(&nav), 4); + assert_eq!(press(&mut nav, &[Key::Char('7'), Key::Escape]), Step::Continue); + assert_eq!(nav.typed, ""); + assert_eq!(press(&mut nav, &[Key::Escape]), Step::Quit); + // Long numbers stop growing and simply name no page. + let mut nav = reader(0, 12, 40, 40); + press(&mut nav, &[Key::Char('9'); 30]); + assert_eq!(nav.typed.len(), MAX_TYPED); + } + + #[test] + fn q_and_ctrl_c_quit_and_f_switches_the_fit() { + let mut nav = reader(0, 2, 30, 100); + press(&mut nav, &[Key::Char(' '), Key::Char('f')]); + assert_eq!((nav.fit, nav.scroll), (Fit::Width, 0)); + press(&mut nav, &[Key::Char('f')]); + assert_eq!(nav.fit, Fit::Page); + press(&mut nav, &[Key::Char('r')]); + assert!(nav.rerender); + assert_eq!(press(&mut nav, &[Key::Char('q')]), Step::Quit); + assert_eq!(press(&mut nav, &[Key::Interrupt]), Step::Quit); + assert_eq!(press(&mut nav, &[Key::Char('x'), Key::PageUp, Key::End]), Step::Continue); + } + + #[test] + fn an_empty_list_shows_no_page_and_never_panics() { + let mut nav = Nav::new(Vec::new(), 0, Fit::Page); + nav.shown(2, 1, 1); + assert_eq!(nav.on_screen(), [None, None]); + press(&mut nav, &[Key::Char(' '), Key::Char('b'), Key::Char('n'), Key::Char('p'), Key::Char('G'), Key::Char('1'), Key::Enter]); + assert!(nav.status("a.pdf", 80, false).contains("no page")); + } + + #[test] + fn the_status_line_fills_the_width_exactly() { + let nav = reader(2, 12, 40, 40); + for cols in [0, 1, 10, 40, 80, 200] { + let line = nav.status("report.pdf", cols, false); + assert_eq!(line.chars().count(), usize::from(cols), "{line:?}"); + } + let wide = nav.status("report.pdf", 120, false); + assert!(wide.starts_with(" report.pdf · page 3/12 · fit page ") && wide.ends_with("q quit "), "{wide:?}"); + let mut two = reader(2, 12, 40, 40); + two.shown(2, 40, 40); + assert!(two.status("report.pdf", 120, false).starts_with(" report.pdf · pages 3–4/12 · fit page ")); + let mut typing = nav; + typing.key(Key::Char('7')); + assert!(typing.status("a.pdf", 80, false).contains("go to page 7")); + } + + #[test] + fn a_narrow_status_line_keeps_its_two_sides_apart() { + let nav = reader(4, 13, 40, 40); + let left = " pdfcraft-showcase.pdf · page 5/13 · fit page "; + // Half a wide window: the shortest reminder of the keys, two spaces from the page. + let half = nav.status("pdfcraft-showcase.pdf", 70, false); + assert!(half.starts_with(left) && half.ends_with(" n p · q quit "), "{half:?}"); + for cols in 0..=120 { + let line = nav.status("pdfcraft-showcase.pdf", cols, false); + assert_eq!(line.chars().count(), usize::from(cols), "{line:?}"); + // Whatever follows the page is a whole reminder of the keys, two spaces or more after + // it (`left` ends with one), or nothing. + if let Some(rest) = line.strip_prefix(left) { + let keys = rest.trim_start(); + assert!(keys.is_empty() || (rest.starts_with(' ') && KEYS.iter().any(|k| keys == format!("{k} "))), "{line:?}"); + } + } + // The file name gives way to the page, and to a message, which stays whole. + let long = "a file name long enough to fill most of the window.pdf"; + assert_eq!(nav.status(long, 50, false), " a file name long enough… · page 5/13 · fit page "); + let mut told = nav; + told.message = Some("no page 99 (1–13)".into()); + assert_eq!(told.status(long, 60, false), " a file name lon… · page 5/13 · fit page no page 99 (1–13) "); + } + + #[test] + fn the_status_line_never_carries_an_escape_sequence() { + let mut nav = reader(0, 3, 40, 40); + // A file name and an error message that would write to the clipboard (OSC 52). + nav.message = Some("page 1: renderer crashed: \x1b]52;c;cHduZWQ=\x07".into()); + let line = nav.status("evil\x1b]52;c;cHduZWQ=\x07.pdf", 200, false); + assert!(!line.chars().any(char::is_control), "{line:?}"); + assert_eq!(line.chars().count(), 200); + nav.message = None; + assert!(nav.status("a.pdf", 80, true).contains("rendering…")); + } + + /// Page `page` sent as image `id`, `mb` megabytes of pixels, with `warning`. + fn sent(page: usize, id: u32, mb: u32, warning: Option<&str>) -> Sent { + let view = View { page, fit: Fit::Page, cols: 50, size: FALLBACK_SIZE }; + Sent { view, id, cols: 50, rows: 40, width: 1024, height: mb * 256, warning: warning.map(String::from) } + } + + #[test] + fn pages_kept_in_the_terminal_stay_under_the_count_and_the_budget() { + let mut kept: Vec = (0..4).map(|p| sent(p, p as u32 + 1, 10, None)).collect(); + let screen = [Some(kept[0].view), None]; + // 40 MB kept, 10 more coming: under 64, nothing goes. + assert!(make_room(&mut kept, 10 << 20, 64 << 20, &screen).is_empty()); + // Under 32: the least recently shown go first, never the page on screen. + assert_eq!(make_room(&mut kept, 10 << 20, 32 << 20, &screen), [2, 3]); + assert_eq!(kept.iter().map(|s| s.id).collect::>(), [1, 4]); + // A page bigger than the whole budget: all but the screen go, and it goes in anyway. + assert_eq!(make_room(&mut kept, 100 << 20, 32 << 20, &screen), [4]); + assert_eq!(kept.len(), 1); + // And never more than MAX_SENT pages, however small. + let mut many: Vec = (0..MAX_SENT).map(|p| sent(p, p as u32, 1, None)).collect(); + assert_eq!(make_room(&mut many, 1, u64::MAX, &[None, None]), [0]); + // Sizes that would overflow don't. + let mut huge = vec![Sent { width: u32::MAX, height: u32::MAX, ..sent(0, 1, 1, None) }]; + assert_eq!(make_room(&mut huge, u64::MAX, BUDGET, &[None, None]), [1]); + } + + #[test] + fn a_page_takes_four_bytes_a_pixel() { + // US Letter at 2 px per point: 1224 × 1584 pixels. + assert_eq!(pixel_bytes((612.0, 792.0), 2.0), 1224 * 1584 * 4); + assert_eq!(pixel_bytes((0.5, 0.5), 1.0), 4, "a part of a pixel is a pixel"); + assert_eq!(pixel_bytes((f32::MAX, 1.0), 2.0), 0, "an endless side counts for nothing"); + assert_eq!(pixel_bytes((1e6, 1e6), 1.0), 8192 * 8192 * 4); + assert_eq!(pixel_bytes((f32::NAN, -5.0), 1.0), 0); + } + + #[test] + fn the_status_line_names_what_is_wrong_with_a_page_on_screen_whenever_it_is() { + let kept = [sent(0, 1, 1, None), sent(1, 2, 1, Some("page 2: content past the page's safety budget was skipped"))]; + let view = |page| Some(View { page, fit: Fit::Page, cols: 50, size: FALLBACK_SIZE }); + // Page 2 was rendered ahead with something skipped: said when it shows, not before. + assert_eq!(note(&[view(0), None], &kept, &[]), None); + assert_eq!(note(&[view(1), None], &kept, &[]).as_deref(), Some("page 2: content past the page's safety budget was skipped")); + assert!(note(&[view(0), view(1)], &kept, &[]).is_some()); + // A failure first. + let failed = [(view(0).unwrap(), "page 1: broken".to_string())]; + assert_eq!(note(&[view(0), view(1)], &kept, &failed).as_deref(), Some("page 1: broken")); + } + + #[test] + fn source_rows_follow_the_scroll() { + let view = View { page: 0, fit: Fit::Width, cols: 50, size: FALLBACK_SIZE }; + let shown = Sent { view, id: 1, cols: 50, rows: 100, width: 1000, height: 2000, warning: None }; + assert_eq!(source_rows(&shown, 0, 30), Rect { x: 0, y: 0, w: 1000, h: 600 }); + assert_eq!(source_rows(&shown, 70, 30), Rect { x: 0, y: 1400, w: 1000, h: 600 }); + // Never past the bottom, whatever it is asked. + let past = source_rows(&shown, u16::MAX, 30); + assert_eq!((past.y, past.h), (2000, 0)); + let empty = Sent { rows: 0, height: 0, ..shown }; + assert_eq!(source_rows(&empty, 5, 5), Rect { x: 0, y: 0, w: 1000, h: 0 }); + } +} diff --git a/apps/pdfcraft-cli/src/view/tmux.rs b/apps/pdfcraft-cli/src/view/tmux.rs new file mode 100644 index 0000000000..ee284b4444 --- /dev/null +++ b/apps/pdfcraft-cli/src/view/tmux.rs @@ -0,0 +1,361 @@ +//! Drawing from inside tmux. tmux keeps its own copy of the screen and knows nothing of images, +//! so `view` sends its escape codes through tmux's passthrough (`allow-passthrough`, `man tmux`) +//! and draws Unicode placeholders that tmux keeps as text (see `kitty.rs`). Inside tmux, `TERM` +//! names tmux, not the terminal, so tmux is asked which terminal its client is. + +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// How long a `tmux` command may take before `view` gives up on it. +const TIMEOUT: Duration = Duration::from_secs(2); + +/// Whether this process runs inside tmux: tmux sets `TMUX` in its panes. +pub(crate) fn inside() -> bool { + std::env::var_os("TMUX").is_some_and(|v| !v.is_empty()) +} + +/// What tmux says about the terminal its client runs in. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Client { + /// `#{client_termname}`: the terminal's `TERM` outside tmux, such as `xterm-kitty`. + pub(crate) termname: String, + /// `RGB` is among `#{client_termfeatures}`: tmux passes 24-bit colours through unchanged. + pub(crate) rgb: bool, + /// `#{client_cell_width}` × `#{client_cell_height}`: the terminal's cell in pixels, when tmux + /// knows it (tmux 3.4 and later). + pub(crate) cell: Option<(u16, u16)>, +} + +pub(crate) fn client() -> Result { + tmux(&["display-message", "-p", "#{client_termname}|#{client_termfeatures}|#{client_cell_width}|#{client_cell_height}"]) + .map(|out| parse_client(&out)) +} + +/// `display-message -p` output for the format in [`client`]. Fields an older tmux leaves empty +/// are missing, never errors. +fn parse_client(out: &str) -> Client { + let line = out.lines().next().unwrap_or_default(); + let mut fields = line.split('|'); + let termname = fields.next().unwrap_or_default().trim().to_string(); + let rgb = fields.next().unwrap_or_default().split(',').any(|f| f.trim() == "RGB"); + let mut pixels = || fields.next().and_then(|v| v.trim().parse::().ok()).filter(|v| *v > 0); + let cell = pixels().zip(pixels()); + Client { termname, rgb, cell } +} + +/// Make sure tmux passes this pane's escape codes through: `allow-passthrough` must be `on` or +/// `all` (tmux 3.3 and later drop them otherwise). Turns it on for this pane only when it is off, +/// and says whether it did. +pub(crate) fn ensure_passthrough() -> Result { + let pane = this_pane(); + let target = target(pane.as_deref()); + let show = tmux(&[&["show-options", "-Ap"][..], &target, &["allow-passthrough"]].concat())?; + if passthrough_allowed(&show) { + return Ok(false); + } + tmux(&[&["set-option", "-p"][..], &target, &["allow-passthrough", "on"]].concat())?; + Ok(true) +} + +/// The pane `view` runs in, as tmux names it in `TMUX_PANE`. +fn this_pane() -> Option { + std::env::var("TMUX_PANE").ok().filter(|pane| !pane.is_empty()) +} + +/// `-t pane`, or nothing (tmux's current pane) when the pane isn't known. +fn target(pane: Option<&str>) -> Vec<&str> { + pane.map_or_else(Vec::new, |pane| vec!["-t", pane]) +} + +/// `show-options -Ap allow-passthrough` output, such as `allow-passthrough* off`; the `*` marks +/// a value inherited from the window or the global options. +fn passthrough_allowed(show: &str) -> bool { + matches!(show.split_whitespace().last(), Some("on" | "all")) +} + +/// The most clients looked at, and remembered. +const MAX_CLIENTS: usize = 64; + +/// What tmux says about the pane `view` runs in and the clients attached. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Report { + /// The pane's window, as `@3`. + window: String, + /// The window is zoomed on another pane, so this one doesn't show. + zoomed_away: bool, + clients: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ClientReport { + /// `#{client_name}` and `#{client_pid}`: a client that attaches again is another one. + who: String, + /// `#{client_discarded}`: the bytes tmux has thrown away for it because its terminal fell + /// behind. Escape codes go with them, images included. + discarded: Option, + /// The window it shows. + window: String, +} + +/// Ask tmux about the pane and its clients; `None` when it doesn't answer. +pub(crate) fn report() -> Option { + let pane = this_pane(); + let args = [ + &["display-message", "-p"][..], + &target(pane.as_deref()), + &[ + "#{window_id}|#{window_zoomed_flag}|#{pane_active}", + ";", + "list-clients", + "-F", + "#{client_name}|#{client_pid}|#{client_discarded}|#{window_id}", + ], + ] + .concat(); + tmux(&args).ok().and_then(|out| parse_report(&out)) +} + +/// The output of the commands in [`report`]: the pane's line, then a line per client (the first +/// [`MAX_CLIENTS`]). +fn parse_report(out: &str) -> Option { + let mut lines = out.lines(); + let mut pane = lines.next()?.split('|').map(str::trim); + let window = pane.next().filter(|w| !w.is_empty())?.to_string(); + let zoomed_away = pane.next() == Some("1") && pane.next() == Some("0"); + let clients = lines + .take(MAX_CLIENTS) + .filter_map(|line| { + let mut fields = line.split('|').map(str::trim); + let (name, pid) = (fields.next()?, fields.next()?); + let discarded = fields.next().and_then(|d| d.parse().ok()); + let window = fields.next()?.to_string(); + Some(ClientReport { who: format!("{name}|{pid}"), discarded, window }) + }) + .collect(); + Some(Report { window, zoomed_away, clients }) +} + +/// Whether what the pager sends through tmux reaches every terminal showing the pane, from one +/// [`Report`] to the next. tmux passes escape codes through to every client showing the pane +/// and to none while it is hidden (`allow-passthrough on`), and what doesn't arrive never will. +#[derive(Debug, Default)] +pub(crate) struct Watch { + /// Every client seen showing the pane (the latest [`MAX_CLIENTS`]). + seen: Vec, +} + +#[derive(Debug)] +struct Seen { + who: String, + /// Whether it showed the pane at the last report, and how many images had been sent then. + showing: bool, + sent: u64, + discarded: Option, +} + +/// What a report means for the images sent. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct News { + /// How many terminals show the pane: none while it is hidden. + pub(crate) showing: usize, + /// One of them may lack images: it is new (or attached again: the terminal has dropped what + /// it had), or it shows the pane again after images went out while it didn't. + pub(crate) missed: bool, + /// tmux threw output away for one of them. + pub(crate) discarded: bool, +} + +impl Watch { + /// Take in `report`, `sent` images having been sent so far. + pub(crate) fn update(&mut self, report: &Report, sent: u64) -> News { + let showing: Vec<&ClientReport> = + if report.zoomed_away { Vec::new() } else { report.clients.iter().filter(|c| c.window == report.window).collect() }; + let mut news = News { showing: showing.len(), ..News::default() }; + for client in &showing { + match self.seen.iter_mut().find(|s| s.who == client.who) { + Some(seen) => { + // Not showing the pane at the last report, with images sent since. + news.missed |= !seen.showing && seen.sent < sent; + news.discarded |= matches!((seen.discarded, client.discarded), (Some(before), Some(now)) if now > before); + seen.discarded = client.discarded; + seen.sent = sent; + } + None => { + news.missed |= sent > 0; + if self.seen.len() >= MAX_CLIENTS { + self.seen.remove(0); + } + self.seen.push(Seen { who: client.who.clone(), showing: true, sent, discarded: client.discarded }); + } + } + } + for seen in &mut self.seen { + seen.showing = showing.iter().any(|c| c.who == seen.who); + } + news + } +} + +/// Whether the tmux client was attached over SSH, by the session's `SSH_CONNECTION`: tmux +/// updates it at every attach (`update-environment`), while the pane's own environment dates +/// from when the pane started. `None` when tmux doesn't say. +pub(crate) fn attached_over_ssh() -> Option { + tmux(&["show-environment", "SSH_CONNECTION"]).ok().and_then(|out| parse_ssh(&out)) +} + +/// `show-environment SSH_CONNECTION` output: `SSH_CONNECTION=…` when the client came over SSH, +/// `-SSH_CONNECTION` when it didn't (tmux removed it at the attach). +fn parse_ssh(out: &str) -> Option { + let line = out.lines().next()?.trim(); + if line.starts_with("SSH_CONNECTION=") { + Some(true) + } else if line == "-SSH_CONNECTION" { + Some(false) + } else { + None + } +} + +/// Run `tmux` with `args` and return its output, or say why not. Bounded however tmux behaves: +/// [`TIMEOUT`] covers both its exit and the end of its output (a process it left behind could +/// keep the pipes open), and at most [`MAX_OUTPUT`] bytes of each pipe are read. +fn tmux(args: &[&str]) -> Result { + let what = || format!("tmux {}", args.join(" ")); + let no_answer = || format!("{}: no answer within {} s", what(), TIMEOUT.as_secs()); + let deadline = Instant::now() + TIMEOUT; + let mut child = Command::new("tmux") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("{}: {e}", what()))?; + let (stdout, stderr) = (child.stdout.take(), child.stderr.take()); + let (sender, receiver) = std::sync::mpsc::channel(); + // A reader left blocked past the deadline ends with the process. + let reader = std::thread::Builder::new().spawn(move || { + let _ = sender.send((read_capped(stdout), read_capped(stderr))); + }); + if let Err(e) = reader { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("{}: {e}", what())); + } + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(no_answer()); + } + Err(e) => return Err(format!("{}: {e}", what())), + } + }; + let (out, err) = receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())).map_err(|_| no_answer())?; + if !status.success() { + return Err(format!("{}: {}", what(), String::from_utf8_lossy(&err).trim())); + } + Ok(String::from_utf8_lossy(&out).into_owned()) +} + +/// The most bytes read from each of tmux's pipes: its answers here are a line. +const MAX_OUTPUT: u64 = 64 * 1024; + +/// Up to [`MAX_OUTPUT`] bytes from `pipe`, until it closes. +fn read_capped(pipe: Option) -> Vec { + let mut bytes = Vec::new(); + if let Some(pipe) = pipe { + let _ = pipe.take(MAX_OUTPUT).read_to_end(&mut bytes); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_client_names_its_terminal_colours_and_cells() { + // As tmux 3.5a reports a kitty 0.45 client on a Retina display. + let kitty = parse_client("xterm-kitty|bpaste,ccolour,clipboard,cstyle,focus,RGB,title|18|46\n"); + assert_eq!(kitty, Client { termname: "xterm-kitty".into(), rgb: true, cell: Some((18, 46)) }); + let plain = parse_client("xterm-256color|bpaste,title|0|0\n"); + assert_eq!(plain, Client { termname: "xterm-256color".into(), rgb: false, cell: None }); + // An older tmux leaves the formats it doesn't know empty. + assert_eq!(parse_client("xterm-ghostty|||\n"), Client { termname: "xterm-ghostty".into(), rgb: false, cell: None }); + assert_eq!(parse_client("xterm-kitty|RGB|18|\n").cell, None); + assert_eq!(parse_client(""), Client::default()); + assert_eq!(parse_client("no separator").termname, "no separator"); + assert_eq!(parse_client("a|b|99999|x|more").cell, None); + } + + /// A report: the pane in window `@1` (zoomed on another pane when `zoomed_away`), and clients + /// `(who, discarded, window)`. + fn report(zoomed_away: bool, clients: &[(&str, u64, &str)]) -> Report { + let clients = + clients.iter().map(|(who, d, w)| ClientReport { who: format!("{who}|1"), discarded: Some(*d), window: w.to_string() }).collect(); + Report { window: "@1".into(), zoomed_away, clients } + } + + #[test] + fn tmux_reports_the_pane_and_its_clients() { + // As tmux 3.5a answers: the pane's window, not zoomed; two clients, the second in a + // grouped session showing another window. + let out = "@0|0|1\n/dev/ttys031|4242|0|@0\n/dev/ttys033|4250|512|@1\n"; + let report = parse_report(out).unwrap(); + assert_eq!((report.window.as_str(), report.zoomed_away), ("@0", false)); + assert_eq!(report.clients[1], ClientReport { who: "/dev/ttys033|4250".into(), discarded: Some(512), window: "@1".into() }); + assert!(parse_report("@0|1|0\n").unwrap().zoomed_away); + assert!(!parse_report("@0|1|1\n").unwrap().zoomed_away, "zoomed on this pane"); + // No client: nothing discarded to tell. Lines short of a window are no clients. + assert_eq!(parse_report("@0|0|1\n/dev/ttys031|1||@0\nbroken\n").unwrap().clients.len(), 1); + assert_eq!(parse_report(""), None); + assert_eq!(parse_report("|0|1"), None); + let many = format!("@0|0|1\n{}", "c|1|0|@0\n".repeat(1000)); + assert_eq!(parse_report(&many).unwrap().clients.len(), MAX_CLIENTS); + } + + #[test] + fn a_terminal_that_may_lack_images_is_noticed() { + let mut watch = Watch::default(); + // One terminal from the start, before any image. + assert_eq!(watch.update(&report(false, &[("a", 0, "@1")]), 0), News { showing: 1, missed: false, discarded: false }); + assert!(!watch.update(&report(false, &[("a", 0, "@1")]), 5).missed); + // It shows another window, and images go out: back, it lacks them. + assert_eq!(watch.update(&report(false, &[("a", 0, "@2")]), 5).showing, 0); + assert_eq!(watch.update(&report(false, &[("a", 0, "@2")]), 7).showing, 0); + assert!(watch.update(&report(false, &[("a", 0, "@1")]), 7).missed); + // Away and back with nothing sent meanwhile: it has everything. + watch.update(&report(false, &[("a", 0, "@2")]), 7); + assert!(!watch.update(&report(false, &[("a", 0, "@1")]), 7).missed); + // Zoomed on another pane, the pane is hidden all the same. + assert_eq!(watch.update(&report(true, &[("a", 0, "@1")]), 7).showing, 0); + assert!(!watch.update(&report(false, &[("a", 0, "@1")]), 7).missed); + // A second terminal comes: it has none of the images. + assert_eq!(watch.update(&report(false, &[("a", 0, "@1"), ("b", 0, "@1")]), 7), News { showing: 2, missed: true, discarded: false }); + // tmux throws output away for one of them. + assert!(watch.update(&report(false, &[("a", 0, "@1"), ("b", 9, "@1")]), 8).discarded); + assert_eq!(watch.update(&report(false, &[("a", 0, "@1"), ("b", 9, "@1")]), 8), News { showing: 2, missed: false, discarded: false }); + } + + #[test] + fn the_session_says_whether_its_client_came_over_ssh() { + // As tmux 3.5a answers for a client attached locally. + assert_eq!(parse_ssh("-SSH_CONNECTION\n"), Some(false)); + assert_eq!(parse_ssh("SSH_CONNECTION=192.0.2.7 50122 192.0.2.1 22\n"), Some(true)); + assert_eq!(parse_ssh(""), None); + assert_eq!(parse_ssh("SSH_CONNECTIONS=x"), None); + } + + #[test] + fn passthrough_is_allowed_when_on_or_all() { + assert!(passthrough_allowed("allow-passthrough on\n")); + assert!(passthrough_allowed("allow-passthrough* all\n")); + assert!(!passthrough_allowed("allow-passthrough* off\n")); + assert!(!passthrough_allowed("")); + assert!(!passthrough_allowed("unknown option: allow-passthrough")); + } +} diff --git a/apps/pdfcraft-cli/src/view/tty.rs b/apps/pdfcraft-cli/src/view/tty.rs new file mode 100644 index 0000000000..8c742915da --- /dev/null +++ b/apps/pdfcraft-cli/src/view/tty.rs @@ -0,0 +1,128 @@ +//! The controlling terminal (`/dev/tty`): its size, raw mode, and reads that give up after a +//! timeout. Opened separately from stdin and stdout, so it works with either redirected. +//! +//! Reads block on a thread of their own and reach callers through a channel, which they wait on +//! with a timeout. `poll(2)` can't wait on the terminal portably: on macOS it doesn't support +//! devices, and on `/dev/tty` it answers at once with `POLLNVAL`, so a read after it would block +//! until the next key. + +use std::cell::OnceCell; +use std::fs::{File, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, sync_channel}; +use std::time::Duration; + +use rustix::termios::{OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr}; + +/// Reads the reader thread may queue before it waits for them to be taken: a bound on what a +/// terminal flooding its input can make `view` hold. +const QUEUED_READS: usize = 64; + +pub(crate) struct Tty { + file: File, + /// What the reader thread has read. It starts with the first read and is the only reader + /// from then on, so nothing typed is split between readers. + input: OnceCell>, +} + +enum Input { + Bytes(Vec), + /// The terminal closed, or reading it failed: why. + Closed(String), +} + +/// The window's size in cells, and in pixels when the terminal reports them (0 otherwise). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WindowSize { + pub(crate) cols: u16, + pub(crate) rows: u16, + pub(crate) xpixel: u16, + pub(crate) ypixel: u16, +} + +impl Tty { + pub(crate) fn open() -> Result { + OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .map(|file| Self { file, input: OnceCell::new() }) + .map_err(|e| format!("no terminal to draw on (/dev/tty: {e})")) + } + + /// `None` when the terminal doesn't say, or says it has no cells. + pub(crate) fn size(&self) -> Option { + let w = tcgetwinsize(&self.file).ok()?; + (w.ws_col > 0 && w.ws_row > 0).then_some(WindowSize { cols: w.ws_col, rows: w.ws_row, xpixel: w.ws_xpixel, ypixel: w.ws_ypixel }) + } + + /// Switch to raw mode (no echo, no line editing, bytes as they come) until the guard drops. + pub(crate) fn raw(&self) -> Result, String> { + let saved = tcgetattr(&self.file).map_err(|e| format!("terminal settings: {e}"))?; + let mut raw = saved.clone(); + raw.make_raw(); + tcsetattr(&self.file, OptionalActions::Now, &raw).map_err(|e| format!("terminal settings: {e}"))?; + Ok(RawMode { tty: self, saved }) + } + + /// The bytes that arrive within `timeout`, or `None` when none do. A terminal that has gone + /// away is an error, never an endless run of empty reads for a caller to spin on. + pub(crate) fn read_timeout(&self, timeout: Duration) -> Result>, String> { + match self.input()?.recv_timeout(timeout) { + Ok(Input::Bytes(bytes)) => Ok(Some(bytes)), + Ok(Input::Closed(why)) => Err(why), + Err(RecvTimeoutError::Timeout) => Ok(None), + Err(RecvTimeoutError::Disconnected) => Err("the terminal closed".into()), + } + } + + /// The reader thread's channel, starting the thread the first time. + fn input(&self) -> Result<&Receiver, String> { + if let Some(input) = self.input.get() { + return Ok(input); + } + let file = self.file.try_clone().map_err(|e| format!("reading the terminal: {e}"))?; + let (sender, receiver) = sync_channel(QUEUED_READS); + // A thread still blocked in a read when `view` finishes ends with the process. + std::thread::Builder::new() + .name("view-terminal-input".into()) + .spawn(move || read_until_closed(file, &sender)) + .map_err(|e| format!("reading the terminal: {e}"))?; + Ok(self.input.get_or_init(|| receiver)) + } + + pub(crate) fn write_all(&self, bytes: &[u8]) -> Result<(), String> { + let mut file = &self.file; + file.write_all(bytes).and_then(|()| file.flush()).map_err(|e| format!("writing to the terminal: {e}")) + } +} + +/// Pass on what `file` reads until it closes or fails, or nobody listens any more. +fn read_until_closed(mut file: File, sender: &SyncSender) { + let mut buf = [0u8; 256]; + loop { + let input = match file.read(&mut buf) { + Ok(0) => Input::Closed("the terminal closed".into()), + Ok(n) => Input::Bytes(buf.get(..n).unwrap_or_default().to_vec()), + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => Input::Closed(format!("reading the terminal: {e}")), + }; + let closed = matches!(input, Input::Closed(_)); + if sender.send(input).is_err() || closed { + return; + } + } +} + +/// Raw mode, restored when this drops: on return, on an error through `?` and while a panic +/// unwinds. +pub(crate) struct RawMode<'a> { + tty: &'a Tty, + saved: Termios, +} + +impl Drop for RawMode<'_> { + fn drop(&mut self) { + let _ = tcsetattr(&self.tty.file, OptionalActions::Now, &self.saved); + } +} diff --git a/apps/pdfcraft-cli/tests/view.rs b/apps/pdfcraft-cli/tests/view.rs new file mode 100644 index 0000000000..aa2f485fa7 --- /dev/null +++ b/apps/pdfcraft-cli/tests/view.rs @@ -0,0 +1,309 @@ +//! `view` draws pages through the kitty graphics protocol. Test output is a pipe, not a terminal, +//! so these tests pass `--protocol kitty` and read the escape codes back from stdout. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use base64::Engine as _; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn tmp(test: &str, name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-view-{test}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +/// A US Letter document (612 × 792 pt) with `pages` pages. +fn fixture(test: &str, pages: usize) -> PathBuf { + let one = tmp(test, "one.pdf"); + let bytes = pdfcraft_engine::Session::new().create_from_text("View test", "Hello from PdfCraft").unwrap(); + std::fs::write(&one, bytes.as_slice()).unwrap(); + if pages == 1 { + return one; + } + let many = tmp(test, "many.pdf"); + let mut args = vec!["combine".to_string()]; + args.extend(std::iter::repeat_n(one.to_str().unwrap().to_string(), pages)); + args.extend(["--out".to_string(), many.to_str().unwrap().to_string()]); + assert!(Command::new(BIN).args(&args).status().unwrap().success()); + many +} + +/// Run `view`, kept away from the terminal, the tmux and the SSH login the tests may run in. +fn view(pdf: &Path, args: &[&str]) -> Output { + view_with(pdf, args, &[]) +} + +/// [`view`], with `env` set. +fn view_with(pdf: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { + let mut command = Command::new(BIN); + command.arg("view").arg(pdf).args(args).env("TERM", "dumb"); + for name in ["TMUX", "TMUX_PANE", "KITTY_WINDOW_ID", "TERM_PROGRAM", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] { + command.env_remove(name); + } + command.envs(env.iter().copied()).output().unwrap() +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +/// One image drawn by `view`: its first control keys and its decoded payload. +struct Drawn { + keys: String, + png: Vec, +} + +/// Split stdout into images: escape codes `ESC _G ; ESC \`, with `m=1` on +/// every chunk but an image's last. Anything between images must be a newline. +fn images(stdout: &[u8]) -> Vec { + let mut rest = std::str::from_utf8(stdout).unwrap(); + let (mut out, mut keys, mut payload) = (Vec::new(), None::, String::new()); + while !rest.is_empty() { + if keys.is_none() + && let Some(after) = rest.strip_prefix('\n') + { + rest = after; + continue; + } + let body = rest.strip_prefix("\x1b_G").unwrap_or_else(|| panic!("expected an escape code at {:?}", &rest[..rest.len().min(20)])); + let end = body.find("\x1b\\").unwrap(); + let (control, data) = body[..end].split_once(';').unwrap(); + keys.get_or_insert_with(|| control.to_string()); + payload.push_str(data); + if control.ends_with("m=0") { + let png = base64::engine::general_purpose::STANDARD.decode(&payload).unwrap(); + out.push(Drawn { keys: keys.take().unwrap(), png }); + payload.clear(); + } else { + assert!(control.ends_with("m=1"), "{control}"); + } + rest = &body[end + 2..]; + } + assert!(keys.is_none(), "an image was left unfinished"); + out +} + +fn png_size(png: &[u8]) -> (u32, u32) { + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert_eq!(&png[12..16], b"IHDR"); + let be32 = |b: &[u8]| u32::from_be_bytes([b[0], b[1], b[2], b[3]]); + (be32(&png[16..20]), be32(&png[20..24])) +} + +/// The value of `key` in a control string such as `a=T,c=40,r=20`. +fn key(control: &str, key: &str) -> u32 { + control.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).unwrap().parse().unwrap() +} + +#[test] +fn each_page_is_a_png_shown_at_the_cursor() { + let pdf = fixture("pages", 2); + let out = view(&pdf, &["--pages", "2,1", "--protocol", "kitty"]); + assert!(out.status.success(), "{}", text(&out.stderr)); + let drawn = images(&out.stdout); + assert_eq!(drawn.len(), 2); + for image in &drawn { + assert!(image.keys.starts_with("a=T,f=100,t=d,q=2,"), "{}", image.keys); + let (w, h) = png_size(&image.png); + // The page's shape, whatever the size of the terminal running the tests. + let ratio = w as f32 / h as f32; + assert!((ratio - 612.0 / 792.0).abs() < 0.01, "{w}×{h}"); + assert!(key(&image.keys, "c") >= 1 && key(&image.keys, "r") >= 1); + } +} + +#[test] +fn fit_width_is_wider_than_fit_page() { + let pdf = fixture("fit", 1); + let page = images(&view(&pdf, &["--protocol", "kitty"]).stdout); + let width = images(&view(&pdf, &["--protocol", "kitty", "--fit", "width"]).stdout); + let (cols_page, cols_width) = (key(&page[0].keys, "c"), key(&width[0].keys, "c")); + // A portrait page fits the height of any window wider than it is tall in cells. + assert!(cols_width >= cols_page, "{cols_width} < {cols_page}"); + assert!(png_size(&width[0].png).0 >= png_size(&page[0].png).0); +} + +#[test] +fn without_a_terminal_auto_points_to_render() { + let pdf = fixture("auto", 1); + let out = view(&pdf, &[]); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + let stderr = text(&out.stderr); + assert!(stderr.contains("stdout is not a terminal") && stderr.contains("render --out"), "{stderr}"); +} + +#[test] +fn page_numbers_are_checked_before_anything_is_drawn() { + let pdf = fixture("past", 1); + let out = view(&pdf, &["--pages", "1,3", "--protocol", "kitty"]); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + assert!(text(&out.stderr).contains("no page 3"), "{}", text(&out.stderr)); +} + +#[test] +fn bad_arguments_say_what_is_allowed() { + let pdf = fixture("args", 1); + for (args, expected) in [ + (&["--fit", "sideways"][..], "use page or width"), + (&["--protocol", "sixel"], "use auto or kitty"), + (&["--zoom", "2"], "unknown option --zoom"), + (&["--pages", "0"], "bad page number"), + ] { + let out = view(&pdf, args); + assert!(!out.status.success(), "{args:?}"); + assert!(text(&out.stderr).contains(expected), "{args:?}: {}", text(&out.stderr)); + } + let out = Command::new(BIN).arg("view").output().unwrap(); + assert!(text(&out.stderr).contains("missing file")); +} + +/// Run `view` as if in a tmux pane, with no `tmux` command on the PATH to ask. +fn view_in_tmux(test: &str, pdf: &Path, args: &[&str]) -> Output { + let empty = tmp(test, "empty-bin"); + std::fs::create_dir_all(&empty).unwrap(); + Command::new(BIN) + .arg("view") + .arg(pdf) + .args(args) + .env("TMUX", "/nonexistent/tmux-socket,1,0") + .env_remove("TMUX_PANE") + .env_remove("KITTY_WINDOW_ID") + .env_remove("TERM_PROGRAM") + .env("TERM", "tmux-256color") + .env("PATH", &empty) + .output() + .unwrap() +} + +/// The bytes inside the tmux passthrough sequences at the start of `bytes` (`ESC P tmux; … +/// ESC \`, with every inner ESC doubled), and what follows them. +fn passthrough(bytes: &[u8]) -> (Vec, &[u8]) { + let mut inner = Vec::new(); + let mut rest = bytes; + while let Some(body) = rest.strip_prefix(b"\x1bPtmux;") { + let mut i = 0; + loop { + match (body[i], body[i + 1]) { + (0x1b, 0x1b) => { + inner.push(0x1b); + i += 2; + } + (0x1b, b'\\') => { + i += 2; + break; + } + (b, _) => { + assert_ne!(b, 0x1b, "a lone ESC inside a passthrough sequence"); + inner.push(b); + i += 1; + } + } + } + rest = &body[i..]; + } + (inner, rest) +} + +#[test] +fn inside_tmux_pages_go_through_passthrough_and_placeholders_draw_them() { + let pdf = fixture("tmux", 1); + let out = view_in_tmux("tmux", &pdf, &["--protocol", "kitty"]); + assert!(out.status.success(), "{}", text(&out.stderr)); + // With no tmux to ask, `view` says it couldn't check passthrough, and goes on. + assert!(text(&out.stderr).contains("allow-passthrough"), "{}", text(&out.stderr)); + + // The image is transmitted, then placed virtually, all through passthrough. + let (codes, after) = passthrough(&out.stdout); + let mut transmitted: Vec<(&str, &str)> = std::str::from_utf8(&codes) + .unwrap() + .strip_suffix("\x1b\\") + .unwrap() + .split("\x1b\\") + .map(|code| { + let body = code.strip_prefix("\x1b_G").unwrap(); + body.split_once(';').unwrap_or((body, "")) + }) + .collect(); + let (place, _) = transmitted.pop().unwrap(); + let id = key(transmitted[0].0, "i"); + assert!(transmitted[0].0.starts_with("a=t,f=100,t=d,q=2,i="), "{}", transmitted[0].0); + // tmux couldn't say whether it passes 24-bit colour, so a palette colour carries the low 8 + // bits and a third diacritic the high byte; nothing is left for the bits in between. + assert_eq!(id & 0x00FF_FF00, 0, "{id:#x}"); + assert_ne!(id & 0xff, 0, "{id:#x}"); + let png = base64::engine::general_purpose::STANDARD.decode(transmitted.iter().map(|(_, p)| *p).collect::()).unwrap(); + let (w, h) = png_size(&png); + assert!((w as f32 / h as f32 - 612.0 / 792.0).abs() < 0.01, "{w}×{h}"); + let (cols, rows) = (key(place, "c"), key(place, "r")); + assert_eq!(place, format!("a=p,U=1,q=2,i={id},c={cols},r={rows}")); + + // Then a line of placeholder cells per row, in the colour that names the image. + let lines: Vec<&str> = std::str::from_utf8(after).unwrap().split_terminator('\n').collect(); + assert_eq!(lines.len(), rows as usize); + for line in lines { + assert!(line.starts_with(&format!("\x1b[38;5;{}m", id & 0xff)) && line.ends_with("\x1b[39m"), "{line:?}"); + assert_eq!(line.chars().filter(|c| *c == '\u{10EEEE}').count(), cols as usize); + } +} + +#[test] +fn inside_tmux_auto_still_needs_a_terminal() { + let pdf = fixture("tmux-auto", 1); + let out = view_in_tmux("tmux-auto", &pdf, &[]); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + assert!(text(&out.stderr).contains("stdout is not a terminal"), "{}", text(&out.stderr)); +} + +#[test] +fn a_hostile_file_name_cant_send_escape_sequences_to_the_terminal() { + // A name that would write to the clipboard (OSC 52) and retitle the window if printed raw. + let dir = tmp("escape", ""); + let name = dir.join("x\x1b]52;c;cHduZWQ=\x07y\x1b]0;owned\x07.pdf"); + for args in [&[][..], &["--protocol", "kitty"], &["--pages", "x\x1b[2J"]] { + let out = Command::new(BIN).arg("view").arg(&name).args(args).env_remove("TMUX").output().unwrap(); + assert!(!out.status.success()); + let stderr = text(&out.stderr); + assert!(stderr.contains("x\u{FFFD}]52;c;cHduZWQ=\u{FFFD}y"), "{stderr:?}"); + assert!(!out.stderr.iter().any(|b| *b == 0x1b || *b == 0x07), "{stderr:?}"); + } +} + +#[test] +fn compression_trades_bytes_for_speed_and_auto_follows_ssh() { + let pdf = fixture("compression", 1); + let png = |args: &[&str], env: &[(&str, &str)]| { + let out = view_with(&pdf, &[&["--protocol", "kitty"], args].concat(), env); + assert!(out.status.success(), "{}", text(&out.stderr)); + images(&out.stdout).remove(0).png + }; + let (fast, small) = (png(&["--compression", "fast"], &[]), png(&["--compression", "small"], &[])); + // The same page, in more bytes for less work. + assert_eq!(png_size(&fast), png_size(&small)); + assert!(fast.len() > small.len(), "fast {} bytes, small {}", fast.len(), small.len()); + // auto: fast on this machine, small when the login came over SSH. + assert_eq!(png(&[], &[]), fast); + assert_eq!(png(&[], &[("SSH_CONNECTION", "192.0.2.7 50122 192.0.2.1 22")]), small); + assert_eq!(png(&["--compression", "fast"], &[("SSH_TTY", "/dev/pts/3")]), fast); + let bad = view(&pdf, &["--protocol", "kitty", "--compression", "best"]); + assert!(text(&bad.stderr).contains("use auto, fast or small")); +} + +#[test] +fn render_without_out_points_to_view() { + let pdf = fixture("render", 1); + let out = Command::new(BIN).args(["render", pdf.to_str().unwrap()]).output().unwrap(); + assert!(!out.status.success()); + assert!(text(&out.stderr).contains("pdfcraft-cli view"), "{}", text(&out.stderr)); +} + +#[test] +fn help_lists_view() { + let out = Command::new(BIN).arg("--help").output().unwrap(); + assert!(text(&out.stdout).contains("pdfcraft-cli view ")); +} diff --git a/apps/pdfcraft-cli/tests/view_pager.rs b/apps/pdfcraft-cli/tests/view_pager.rs new file mode 100644 index 0000000000..0904491e6c --- /dev/null +++ b/apps/pdfcraft-cli/tests/view_pager.rs @@ -0,0 +1,501 @@ +//! `view` on a terminal, end to end: on a pseudo-terminal of its own that script(1) provides, so +//! the terminal running the tests is never touched. Keys go in through script's stdin; what +//! `view` draws comes out of its stdout. The window's size is set with stty; no pixel size is +//! reported, and the terminal these tests play doesn't know its cell size unless a test says so, +//! so cells are the 10 × 20 px `view` assumes. +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// How long anything may take: generous, for a slow CI machine. +const PATIENCE: Duration = Duration::from_secs(60); + +/// A US Letter document with `pages` pages. +fn fixture(test: &str, pages: usize) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-view-pager-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let one = dir.join("one.pdf"); + let bytes = pdfcraft_engine::Session::new().create_from_text("Pager test", "Hello from PdfCraft").unwrap(); + std::fs::write(&one, bytes.as_slice()).unwrap(); + if pages == 1 { + return one; + } + let many = dir.join("many.pdf"); + let mut args = vec!["combine".to_string()]; + args.extend(std::iter::repeat_n(one.to_str().unwrap().to_string(), pages)); + args.extend(["--out".to_string(), many.to_str().unwrap().to_string()]); + assert!(Command::new(BIN).args(&args).status().unwrap().success()); + many +} + +/// `line` run by a shell on a new pseudo-terminal: BSD script (macOS, FreeBSD) takes the command +/// after the output file; util-linux script (Linux) takes it as one shell line. +fn script(line: &str) -> Command { + let mut script = Command::new("script"); + if cfg!(target_os = "linux") { + script.args(["-q", "-e", "-c", line, "/dev/null"]); + } else { + script.args(["-q", "/dev/null", "sh", "-c", line]); + } + script +} + +/// Whether script(1) gives a command a pseudo-terminal of the size asked for, which every test +/// here stands on. Where it can't (no script, or one that can't open a terminal), the tests skip +/// with a warning instead of failing over something that isn't `view`'s. +fn pty_works() -> bool { + static WORKS: OnceLock = OnceLock::new(); + *WORKS.get_or_init(|| { + let works = (|| { + let mut child = + script("stty rows 5 cols 7 && stty size").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().ok()?.is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } + let mut out = String::new(); + child.stdout.take()?.read_to_string(&mut out).ok()?; + Some(out.contains("5 7")) + })() + .unwrap_or(false); + if !works { + eprintln!("warning: skipped: script(1) can't give a command a pseudo-terminal here"); + } + works + }) +} + +/// `view pdf args…` running on a `cols` × `rows` pseudo-terminal. +struct Session { + child: Child, + /// script's stdin: keys, and the terminal's answers to `view`'s questions. + keys: Arc>>, + output: Arc>>, + /// The files taken, as a terminal on this machine takes them, or what was wrong with one. + taken: Arc>>>, +} + +/// The terminal at the other end of the pseudo-terminal: whether it reads the temporary files +/// `view` names, as kitty does (and deletes them), the temporary directory `view` gets, and the +/// cell size it answers when asked (the device attributes alone when `None`). +#[derive(Clone, Copy)] +struct Terminal<'a> { + takes_files: bool, + tmpdir: Option<&'a Path>, + cell: Option<(u16, u16)>, +} + +/// One that never reads files, as script(1) doesn't, and doesn't know its cell size. +const PLAIN: Terminal = Terminal { takes_files: false, tmpdir: None, cell: None }; + +/// `view`'s question about the cell size, which a pseudo-terminal without pixels gets. +const CELL_QUERY: &[u8] = b"\x1b[16t\x1b[c"; + +impl Session { + fn start(pdf: &Path, args: &[&str], cols: u16, rows: u16) -> Self { + Self::start_on(PLAIN, pdf, args, cols, rows) + } + + fn start_on(terminal: Terminal, pdf: &Path, args: &[&str], cols: u16, rows: u16) -> Self { + let quote = |s: &str| format!("'{}'", s.replace('\'', r"'\''")); + let command = [BIN, "view", pdf.to_str().unwrap()].iter().chain(args).map(|a| quote(a)).collect::>().join(" "); + let mut script = script(&format!("stty rows {rows} cols {cols} && exec {command}")); + if let Some(dir) = terminal.tmpdir { + script.env("TMPDIR", dir); + } + // On this machine, whatever machine the tests run from: an SSH login would turn the files off. + for name in ["TMUX", "TMUX_PANE", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] { + script.env_remove(name); + } + let mut child = script.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let output = Arc::new(Mutex::new(Vec::new())); + let taken = Arc::new(Mutex::new(Vec::new())); + let keys = Arc::new(Mutex::new(child.stdin.take())); + let mut stdout = child.stdout.take().unwrap(); + let (collected, took, answers) = (output.clone(), taken.clone(), keys.clone()); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + let (mut scanned, mut asked) = (0, 0); + while let Ok(n) = stdout.read(&mut buf) { + if n == 0 { + break; + } + let mut output = collected.lock().unwrap(); + output.extend_from_slice(&buf[..n]); + if terminal.takes_files { + scanned = take_files(&output, scanned, &mut took.lock().unwrap()); + } + // Answer the cell size question as a terminal does, once per asking. + while let Some(at) = output[asked..].windows(CELL_QUERY.len()).position(|w| w == CELL_QUERY) { + asked += at + CELL_QUERY.len(); + let cell = terminal.cell.map(|(w, h)| format!("\x1b[6;{h};{w}t")).unwrap_or_default(); + if let Some(stdin) = answers.lock().unwrap().as_mut() { + let _ = stdin.write_all(format!("{cell}\x1b[?62;c").as_bytes()).and_then(|()| stdin.flush()); + } + } + } + }); + Session { child, keys, output, taken } + } + + fn text(&self) -> String { + String::from_utf8_lossy(&self.output.lock().unwrap()).into_owned() + } + + /// Wait until `done` holds for what was drawn so far; panics with the output if it never does. + fn wait_until(&self, what: &str, done: impl Fn(&str) -> bool) { + let deadline = Instant::now() + PATIENCE; + while !done(&self.text()) { + assert!(Instant::now() < deadline, "{what} never happened: {:?}", self.text()); + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn press(&mut self, keys: &str) { + let mut stdin = self.keys.lock().unwrap(); + let stdin = stdin.as_mut().unwrap(); + stdin.write_all(keys.as_bytes()).unwrap(); + stdin.flush().unwrap(); + } + + /// Wait for `view` to exit, all of its output collected. + fn finish(&mut self) -> ExitStatus { + let deadline = Instant::now() + PATIENCE; + let status = loop { + if let Some(status) = self.child.try_wait().unwrap() { + break status; + } + assert!(Instant::now() < deadline, "view didn't exit: {:?}", self.text()); + std::thread::sleep(Duration::from_millis(20)); + }; + *self.keys.lock().unwrap() = None; + // The reader thread has the last of the output once script's stdout closes. + std::thread::sleep(Duration::from_millis(100)); + status + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Take the files that the file transmissions in `output` from `from` on name, as kitty does: +/// read each, check it holds the pixels its escape code says, and delete it. Returns where the +/// next look starts (at a transmission not yet whole). +fn take_files(output: &[u8], mut from: usize, taken: &mut Vec>) -> usize { + use base64::Engine as _; + use std::os::unix::ffi::OsStrExt as _; + const START: &[u8] = b"\x1b_Ga=t,f=32,t=t,"; + while let Some(at) = output[from..].windows(START.len()).position(|w| w == START).map(|p| from + p) { + let Some(end) = output[at..].windows(2).position(|w| w == b"\x1b\\").map(|p| at + p) else { return at }; + from = end + 2; + let code = String::from_utf8_lossy(&output[at + 3..end]).into_owned(); + let (control, payload) = code.split_once(';').unwrap_or((&code, "")); + let key = |k: &str| control.split(',').find_map(|kv| kv.strip_prefix(k)?.strip_prefix('=')?.parse::().ok()); + let path = base64::engine::general_purpose::STANDARD.decode(payload).map(|p| PathBuf::from(std::ffi::OsStr::from_bytes(&p))); + taken.push(match (path, key("s"), key("v")) { + (Ok(path), Some(w), Some(h)) => match std::fs::metadata(&path) { + Ok(meta) if meta.len() == w * h * 4 => std::fs::remove_file(&path).map(|()| path).map_err(|e| e.to_string()), + Ok(meta) => Err(format!("{}: {} bytes for {w} × {h} pixels", path.display(), meta.len())), + Err(e) => Err(format!("{}: {e}", path.display())), + }, + _ => Err(format!("unreadable: {code:?}")), + }); + } + output.len().saturating_sub(START.len()).max(from) +} + +/// How many images were transmitted (for the pager) or shown at the cursor (when printing). +fn transmitted(text: &str) -> usize { + text.matches("a=t,f=100,t=d,q=2,i=").count() +} + +fn printed(text: &str) -> usize { + text.matches("a=T,f=100,t=d,q=2,").count() +} + +#[test] +fn more_than_a_page_opens_the_pager_and_pages_arrive_without_a_key() { + if !pty_works() { + return; + } + let pdf = fixture("tall", 3); + // A tall window: one page to the screen. No --pager: three pages on a terminal page. + let mut view = Session::start(&pdf, &["--protocol", "kitty"], 80, 60); + // No key yet: the first page arrives from the render thread on its own, its status line + // replacing "rendering". Before, the pager waited on a read until a key came. + view.wait_until("page 1 shown", |t| t.contains("q quit") && t.contains(" page 1/3 ")); + let text = view.text(); + assert!(text.contains("\x1b[?1049h"), "the alternate screen"); + assert!(text.contains(",p=1,C=1,"), "placed without moving the cursor"); + + // The next pages are rendered and sent ahead, still without a key. + view.wait_until("pages 2 and 3 sent ahead", |t| transmitted(t) == 3); + // So turning the page sends nothing: page 2 is only placed, and page 1 hidden, at once. + view.press("n"); + view.wait_until("page 2 shown", |t| t.contains(" page 2/3 ") && t.contains("a=d,d=i,q=2,i=")); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(transmitted(&view.text()), 3, "a page was sent again"); + + // q quits and puts the screen back. + view.press("q"); + assert!(view.finish().success()); + assert!(view.text().contains("\x1b[?1049l"), "the alternate screen was left"); +} + +#[test] +fn a_wide_window_shows_two_pages_and_turns_two_at_a_time() { + if !pty_works() { + return; + } + let pdf = fixture("wide", 3); + let mut view = Session::start(&pdf, &["--protocol", "kitty"], 120, 24); + view.wait_until("pages 1-2 shown", |t| t.contains(" pages 1–2/3 ") && t.contains("q quit")); + // Page 3, on the next spread, is sent ahead: three images, three ids. + view.wait_until("page 3 sent ahead", |t| transmitted(t) == 3); + let text = view.text(); + let ids: std::collections::HashSet = text.match_indices("a=t,f=100,t=d,q=2,i=").map(|(at, _)| view_id(&text[at..])).collect(); + assert_eq!(ids.len(), 3, "{ids:?}"); + // Space turns the spread, page 3 alone on the left, with nothing sent. + view.press(" "); + view.wait_until("page 3 shown", |t| t.contains(" page 3/3 ")); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(transmitted(&view.text()), 3, "a page was sent again"); + // d: one page to the screen, from where the reader is; p goes back one page. One page to the + // screen is wider, so pages are made again for it. + view.press("dp"); + view.wait_until("page 2 alone", |t| t.contains(" page 2/3 ") && transmitted(t) > 3); + view.press("q"); + assert!(view.finish().success()); +} + +#[test] +fn a_page_given_twice_shows_twice_side_by_side() { + if !pty_works() { + return; + } + let pdf = fixture("twice", 3); + let mut view = Session::start(&pdf, &["--protocol", "kitty", "--pages", "1,1"], 120, 24); + view.wait_until("page 1 twice", |t| t.contains(" pages 1–1/3 ") && t.contains("q quit")); + // One image, placed twice: each place on screen a placement of its own. + let text = view.text(); + assert_eq!(transmitted(&text), 1, "{text:?}"); + let id = view_id(&text[text.find("a=t,f=100,t=d,q=2,i=").unwrap()..]); + for placement in [1, 2] { + assert!(text.contains(&format!("a=p,q=1,i={id},p={placement},")), "placement {placement}: {text:?}"); + } + view.press("q"); + assert!(view.finish().success()); +} + +#[test] +fn an_image_the_terminal_dropped_is_sent_again_and_its_answers_are_never_keys() { + if !pty_works() { + return; + } + let pdf = fixture("dropped", 3); + let mut view = Session::start(&pdf, &["--protocol", "kitty"], 80, 60); + view.wait_until("pages 1-3 sent", |t| t.contains(" page 1/3 ") && t.contains("q quit") && transmitted(t) == 3); + let text = view.text(); + let at = text.find("a=p,q=1,i=").unwrap(); + let id: String = text[at + "a=p,q=1,i=".len()..].chars().take_while(char::is_ascii_digit).collect(); + // Answers that say nothing: an OK, another error, one cut in two. Not one of them is a key: + // their ESC isn't Esc (which quits), nor their letters keys. + view.press(&format!("\x1b_Gi={id};OK\x1b\\\x1b_Gi={id};EINVAL:quit\x1b\\\x1b_Gi={id};O")); + std::thread::sleep(Duration::from_millis(100)); + view.press("K\x1b\\"); + std::thread::sleep(Duration::from_millis(300)); + assert!(view.child.try_wait().unwrap().is_none(), "an answer quit the pager"); + assert_eq!(transmitted(&view.text()), 3, "an answer that says nothing sent a page"); + // kitty dropped page 1 to stay under its quota, and answers its placement so: it is sent + // again, and placed. + view.press(&format!("\x1b_Gi={id},p=1;ENOENT:Put command refers to non-existent image with id: {id} and number: 0\x1b\\")); + view.wait_until("page 1 sent again", |t| transmitted(t) == 4); + let again = view.text(); + let new = view_id(&again[again.rfind("a=t,f=100,t=d,q=2,i=").unwrap()..]); + assert_ne!(new, id); + view.wait_until("page 1 placed again", |t| t.contains(&format!("a=p,q=1,i={new},p=1,"))); + view.press("q"); + assert!(view.finish().success()); +} + +/// The `i=` value at the start of a transmission. +fn view_id(transmission: &str) -> String { + transmission.trim_start_matches("a=t,f=100,t=d,q=2,i=").chars().take_while(char::is_ascii_digit).collect() +} + +/// The first page placed: the cells it covers, and its size in pixels, from its PNG header. (The +/// first page sent may be one rendered ahead, never placed yet.) +fn first_page_size(text: &str) -> ((u32, u32), (u32, u32)) { + use base64::Engine as _; + let placed = &text[text.find("a=p,q=1,i=").unwrap()..]; + let control = placed.split(['\x1b', ';']).next().unwrap(); + let key = |k: &str| control.split(',').find_map(|kv| kv.strip_prefix(k)).unwrap().parse::().unwrap(); + let sent = &text[text.find(&format!("a=t,f=100,t=d,q=2,i={},", key("i="))).unwrap()..]; + let payload = &sent[sent.find(';').unwrap() + 1..]; + let header = base64::engine::general_purpose::STANDARD.decode(&payload[..44]).unwrap(); + let be = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap()); + ((be(16), be(20)), (key("c="), key("r="))) +} + +#[test] +fn a_terminal_without_pixels_is_asked_its_cell_size() { + if !pty_works() { + return; + } + let pdf = fixture("cells", 3); + // As under `docker exec`: the pseudo-terminal has no pixel size, and the terminal knows its + // cells are 16 × 24 px. One that doesn't know gets the 10 × 20 px assumed. + for (terminal, (cell_w, cell_h)) in [(Terminal { cell: Some((16, 24)), ..PLAIN }, (16, 24)), (PLAIN, (10, 20))] { + let mut view = Session::start_on(terminal, &pdf, &["--protocol", "kitty"], 80, 60); + view.wait_until("page 1 shown", |t| t.contains(" page 1/3 ") && t.contains("q quit")); + let text = view.text(); + assert_eq!(text.matches("\x1b[16t").count(), 1, "asked once"); + // Rendered for those cells: the page fills the last of the cells it covers. + let ((w, h), (cols, rows)) = first_page_size(&text); + assert!(w <= cols * cell_w + 1 && w + 1 > (cols - 1) * cell_w, "{w} px wide over {cols} cells {cell_w} px wide"); + assert!(h <= rows * cell_h + 1 && h + 1 > (rows - 1) * cell_h, "{h} px tall over {rows} cells {cell_h} px tall"); + view.press("q"); + assert!(view.finish().success()); + } +} + +#[test] +fn one_page_is_printed_without_the_pager() { + if !pty_works() { + return; + } + let pdf = fixture("one", 1); + let mut view = Session::start(&pdf, &["--protocol", "kitty"], 80, 24); + assert!(view.finish().success()); + let text = view.text(); + assert_eq!(printed(&text), 1, "{text:?}"); + assert!(!text.contains("\x1b[?1049h"), "no alternate screen"); +} + +#[test] +fn no_pager_prints_every_page() { + if !pty_works() { + return; + } + let pdf = fixture("print", 3); + let mut view = Session::start(&pdf, &["--protocol", "kitty", "--no-pager"], 80, 24); + assert!(view.finish().success()); + let text = view.text(); + assert_eq!(printed(&text), 3, "{text:?}"); + assert!(!text.contains("\x1b[?1049h"), "no alternate screen"); +} + +#[test] +fn pager_shows_even_one_page() { + if !pty_works() { + return; + } + let pdf = fixture("forced", 1); + let mut view = Session::start(&pdf, &["--protocol", "kitty", "--pager"], 80, 60); + view.wait_until("page 1 shown", |t| t.contains(" page 1/1 ") && transmitted(t) == 1); + view.press("q"); + assert!(view.finish().success()); +} + +/// A directory of the test's own, empty, for `view`'s temporary files. +fn tmpdir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-view-tmp-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// The files `view` left in the temporary directory `dir`: in its own directory there. +fn files_in(dir: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(files_in(&path)); + } else { + files.push(path); + } + } + files +} + +#[test] +fn a_terminal_that_takes_files_gets_its_pages_through_them() { + if !pty_works() { + return; + } + let pdf = fixture("files", 3); + let dir = tmpdir("files"); + let terminal = Terminal { takes_files: true, tmpdir: Some(&dir), cell: None }; + let mut view = Session::start_on(terminal, &pdf, &["--protocol", "kitty"], 80, 60); + view.wait_until("page 1 shown", |t| t.contains("q quit") && t.contains(" page 1/3 ")); + // The probe first: one pixel, its image deleted straight after. + view.wait_until("the probe taken", |t| t.contains("a=t,f=32,t=t,s=1,v=1,q=2,i=")); + assert!(view.taken.lock().unwrap().first().is_some_and(Result::is_ok), "{:?}", view.taken.lock().unwrap()); + // r sends every page again, long after the probe: each through a file, none as a PNG. + std::thread::sleep(Duration::from_millis(300)); + let before = view.text().len(); + view.press("r"); + let after = |t: &str| t.get(before..).unwrap_or_default().to_string(); + view.wait_until("pages 1-3 sent again", |t| after(t).matches("a=t,f=32,t=t,").count() == 3); + std::thread::sleep(Duration::from_millis(200)); + let text = view.text(); + assert_eq!(transmitted(&after(&text)), 0, "a PNG after the probe was taken"); + let taken = view.taken.lock().unwrap().clone(); + assert!(taken.iter().all(Result::is_ok), "{taken:?}"); + assert!(taken.len() >= 4, "{taken:?}"); + for path in taken.iter().flatten() { + assert!(path.starts_with(&dir) && path.to_string_lossy().contains("tty-graphics-protocol"), "{path:?}"); + } + view.press("q"); + assert!(view.finish().success()); + assert!(files_in(&dir).is_empty(), "left behind: {:?}", files_in(&dir)); +} + +#[test] +fn a_terminal_that_leaves_files_gets_pngs_and_no_file_stays() { + if !pty_works() { + return; + } + let pdf = fixture("nofiles", 3); + let dir = tmpdir("nofiles"); + let terminal = Terminal { takes_files: false, tmpdir: Some(&dir), cell: None }; + let mut view = Session::start_on(terminal, &pdf, &["--protocol", "kitty"], 80, 60); + view.wait_until("pages 1-3 sent as PNGs", |t| t.contains(" page 1/3 ") && transmitted(t) == 3); + let text = view.text(); + assert_eq!(text.matches("a=t,f=32,t=t,").count(), 1, "only the probe went through a file"); + // The probe waits for the terminal a while, and goes after that or when the pager ends. + view.press("q"); + assert!(view.finish().success()); + assert!(files_in(&dir).is_empty(), "left behind: {:?}", files_in(&dir)); +} + +#[test] +fn compression_by_name_sends_pngs_without_trying_files() { + if !pty_works() { + return; + } + let pdf = fixture("named", 3); + let dir = tmpdir("named"); + let terminal = Terminal { takes_files: true, tmpdir: Some(&dir), cell: None }; + let mut view = Session::start_on(terminal, &pdf, &["--protocol", "kitty", "--compression", "fast"], 80, 60); + view.wait_until("pages 1-3 sent as PNGs", |t| t.contains(" page 1/3 ") && transmitted(t) == 3); + view.press("q"); + assert!(view.finish().success()); + assert!(!view.text().contains("t=t"), "no file, not even the probe"); +} diff --git a/apps/pdfcraft-cli/tests/view_tmux.rs b/apps/pdfcraft-cli/tests/view_tmux.rs new file mode 100644 index 0000000000..3f954f2386 --- /dev/null +++ b/apps/pdfcraft-cli/tests/view_tmux.rs @@ -0,0 +1,266 @@ +//! `view --pager` inside tmux, end to end: every page on screen must reach the terminal. +//! +//! tmux throws output away when it decides its terminal can't keep up, and images sent through +//! its passthrough go with it for good (it redraws its text, not what passed through). A screen +//! of fully marked placeholder cells was enough to make it do so, so pages went black after a few +//! flips. This runs `view` in a private tmux server (its own socket, killed at the end) whose +//! client sits on a pseudo-terminal from script(1); what tmux writes there is what a terminal +//! would get. Skipped, with a warning, where tmux or a pseudo-terminal from script(1) isn't +//! available, or tmux can't start: CI machines without them pass instead of failing over +//! something that isn't `view`'s. +#![cfg(unix)] + +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// `line` run by a shell on a new pseudo-terminal: BSD script (macOS, FreeBSD) takes the command +/// after the output file; util-linux script (Linux) takes it as one shell line. +fn script(line: &str) -> Command { + let mut script = Command::new("script"); + if cfg!(target_os = "linux") { + script.args(["-q", "-e", "-c", line, "/dev/null"]); + } else { + script.args(["-q", "/dev/null", "sh", "-c", line]); + } + script +} + +/// What this machine lacks for the test, if anything: tmux, or a pseudo-terminal of the size +/// asked for from script(1). +fn missing() -> Option<&'static str> { + if Command::new("tmux").arg("-V").stdin(Stdio::null()).output().is_err() { + return Some("no tmux"); + } + let works = (|| { + let mut child = script("stty rows 5 cols 7 && stty size").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().ok()?.is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } + let mut out = String::new(); + child.stdout.take()?.read_to_string(&mut out).ok()?; + Some(out.contains("5 7")) + })(); + (works != Some(true)).then_some("script(1) can't give a command a pseudo-terminal") +} + +/// A US Letter document with `pages` pages, in a directory of the test's own. +fn fixture(test: &str, pages: usize) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-view-tmux-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let one = dir.join("one.pdf"); + let bytes = pdfcraft_engine::Session::new().create_from_text("Tmux test", "Hello from PdfCraft").unwrap(); + std::fs::write(&one, bytes.as_slice()).unwrap(); + let many = dir.join("many.pdf"); + let mut args = vec!["combine".to_string()]; + args.extend(std::iter::repeat_n(one.to_str().unwrap().to_string(), pages)); + args.extend(["--out".to_string(), many.to_str().unwrap().to_string()]); + assert!(Command::new(BIN).args(&args).status().unwrap().success()); + many +} + +/// A private tmux server, and the script(1) processes its clients run under; all go at the end, +/// the server's socket too (tmux leaves it behind when killed). +struct Server { + socket: String, + path: Option, + clients: Vec, +} + +impl Server { + fn tmux(&self, args: &[&str]) -> Vec { + Command::new("tmux").arg("-L").arg(&self.socket).args(args).output().map(|o| o.stdout).unwrap_or_default() + } + + /// A client on a new pseudo-terminal, running `line` (a tmux command); what tmux writes to its + /// terminal collects in the buffer returned. + fn client(&mut self, line: &str) -> Arc>> { + // Any terminal type tmux knows will do: `view` is told the protocol, and tmux passes the + // escape codes through whatever it thinks the terminal is. xterm-256color is in every + // terminfo database; kitty's own entry isn't. + let line = format!("stty rows 58 cols 200 && exec tmux -L {} -f /dev/null {line}", self.socket); + let mut client = script(&line) + .env_remove("TMUX") + .env_remove("TMUX_PANE") + .env("TERM", "xterm-256color") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdout = client.stdout.take().unwrap(); + self.clients.push(client); + let output = Arc::new(Mutex::new(Vec::new())); + let collected = output.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 1 << 16]; + while let Ok(n) = stdout.read(&mut buf) { + if n == 0 { + break; + } + collected.lock().unwrap().extend_from_slice(&buf[..n]); + } + }); + output + } + + /// The image ids the pane's placeholder cells name. + fn on_screen(&self) -> HashSet { + on_screen(&self.tmux(&["capture-pane", "-p", "-e", "-t", "view:0"])) + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.tmux(&["kill-server"]); + if let Some(path) = &self.path { + let _ = std::fs::remove_file(path); + } + for client in &mut self.clients { + let _ = client.kill(); + let _ = client.wait(); + } + } +} + +/// `view` on a 12-page document in a private tmux server, in session `view`, with one client: the +/// server, and what that client's terminal gets. `None`, with a warning, where tmux or script(1) +/// can't run. +fn start(test: &str) -> Option<(Server, Arc>>)> { + if let Some(what) = missing() { + eprintln!("warning: skipped: {what} here"); + return None; + } + let pdf = fixture(test, 12); + let socket = format!("pdfcraft-cli-test-{test}-{}", std::process::id()); + let view = format!("'{BIN}' view '{}' --protocol kitty", pdf.display()); + let mut server = Server { socket, path: None, clients: Vec::new() }; + // A wide window: two pages side by side, and a screenful of placeholder cells per flip. + let output = server.client(&format!("new-session -s view \"{view}\"")); + // A tmux that can't start here (say, a terminal type it doesn't know) isn't `view`'s failure. + let deadline = Instant::now() + Duration::from_secs(20); + while !Command::new("tmux").arg("-L").arg(&server.socket).arg("has-session").output().is_ok_and(|o| o.status.success()) { + if Instant::now() > deadline { + eprintln!("warning: skipped: tmux didn't start here"); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + let path = PathBuf::from(String::from_utf8_lossy(&server.tmux(&["display-message", "-p", "#{socket_path}"])).trim()); + server.path = path.file_name().is_some_and(|name| *name == *server.socket).then_some(path); + // The first pages on screen. + let deadline = Instant::now() + Duration::from_secs(60); + while arrived(&output.lock().unwrap()).len() < 2 { + assert!(Instant::now() < deadline, "no page arrived"); + std::thread::sleep(Duration::from_millis(50)); + } + Some((server, output)) +} + +/// Wait until every image on the pane's screen has arrived in `output`. +fn all_arrive(server: &Server, output: &Mutex>, what: &str) { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let shown = server.on_screen(); + let missing: Vec = shown.difference(&arrived(&output.lock().unwrap())).copied().collect(); + if !shown.is_empty() && missing.is_empty() { + return; + } + assert!( + Instant::now() < deadline, + "{what}: images on screen that never reached the terminal: {missing:?} (tmux discarded {} bytes)", + String::from_utf8_lossy(&server.tmux(&["display", "-p", "#{client_discarded}"])).trim() + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// The ids of the images whose uploads arrived whole in `out`: from a first chunk (`a=t`) to +/// its last (`m=0`), with nothing else in between. +fn arrived(out: &[u8]) -> HashSet { + let text = String::from_utf8_lossy(out); + let mut ids = HashSet::new(); + let mut upload = None; + for code in text.split("\x1b_G").skip(1) { + let control = code.split([';', '\x1b']).next().unwrap_or_default(); + let keys: Vec<(&str, &str)> = control.split(',').filter_map(|kv| kv.split_once('=')).collect(); + let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + if get("a") == Some("t") { + upload = get("i").and_then(|i| i.parse::().ok()); + } else if !keys.iter().all(|(k, _)| matches!(*k, "m" | "q")) { + upload = None; + } + if get("m") == Some("0") + && let Some(id) = upload.take() + { + // As the screen's colours name it: the low 24 bits, or the low 8 in the palette + // (the high byte travels in a diacritic, not in the colour). + ids.extend([id & 0xFF_FFFF, id & 0xFF]); + } + } + ids +} + +/// The image ids the placeholder cells on screen name, by their 24-bit or palette colour. +fn on_screen(capture: &[u8]) -> HashSet { + let text = String::from_utf8_lossy(capture); + let mut ids = HashSet::new(); + for (at, _) in text.match_indices("\u{10EEEE}") { + let before = &text[..at]; + let Some(sgr) = before.rfind("\x1b[") else { continue }; + let params: Vec = before[sgr + 2..].trim_end_matches('m').split(';').filter_map(|p| p.parse().ok()).collect(); + if let Some(i) = params.windows(5).position(|w| w[0] == 38 && w[1] == 2) { + ids.insert(params[i + 2] << 16 | params[i + 3] << 8 | params[i + 4]); + } else if let Some(i) = params.windows(3).position(|w| w[0] == 38 && w[1] == 5) { + ids.insert(params[i + 2]); + } + } + ids +} + +#[test] +fn inside_tmux_every_page_on_screen_reaches_the_terminal() { + let Some((server, output)) = start("flips") else { return }; + for _ in 0..5 { + server.tmux(&["send-keys", "-t", "view:0", " "]); + std::thread::sleep(Duration::from_millis(400)); + } + // Every image the screen shows now must have reached the terminal. + all_arrive(&server, &output, "after the flips"); + server.tmux(&["send-keys", "-t", "view:0", "q"]); +} + +#[test] +fn pages_turned_while_the_pane_is_hidden_arrive_once_it_shows() { + let Some((server, output)) = start("hidden") else { return }; + // Another window in front: tmux throws away what goes through passthrough from the pane. + server.tmux(&["new-window", "-t", "view", "sleep 600"]); + std::thread::sleep(Duration::from_millis(300)); + // To the last pages, which weren't sent ahead: rendered and sent while the pane is hidden. + server.tmux(&["send-keys", "-t", "view:0", "G"]); + std::thread::sleep(Duration::from_millis(1500)); + server.tmux(&["select-window", "-t", "view:0"]); + all_arrive(&server, &output, "after the pane showed again"); + server.tmux(&["send-keys", "-t", "view:0", "q"]); +} + +#[test] +fn a_terminal_that_attaches_later_gets_the_pages_too() { + let Some((mut server, _)) = start("second") else { return }; + // A second client on the same session: tmux sends it what goes through passthrough from now + // on, and the pages were sent before. + let second = server.client("attach-session -t view"); + all_arrive(&server, &second, "in the second terminal"); + server.tmux(&["send-keys", "-t", "view:0", "q"]); +} diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a77..855b5bec3e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -145,3 +145,7 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.dobladez] +real_name = "Fernando Dobladez" +sync_github_name = true diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6b7aad4fad..e623b07623 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -138,7 +138,22 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image /// Premultiplied RGBA → PNG (straight alpha). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - let mut rgba = premultiplied.to_vec(); + encode_png_with(width, height, premultiplied, PngEffort::Small) +} + +/// How hard PNG encoding works at making the file small. The pixels come out the same either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PngEffort { + /// The png crate's default: the smaller file (what [`encode_png`] writes). + #[default] + Small, + /// Several times faster, about twice the bytes on a text page: for images shown at once, + /// where encoding time is felt and the bytes go no further than the screen. + Fast, +} + +/// Premultiplied RGBA → straight RGBA, in place. A trailing partial pixel is left as it is. +pub fn unpremultiply(rgba: &mut [u8]) { for px in rgba.as_chunks_mut::<4>().0 { let a = u32::from(px[3]); if a != 0 && a != 255 { @@ -147,10 +162,19 @@ pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result Result, String> { + let mut rgba = premultiplied.to_vec(); + unpremultiply(&mut rgba); let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); + if effort == PngEffort::Fast { + enc.set_compression(png::Compression::Fast); + } let mut w = enc.write_header().map_err(|e| e.to_string())?; w.write_image_data(&rgba).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; @@ -244,3 +268,46 @@ mod image_name_tests { assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } + +#[cfg(test)] +mod png_tests { + use super::{PngEffort, encode_png, encode_png_with}; + + fn decode(png: &[u8]) -> (u32, u32, Vec) { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(info.buffer_size()); + (info.width, info.height, pixels) + } + + #[test] + fn fast_png_holds_the_same_pixels_as_small() { + // A page-like raster: white, with rows of dark "text", one half-transparent pixel. + let (w, h) = (64u32, 48u32); + let mut rgba = vec![255u8; (w * h * 4) as usize]; + for y in (4..h).step_by(6) { + for x in 4..w - 4 { + let i = ((y * w + x) * 4) as usize; + rgba[i..i + 3].copy_from_slice(&[20, 20, 30]); + } + } + rgba[..4].copy_from_slice(&[64, 0, 0, 128]); + let small = encode_png(w, h, &rgba).unwrap(); + assert_eq!(small, encode_png_with(w, h, &rgba, PngEffort::Small).unwrap()); + let fast = encode_png_with(w, h, &rgba, PngEffort::Fast).unwrap(); + assert_ne!(fast, small, "a different compression"); + assert_eq!(decode(&fast), decode(&small)); + // Straight alpha out: the premultiplied (64, 0, 0, 128) is (128, 0, 0, 128). + assert_eq!(decode(&fast).2[..4], [128, 0, 0, 128]); + } + + #[test] + fn unpremultiply_leaves_opaque_clear_and_partial_pixels() { + let mut px = vec![64, 0, 200, 128, 10, 20, 30, 255, 0, 0, 0, 0, 255, 128, 0, 1, 9, 9]; + super::unpremultiply(&mut px); + // Half-covered red doubles; opaque and clear stay; a channel past the alpha clamps; the + // two bytes after the last whole pixel are left alone. + assert_eq!(px, [128, 0, 255, 128, 10, 20, 30, 255, 0, 0, 0, 0, 255, 255, 0, 1, 9, 9]); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index aa91c36c4e..a1584a3d39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ handwritten JS/TS, in the product or the build. | Binary | What it is | |---|---| | `apps/pdfcraft` | The desktop app (macOS, Windows, Linux, FreeBSD): eframe/egui on wgpu, single instance, Apple events, update check on request | -| `apps/pdfcraft-cli` | `inspect`, `edit`, `combine`, `extract`, `split`, `run` (any automation tool or a JSON script), `tools`, `ui` (drive the running app), and the opt-in `mcp` server | +| `apps/pdfcraft-cli` | `inspect`, `edit`, `combine`, `extract`, `split`, `run` (any automation tool or a JSON script), `tools`, `ui` (drive the running app), `view` (pages in the terminal, `src/view/README.md`), and the opt-in `mcp` server | | `apps/pdfcraft-web` | The same egui shell compiled to WebAssembly | ## Layers diff --git a/docs/cli-view.md b/docs/cli-view.md new file mode 100644 index 0000000000..763e6ea06e --- /dev/null +++ b/docs/cli-view.md @@ -0,0 +1,172 @@ +# Showing pages in the terminal + +`pdfcraft-cli view` draws PDF pages straight into the terminal, through the +[kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). kitty, Ghostty +and WezTerm speak it, among others. It works on macOS, Linux and FreeBSD, and inside tmux. + +```sh +pdfcraft-cli view report.pdf # page through it, like less +pdfcraft-cli view report.pdf --pages 1,3-5 # page through just these +pdfcraft-cli view report.pdf --fit width # as wide as the window +pdfcraft-cli view report.pdf --no-pager # print every page and exit, like cat +pdfcraft-cli view secret.pdf --password PW +``` + +On a terminal, more than one page opens the pager: one page at a time, or two side by side +when the window is wide enough. A single page (a one-page document, or `--pages 3`) is printed +at the cursor instead, and `view` exits, the way `less -F` prints text that fits. + +Output to a file or a pipe takes `--protocol kitty` (without it, `view` stops with an error). +Then `view` doesn't page: it writes every page's escape codes one after another, and `cat` shows +them later in a terminal that speaks the protocol. + +Printed pages are drawn one under another and stay in the terminal's scrollback, the same as +text that `cat` prints. `--pages` takes the same list as `extract`: single pages and ranges, in +the order given. Without it, `view` shows the whole document. + +To write an image file instead, use `render`: +`pdfcraft-cli render report.pdf --page 1 --out page.png`. + +How `view` works inside (the transports, what the terminal keeps, what tmux loses and the numbers +behind each choice) is in [`apps/pdfcraft-cli/src/view/README.md`](../apps/pdfcraft-cli/src/view/README.md). + +## Options + +| Option | Meaning | +|---|---| +| `--pages 1,3-5` | the pages to show, in order (default: all of them) | +| `--fit page` | the whole page fits the window (the default) | +| `--fit width` | the page is as wide as the window; a tall page runs past the bottom | +| `--pager` | always page, even through a single page | +| `--no-pager` | never page: print the pages and exit | +| `--compression auto` | on this machine, temporary files when the terminal takes them and `fast` otherwise; `small` over SSH (the default) | +| `--compression fast` | PNGs with the least work: pages ready sooner, about twice the bytes | +| `--compression small` | PNGs with the fewest bytes, for a slow link | +| `--protocol auto` | check that the terminal can show images first (the default) | +| `--protocol kitty` | skip the check and send the escape codes anyway, even to a file or a pipe | +| `--password PW` | the password of an encrypted document | + +## Paging through a document + +The pager shows pages on the alternate screen, like `less`. The shell's scrollback is left as it +was, and quitting returns to it. With `--pages`, the pager goes through those pages only, in +their order. + +When the window is wide enough, two pages share it side by side, like an open book: pages 1–2, +then 3–4, and the keys that turn a page turn both. Two pages go side by side when each keeps at +least 80% of the size it would have alone, as portrait pages in a wide window do. `d` switches +between two pages and one; fitting the width always shows one. + +| Keys | Action | +|---|---| +| `space`, `PgDn` | down a screen; at the bottom of a page, on to the next page (or two) | +| `b`, `PgUp` | up a screen; at the top of a page, back to the previous page (or two) | +| `j`, `↓`, `Enter` | down a few rows (then on to the next page) | +| `k`, `↑` | up a few rows (then back to the previous page) | +| `n`, `→` | next page (or two) | +| `p`, `←` | previous page (or two) | +| `g`, `Home` | first page | +| `G`, `End` | last page | +| a number, then `Enter` or `g` | that page | +| `f` | switch between fitting the page and fitting the width | +| `d` | switch between two pages side by side and one | +| `r`, `Ctrl-L` | render the pages again | +| `q`, `Esc`, `Ctrl-C` | quit | + +The pager follows the window: after a resize, the pages are drawn again for the new size, two +side by side or one as the new size allows. Pages are rendered when they are shown, on a thread +of their own: the keys keep working while a slow page renders (the status line says it is +rendering), moving on cancels it, and the renderer's watchdog gives up on a page that takes too +long (20 seconds a try) and says so. + +The pages around the one on screen are prepared ahead, so turning to them is instant. Jumping +further away waits for the page to render. + +Quitting always puts the terminal back as it was, including after an error. A pager killed by a +signal (`kill`, a closed SSH session) can leave the terminal in raw mode; `reset` fixes that. + +## Size and sharpness + +Each page is rendered for the window it is shown in, at the terminal's own pixel density, so text +stays sharp on high-DPI screens. One row is left free for the shell prompt. Pages are never +rendered larger than 8192 pixels on a side or 16 megapixels in all (more than a 4K screen shows), +which bounds the memory one page can take, however its document shapes it. + +The size in pixels usually comes with the window's size, as it does over SSH. Some ways in pass +on only rows and columns, such as `docker exec` and `kubectl exec`; then `view` asks the terminal +how big its cells are, once, when it starts, and assumes 10 × 20 pixel cells if the terminal +doesn't say. Inside tmux, tmux says. When there is no terminal to ask (output to a file with +`--protocol kitty`, no controlling terminal), `view` assumes an 80 × 24 window. + +## On this machine or over SSH + +When the terminal runs on the same machine, the pager hands it pages through temporary files, as +the protocol allows: kitty takes a page this way much faster than as a PNG inside the escape +codes. Each page's pixels go into a new file that only you can read, which the terminal +deletes as soon as it has read it. The files are in a directory of your own, +`tty-graphics-protocol-pdfcraft-`, inside `TMPDIR`, else `/dev/shm`, else `/tmp`. Any the +terminal leaves are removed when the pager ends, or, if it was killed, by the next one. + +Whether the terminal reads files from this machine is found out by trying, at the start. Until it +does, and for good when it doesn't (a terminal without temporary files, or one on another +machine), pages travel as PNGs inside the escape codes. They also do when the temporary directory +isn't safe from other users, and when printing without the pager, so saved or piped output still +works. + +A small PNG takes longer to make, and a fast one takes more bytes; the pixels are the same either +way. On the same machine, bytes are cheap and the time is what shows, so `view` compresses fast. +Over SSH, the bytes cross the network, so it compresses small and doesn't try temporary files. It +goes by `SSH_CONNECTION`, `SSH_CLIENT` and `SSH_TTY`, and inside tmux by how the tmux client +attached. `--compression fast` or `--compression small` overrides the guess, and sends PNGs even +on this machine. + +## Which terminals work + +`--protocol auto` trusts the environment for kitty (`TERM=xterm-kitty` or `KITTY_WINDOW_ID`) and +Ghostty (`TERM=xterm-ghostty` or `TERM_PROGRAM=ghostty`). Any other terminal is asked with the +query the protocol recommends, and has half a second to answer. A terminal that doesn't speak the +protocol gets an error that says so, and nothing is drawn. + +Over SSH, the image is sent inside the escape codes themselves, so `view` works there too. + +`view` fails when stdout isn't a terminal, unless `--protocol kitty` is given. Windows isn't +supported yet: Windows Terminal speaks sixel, not the kitty protocol. + +## Inside tmux + +`view` works inside tmux 3.3 and later when tmux runs in a terminal that speaks the protocol: + +- tmux doesn't know about images, so the escape codes go through tmux's passthrough. tmux only + lets them through when the pane's `allow-passthrough` option is `on` or `all`. If it is off, + `view` turns it on **for its own pane only** (`tmux set-option -p allow-passthrough on`) and says + so on stderr. To turn it on for every pane yourself, add `set -g allow-passthrough on` to + `~/.tmux.conf`. +- Pages are drawn with the protocol's *Unicode placeholders*: special characters that tmux stores + as text, which the terminal draws as the image. The page scrolls with the pane, and survives + switching windows and panes. Placeholders number at most 297 rows and columns, so inside tmux a + page is never drawn larger than that. +- Inside tmux, `TERM` describes tmux, not the terminal, so `view` asks tmux which terminal it runs + in. `xterm-kitty` and `xterm-ghostty` are trusted. For anything else, `view` refuses unless + `--protocol kitty` is given. +- Pages turned while the pane is hidden (another window in front, or the window zoomed on another + pane) arrive when it shows again: tmux throws away images sent to a hidden pane. A terminal that + attaches to the session later gets the pages too. +- With more than one terminal showing the pane, pages go as PNGs, not temporary files: the first + terminal to read a file deletes it before the others can. +- tmux throws output away when it decides the terminal can't keep up, images with it. `view` + notices and sends the pages again. If tmux keeps dropping them, the status line says so, and + `r` sends them again. + +The terminal itself must support Unicode placeholders. kitty does. Other terminals may draw nothing +inside tmux even though they draw pages outside it. + +## Errors + +- A page number past the end of the document fails before anything is drawn. +- A page that can't be rendered is reported at the end. The other pages are still drawn, and + `view` exits with an error. +- A page whose content runs past the renderer's safety budget is drawn without the rest of its + content, with a warning, as in `render`. +- File names and messages are written with their control characters replaced, so neither a file + name nor a document can send escape sequences (a clipboard write, a window title) to the + terminal through them. From 2afc0375ce68aad003062d7b6770af8d7e1a3915 Mon Sep 17 00:00:00 2001 From: Fernando Dobladez Date: Fri, 16 Oct 2026 19:17:39 -0300 Subject: [PATCH 2/4] CLI view: leave tmux's allow-passthrough to the user Review of #2920: when the pane's allow-passthrough was off, view turned it on for the pane and never put it back. The option lets any program in the pane send escape codes past tmux, not only view's images, so opening a PDF changed how the shell's pane behaves afterwards. view now only reads the pane's effective value (show-options -Ap). When it is off and stdout is the pane, view stops with the command that turns it on for that pane, naming it (tmux set -p -t %N allow-passthrough on: run from a shell without -t, tmux sets the session's active pane, which may be another), or set -g allow-passthrough on for ~/.tmux.conf. Output to a file gets the same text as a warning, and is written. Test: in a private tmux server with tmux's defaults, view under an inherited off, an off of the pane's own, an inherited off with output to a file, and an on of the pane's own: it stops, stops, writes and prints, and the pane's own setting and the global one are unchanged every time. It fails against the previous commit. The other tmux tests now start their server with allow-passthrough on. Co-Authored-By: Claude Opus 5.5 --- apps/pdfcraft-cli/src/view/README.md | 7 +- apps/pdfcraft-cli/src/view/mod.rs | 16 ++-- apps/pdfcraft-cli/src/view/tmux.rs | 45 ++++++++--- apps/pdfcraft-cli/tests/view_tmux.rs | 111 ++++++++++++++++++++++++--- docs/cli-view.md | 8 +- 5 files changed, 155 insertions(+), 32 deletions(-) diff --git a/apps/pdfcraft-cli/src/view/README.md b/apps/pdfcraft-cli/src/view/README.md index cbac890640..7310206729 100644 --- a/apps/pdfcraft-cli/src/view/README.md +++ b/apps/pdfcraft-cli/src/view/README.md @@ -104,8 +104,11 @@ a placement. Inside tmux, tmux keeps its own copy of the screen and knows nothing of images: - **Passthrough.** Every escape code goes inside `ESC P tmux; … ESC \`, with each ESC doubled. - tmux passes it on only when the pane's `allow-passthrough` is `on` or `all`; `view` turns it on - for its own pane when it is off. + tmux passes it on only when the pane's `allow-passthrough` is `on` or `all`. `view` only asks + (`show-options -Ap`): the option lets any program in the pane write to the terminal past tmux, + so it is the user's. When it is off, `view` stops with the command that turns it on, naming the + pane: run from the shell without `-t`, tmux sets it on the session's active pane, which may be + another. Output to a file only gets a warning, since it may be shown where it is on. - **Unicode placeholders.** A page is a virtual placement (`U=1`) plus rows of U+10EEEE cells that tmux stores as text, so pages scroll with the pane and survive window switches. The image id is in the cells' foreground colour: 24-bit when tmux reports `RGB` for the terminal, the 256-colour diff --git a/apps/pdfcraft-cli/src/view/mod.rs b/apps/pdfcraft-cli/src/view/mod.rs index e2c0578872..1c9dd769c3 100644 --- a/apps/pdfcraft-cli/src/view/mod.rs +++ b/apps/pdfcraft-cli/src/view/mod.rs @@ -322,17 +322,21 @@ fn ask(tty: &Tty, query: &[u8], done: impl Fn(&[u8]) -> bool) -> Result, Ok(reply) } -/// Inside tmux: ask tmux which terminal its client runs in (`TERM` names tmux itself), and make -/// sure the pane passes escape codes through. +/// Inside tmux: ask tmux which terminal its client runs in (`TERM` names tmux itself), and +/// whether the pane passes escape codes through. When it doesn't, `view` stops, and leaves the +/// setting as it is ([`tmux::passthrough`]). fn tmux_terminal(protocol: Protocol) -> Result { - if protocol == Protocol::Auto && !std::io::stdout().is_terminal() { + let to_terminal = std::io::stdout().is_terminal(); + if protocol == Protocol::Auto && !to_terminal { return Err(NOT_A_TERMINAL.into()); } let client = tmux::client(); let colour = tmux_colour(protocol, &client)?; - match tmux::ensure_passthrough() { - Ok(true) => note("view: turned on tmux's allow-passthrough for this pane, so images reach the terminal"), - Ok(false) => {} + match tmux::passthrough() { + Ok(true) => {} + // Nothing would show. Written elsewhere, the output may yet be shown where it is on. + Ok(false) if to_terminal => return Err(format!("view: {}", tmux::passthrough_off()).into()), + Ok(false) => note(&format!("warning: view: {}", tmux::passthrough_off())), Err(e) => note(&format!("warning: view: couldn't check tmux's allow-passthrough ({e}); images may not show")), } let cell = client.ok().and_then(|c| c.cell).map(|(w, h)| Cell { w: f32::from(w), h: f32::from(h) }); diff --git a/apps/pdfcraft-cli/src/view/tmux.rs b/apps/pdfcraft-cli/src/view/tmux.rs index ee284b4444..1bff2e1884 100644 --- a/apps/pdfcraft-cli/src/view/tmux.rs +++ b/apps/pdfcraft-cli/src/view/tmux.rs @@ -44,18 +44,31 @@ fn parse_client(out: &str) -> Client { Client { termname, rgb, cell } } -/// Make sure tmux passes this pane's escape codes through: `allow-passthrough` must be `on` or -/// `all` (tmux 3.3 and later drop them otherwise). Turns it on for this pane only when it is off, -/// and says whether it did. -pub(crate) fn ensure_passthrough() -> Result { +/// Whether tmux passes this pane's escape codes through: `allow-passthrough` must be `on` or +/// `all` (tmux 3.3 and later drop them otherwise). `view` only asks. The setting lets any program +/// in the pane write to the terminal past tmux, so it is the user's to change +/// ([`passthrough_off`]). +pub(crate) fn passthrough() -> Result { let pane = this_pane(); - let target = target(pane.as_deref()); - let show = tmux(&[&["show-options", "-Ap"][..], &target, &["allow-passthrough"]].concat())?; - if passthrough_allowed(&show) { - return Ok(false); - } - tmux(&[&["set-option", "-p"][..], &target, &["allow-passthrough", "on"]].concat())?; - Ok(true) + let show = tmux(&[&["show-options", "-Ap"][..], &target(pane.as_deref()), &["allow-passthrough"]].concat())?; + Ok(passthrough_allowed(&show)) +} + +/// What to tell the user when this pane's `allow-passthrough` is off: how to turn it on. +pub(crate) fn passthrough_off() -> String { + passthrough_off_for(this_pane().as_deref()) +} + +/// [`passthrough_off`] for `pane`, which the command names when it is a pane id (`%3`): run from +/// the shell, tmux's current pane is the session's active one, which may be another. +fn passthrough_off_for(pane: Option<&str>) -> String { + let pane_id = pane.filter(|p| p.strip_prefix('%').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))); + let target = pane_id.map(|p| format!(" -t {p}")).unwrap_or_default(); + format!( + "tmux's allow-passthrough is off for this pane, so tmux would drop the images. Turn it on for this pane with \ + `tmux set -p{target} allow-passthrough on`, or for every pane with `set -g allow-passthrough on` in ~/.tmux.conf \ + (it lets programs in the pane send escape codes past tmux)" + ) } /// The pane `view` runs in, as tmux names it in `TMUX_PANE`. @@ -358,4 +371,14 @@ mod tests { assert!(!passthrough_allowed("")); assert!(!passthrough_allowed("unknown option: allow-passthrough")); } + + #[test] + fn passthrough_off_says_how_to_turn_it_on_for_this_pane() { + assert!(passthrough_off_for(Some("%12")).contains("`tmux set -p -t %12 allow-passthrough on`")); + assert!(passthrough_off_for(None).contains("`tmux set -p allow-passthrough on`")); + // Anything but a pane id is left out of a command the user may paste. + for pane in ["%", "%1; rm -rf ~", "12", ""] { + assert!(passthrough_off_for(Some(pane)).contains("`tmux set -p allow-passthrough on`"), "{pane:?}"); + } + } } diff --git a/apps/pdfcraft-cli/tests/view_tmux.rs b/apps/pdfcraft-cli/tests/view_tmux.rs index 3f954f2386..13e184aad3 100644 --- a/apps/pdfcraft-cli/tests/view_tmux.rs +++ b/apps/pdfcraft-cli/tests/view_tmux.rs @@ -1,4 +1,5 @@ -//! `view --pager` inside tmux, end to end: every page on screen must reach the terminal. +//! `view` inside tmux, end to end: every page on screen must reach the terminal, and the pane's +//! `allow-passthrough` is left as it was. //! //! tmux throws output away when it decides its terminal can't keep up, and images sent through //! its passthrough go with it for good (it redraws its text, not what passed through). A screen @@ -71,16 +72,33 @@ fn fixture(test: &str, pages: usize) -> PathBuf { } /// A private tmux server, and the script(1) processes its clients run under; all go at the end, -/// the server's socket too (tmux leaves it behind when killed). +/// the server's socket too (tmux leaves it behind when killed). It starts with `config`. struct Server { socket: String, + config: PathBuf, path: Option, clients: Vec, } impl Server { + fn new(test: &str, config: PathBuf) -> Self { + Server { socket: format!("pdfcraft-cli-test-{test}-{}", std::process::id()), config, path: None, clients: Vec::new() } + } + + /// A tmux command, outside any tmux the tests run in: its output when it succeeds. + fn run(&self, args: &[&str]) -> Option> { + let out = Command::new("tmux").arg("-L").arg(&self.socket).args(args).env_remove("TMUX").env_remove("TMUX_PANE").output().ok()?; + out.status.success().then_some(out.stdout) + } + fn tmux(&self, args: &[&str]) -> Vec { - Command::new("tmux").arg("-L").arg(&self.socket).args(args).output().map(|o| o.stdout).unwrap_or_default() + self.run(args).unwrap_or_default() + } + + /// Note where the server's socket is, once it runs, to remove it at the end. + fn started(&mut self) { + let path = PathBuf::from(String::from_utf8_lossy(&self.tmux(&["display-message", "-p", "#{socket_path}"])).trim()); + self.path = path.file_name().is_some_and(|name| *name == *self.socket).then_some(path); } /// A client on a new pseudo-terminal, running `line` (a tmux command); what tmux writes to its @@ -89,7 +107,7 @@ impl Server { // Any terminal type tmux knows will do: `view` is told the protocol, and tmux passes the // escape codes through whatever it thinks the terminal is. xterm-256color is in every // terminfo database; kitty's own entry isn't. - let line = format!("stty rows 58 cols 200 && exec tmux -L {} -f /dev/null {line}", self.socket); + let line = format!("stty rows 58 cols 200 && exec tmux -L {} -f '{}' {line}", self.socket, self.config.display()); let mut client = script(&line) .env_remove("TMUX") .env_remove("TMUX_PANE") @@ -143,22 +161,23 @@ fn start(test: &str) -> Option<(Server, Arc>>)> { return None; } let pdf = fixture(test, 12); - let socket = format!("pdfcraft-cli-test-{test}-{}", std::process::id()); + // Passthrough on, as `view` needs and leaves to the user. + let config = pdf.with_file_name("tmux.conf"); + std::fs::write(&config, "set -g allow-passthrough on\n").unwrap(); let view = format!("'{BIN}' view '{}' --protocol kitty", pdf.display()); - let mut server = Server { socket, path: None, clients: Vec::new() }; + let mut server = Server::new(test, config); // A wide window: two pages side by side, and a screenful of placeholder cells per flip. let output = server.client(&format!("new-session -s view \"{view}\"")); // A tmux that can't start here (say, a terminal type it doesn't know) isn't `view`'s failure. let deadline = Instant::now() + Duration::from_secs(20); - while !Command::new("tmux").arg("-L").arg(&server.socket).arg("has-session").output().is_ok_and(|o| o.status.success()) { + while server.run(&["has-session"]).is_none() { if Instant::now() > deadline { eprintln!("warning: skipped: tmux didn't start here"); return None; } std::thread::sleep(Duration::from_millis(50)); } - let path = PathBuf::from(String::from_utf8_lossy(&server.tmux(&["display-message", "-p", "#{socket_path}"])).trim()); - server.path = path.file_name().is_some_and(|name| *name == *server.socket).then_some(path); + server.started(); // The first pages on screen. let deadline = Instant::now() + Duration::from_secs(60); while arrived(&output.lock().unwrap()).len() < 2 { @@ -264,3 +283,77 @@ fn a_terminal_that_attaches_later_gets_the_pages_too() { all_arrive(&server, &second, "in the second terminal"); server.tmux(&["send-keys", "-t", "view:0", "q"]); } + +#[test] +fn an_off_allow_passthrough_is_left_as_it_was() { + if Command::new("tmux").arg("-V").stdin(Stdio::null()).output().is_err() { + eprintln!("warning: skipped: no tmux here"); + return; + } + let pdf = fixture("passthrough", 2); + let dir = pdf.parent().unwrap().to_path_buf(); + // tmux's own defaults, under which `allow-passthrough` is off. No client: `view` runs in + // panes of a detached session, and is told the protocol. + let mut server = Server::new("passthrough", PathBuf::from("/dev/null")); + if server.run(&["-f", "/dev/null", "new-session", "-d", "-s", "pt", "sleep 600"]).is_none() { + eprintln!("warning: skipped: tmux didn't start here"); + return; + } + server.started(); + let global = server.run(&["show-options", "-g", "allow-passthrough"]); + if global.is_none() { + eprintln!("warning: skipped: this tmux has no allow-passthrough (before 3.3, it passes everything)"); + return; + } + // The pane's own setting, if any, and whether stdout goes to a file: `view` stops where + // nothing would show, and goes on otherwise; never does the pane's setting change. + let cases = [ + ("inherited off", None, false), + ("off for the pane", Some("off"), false), + ("inherited off, to a file", None, true), + ("on for the pane", Some("on"), false), + ]; + for (n, (what, own, to_file)) in cases.into_iter().enumerate() { + let file = |name: &str| dir.join(format!("{name}-{n}")).display().to_string(); + let out = if to_file { format!(" >'{}'", file("out")) } else { String::new() }; + // The pane waits for its setting before `view` runs. + let line = format!( + "while [ ! -e '{go}' ]; do sleep 0.1; done; '{BIN}' view '{pdf}' --protocol kitty --no-pager 2>'{err}'{out}; \ + echo $? >'{status}.tmp'; mv '{status}.tmp' '{status}'; exec sleep 600", + go = file("go"), + pdf = pdf.display(), + err = file("err"), + status = file("status"), + ); + let pane = server.tmux(&["new-window", "-d", "-P", "-F", "#{pane_id}", "-t", "pt", "sh", "-c", &line]); + let pane = String::from_utf8_lossy(&pane).trim().to_string(); + assert!(pane.starts_with('%'), "{what}: no pane"); + if let Some(value) = own { + server.tmux(&["set-option", "-p", "-t", &pane, "allow-passthrough", value]); + } + let setting = || String::from_utf8_lossy(&server.tmux(&["show-options", "-p", "-t", &pane, "allow-passthrough"])).into_owned(); + let before = setting(); + assert_eq!(before.is_empty(), own.is_none(), "{what}: {before:?}"); + std::fs::write(file("go"), "").unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + let status = loop { + if let Ok(status) = std::fs::read_to_string(file("status")) { + break status.trim().to_string(); + } + assert!(Instant::now() < deadline, "{what}: view didn't finish"); + std::thread::sleep(Duration::from_millis(50)); + }; + let err = std::fs::read_to_string(file("err")).unwrap_or_default(); + assert_eq!(setting(), before, "{what}: the pane's setting changed; {err}"); + let on = own == Some("on"); + assert_eq!(status == "0", on || to_file, "{what}: exit {status}; {err}"); + if !on { + // It says how to turn it on, for this very pane. + assert!(err.contains(&format!("`tmux set -p -t {pane} allow-passthrough on`")), "{what}: {err}"); + } + if to_file { + assert!(std::fs::metadata(file("out")).is_ok_and(|m| m.len() > 0), "{what}: nothing written"); + } + } + assert_eq!(server.run(&["show-options", "-g", "allow-passthrough"]), global, "the global setting changed"); +} diff --git a/docs/cli-view.md b/docs/cli-view.md index 763e6ea06e..f6da675141 100644 --- a/docs/cli-view.md +++ b/docs/cli-view.md @@ -137,10 +137,10 @@ supported yet: Windows Terminal speaks sixel, not the kitty protocol. `view` works inside tmux 3.3 and later when tmux runs in a terminal that speaks the protocol: - tmux doesn't know about images, so the escape codes go through tmux's passthrough. tmux only - lets them through when the pane's `allow-passthrough` option is `on` or `all`. If it is off, - `view` turns it on **for its own pane only** (`tmux set-option -p allow-passthrough on`) and says - so on stderr. To turn it on for every pane yourself, add `set -g allow-passthrough on` to - `~/.tmux.conf`. + lets them through when the pane's `allow-passthrough` option is `on` or `all`. The option lets + any program in the pane send escape codes past tmux, so `view` never changes it. If it is off, + `view` stops and says how to turn it on: for every pane, add `set -g allow-passthrough on` to + `~/.tmux.conf`; for one pane, run the `tmux set -p -t allow-passthrough on` it shows. - Pages are drawn with the protocol's *Unicode placeholders*: special characters that tmux stores as text, which the terminal draws as the image. The page scrolls with the pane, and survives switching windows and panes. Placeholders number at most 297 rows and columns, so inside tmux a From c3fb63530bf54c012c1afa6caf045a1b416c44ab Mon Sep 17 00:00:00 2001 From: Fernando Dobladez Date: Fri, 16 Oct 2026 19:17:47 -0300 Subject: [PATCH 3/4] CLI view: an escape sequence cut off past its ESC is never Esc Review of #2920: the pager took an escape sequence still cut off after 50 ms (ESCAPE_WAIT) as the Esc key, and its other bytes as keys. So a late cell-size or device-attributes answer, or an arrow key over SSH, that arrived in two parts further apart than that quit the pager. Now only a lone ESC with nothing after it is the Esc key. Past the ESC, a cut-off CSI, SS3 or ESC _ waits for the rest, up to the pager's existing bounds (1 s, 1 KiB), and is dropped whole if it never comes. Tests: a unit test cuts the cell-size and device-attributes answers, and both together, at every point past an ESC (no keys) and Page Down in two (Page Down); on a pseudo-terminal, ESC [ 6 ; then 250 ms later 25;14t, and ESC [ then ?62;c, leave the pager open with no page number typed, ESC [ 6 then ~ turns the page, and Esc alone still quits. Both fail against the previous commit. Co-Authored-By: Claude Opus 5.5 --- apps/pdfcraft-cli/src/view/README.md | 13 ++++-- apps/pdfcraft-cli/src/view/keys.rs | 66 +++++++++++++++++++++------ apps/pdfcraft-cli/src/view/pager.rs | 5 +- apps/pdfcraft-cli/tests/view_pager.rs | 32 +++++++++++++ 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/apps/pdfcraft-cli/src/view/README.md b/apps/pdfcraft-cli/src/view/README.md index 7310206729..64a635be6f 100644 --- a/apps/pdfcraft-cli/src/view/README.md +++ b/apps/pdfcraft-cli/src/view/README.md @@ -89,9 +89,10 @@ rendered a page for a kitty with 18 × 46 px cells at under half its resolution, `ESC [ 6 ; ; t` in about 3 ms. The query is followed by the device attributes query, which every terminal answers, so one that doesn't know the first ends the wait at once; then the 10 × 20 assumed stands. An answer arriving late reaches the pager as an unknown CSI -sequence, which is no key. Inside tmux, `#{client_cell_width}` and `#{client_cell_height}` give -the cell instead. The answer isn't asked again, so changing the font size in a session that -reports no pixels shows only on the next start. +sequence, which is no key, even cut in two (see the terminal's answers below). Inside tmux, +`#{client_cell_width}` and `#{client_cell_height}` give the cell instead. The answer isn't asked +again, so changing the font size in a session that reports no pixels shows only on the next +start. ## Directly, or inside tmux @@ -151,8 +152,10 @@ one never gets this answer by mistake. Any other answer is dropped. An answer must never be read as keys: its `ESC` would be the Esc key, which quits, and its letters keys. One cut off between reads waits for the rest, up to 512 -bytes and 1 s. At the end, the pager waits up to 100 ms after its last placements, so no late answer lands -on the shell's command line. +bytes and 1 s, and is dropped whole if the rest doesn't come. So does any escape sequence cut off +past its `ESC`, a late cell size or a key over SSH alike. Only an `ESC` alone, with nothing after +it for 50 ms, is the Esc key. At the end, the pager waits up to 100 ms after its last placements, +so no late answer lands on the shell's command line. ## What tmux loses, and how the pager notices diff --git a/apps/pdfcraft-cli/src/view/keys.rs b/apps/pdfcraft-cli/src/view/keys.rs index e00dbf5729..a1dc7d8417 100644 --- a/apps/pdfcraft-cli/src/view/keys.rs +++ b/apps/pdfcraft-cli/src/view/keys.rs @@ -1,8 +1,9 @@ //! Keys from a terminal in raw mode: bytes in, keys out. Pure, and total over any bytes. //! -//! The terminal's answers to graphics commands arrive among the keys, as `ESC _ G … ESC \` -//! (the kitty graphics protocol). They are never keys: one that says an image is missing is -//! [`Key::Missing`], and the rest are dropped. +//! The terminal's answers arrive among the keys: to graphics commands as `ESC _ G … ESC \` (the +//! kitty graphics protocol), and to `view`'s questions as CSI sequences (the cell size, device +//! attributes), late ones included. They are never keys, even cut in two: one that says an image +//! is missing is [`Key::Missing`], and the rest are dropped. /// A key the pager knows. Anything else is dropped. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -34,9 +35,9 @@ pub(crate) const MAX_ANSWER: usize = 512; const ANSWER: &[u8] = b"\x1b_G"; /// Decode `bytes` into keys, and say how many bytes were used. An escape sequence cut off at the -/// end is left for the next read, unless `flush`: then a lone ESC is the Escape key (the user -/// pressed Esc and nothing followed). A graphics answer cut off always waits for the rest, so -/// none of it is ever taken for keys. +/// end is left for the next read, whole: past its ESC it is no key, however long the rest takes +/// (the caller drops what never completes). Only a lone ESC at the end can be a key, the Escape +/// key, and is when `flush` (the user pressed Esc and nothing followed). pub(crate) fn decode(bytes: &[u8], flush: bool) -> (Vec, usize) { let mut keys = Vec::new(); let mut i = 0usize; @@ -47,11 +48,11 @@ pub(crate) fn decode(bytes: &[u8], flush: bool) -> (Vec, usize) { Some(found) => found, None => break, }, - // The start of what may be an answer waits too, unless nothing more came. - 0x1b if ANSWER.starts_with(rest) && !flush => break, + // The start of what may be an answer waits too. + 0x1b if rest == b"\x1b_" => break, 0x1b => match escape(rest) { Some(found) => found, - None if flush => (Some(Key::Escape), 1), + None if flush && rest.len() == 1 => (Some(Key::Escape), 1), None => break, }, b'\r' | b'\n' => (Some(Key::Enter), 1), @@ -168,12 +169,43 @@ mod tests { assert_eq!(decode(b"j\x1b[", false), (vec![Key::Char('j')], 1)); assert_eq!(decode(b"\x1b[6", false), (vec![], 0)); assert_eq!(decode(b"\x1bO", false), (vec![], 0)); - // Nothing followed: it was the Esc key. + // Nothing followed a lone ESC: it was the Esc key. assert_eq!(decode(b"\x1b", true), (vec![Key::Escape], 1)); + assert_eq!(decode(b"j\x1b", true), (vec![Key::Char('j'), Key::Escape], 2)); + // Past the ESC, a sequence waits for the rest even when nothing more came yet: it is no + // Esc (which quits), nor its bytes keys. + assert_eq!(decode(b"j\x1b[", true), (vec![Key::Char('j')], 1)); + assert_eq!(decode(b"\x1b[6;", true), (vec![], 0)); + assert_eq!(decode(b"\x1bO", true), (vec![], 0)); // Esc typed before another key. assert_eq!(keys(b"\x1bq"), [Key::Escape, Key::Char('q')]); } + #[test] + fn a_reply_cut_anywhere_past_its_esc_is_never_keys() { + // A late cell size and device attributes, as the terminal answers `view`'s question, and + // both together; each cut in two, the first part read alone with nothing more to come. + // (Not cut just past an ESC: an ESC alone with nothing after it is the Esc key, which is + // what the pager waits a while to tell.) + let cell = b"\x1b[6;25;14t".as_slice(); + let attributes = b"\x1b[?62;22c".as_slice(); + for reply in [cell, attributes, &[cell, attributes].concat()] { + for cut in (2..reply.len()).filter(|&cut| reply[cut - 1] != 0x1b) { + let (first, used) = decode(&reply[..cut], true); + let pending = [&reply[used..cut], &reply[cut..]].concat(); + let (rest, all) = decode(&pending, true); + assert_eq!((first, rest, all), (vec![], vec![], pending.len()), "{:?} cut at {cut}", String::from_utf8_lossy(reply)); + } + } + // A key cut in two, as over SSH, is the key. + let page_down = b"\x1b[6~".as_slice(); + for cut in 2..page_down.len() { + let (first, used) = decode(&page_down[..cut], true); + let pending = [&page_down[used..cut], &page_down[cut..]].concat(); + assert_eq!((first, decode(&pending, true)), (vec![], (vec![Key::PageDown], pending.len())), "cut at {cut}"); + } + } + #[test] fn graphics_answers_are_never_keys() { // kitty's answer to placing an image it dropped, between two keys. @@ -191,9 +223,11 @@ mod tests { // Too long to be an answer: dropped whole. let long = [b"\x1b_Gi=1;".as_slice(), &[b'q'; MAX_ANSWER]].concat(); assert_eq!(decode(&long, false), (vec![], long.len())); - // Its first bytes wait for the rest; with nothing more, they were Esc and _ typed together. + // Its first bytes wait for the rest, even when nothing more came yet. assert_eq!(decode(b"j\x1b_", false), (vec![Key::Char('j')], 1)); - assert_eq!(keys(b"\x1b_"), [Key::Escape, Key::Char('_')]); + assert_eq!(decode(b"j\x1b_", true), (vec![Key::Char('j')], 1)); + // Esc and _ typed together, then another key: Esc, and the keys. + assert_eq!(keys(b"\x1b_j"), [Key::Escape, Key::Char('_'), Key::Char('j')]); } #[test] @@ -208,8 +242,12 @@ mod tests { let (_, used) = decode(&bytes, flush); proptest::prop_assert!(used <= bytes.len()); if flush { - // All of it, but for a graphics answer still arriving. - proptest::prop_assert!(used == bytes.len() || bytes[used..].starts_with(ANSWER)); + // All of it, but for an escape sequence still arriving past its ESC: never a lone + // ESC, and short unless it is a graphics answer. + let rest = &bytes[used..]; + proptest::prop_assert!( + rest.is_empty() || (rest.len() > 1 && rest[0] == 0x1b && (rest.starts_with(ANSWER) || rest.len() < MAX_SEQUENCE + 2)) + ); } } } diff --git a/apps/pdfcraft-cli/src/view/pager.rs b/apps/pdfcraft-cli/src/view/pager.rs index 1db3e5e70c..0c9df102c2 100644 --- a/apps/pdfcraft-cli/src/view/pager.rs +++ b/apps/pdfcraft-cli/src/view/pager.rs @@ -39,7 +39,8 @@ use super::{Doc, FALLBACK_SIZE, Output, QUIET, Terminal, checked, png, printable const TICK: Duration = Duration::from_millis(200); /// How often renders in progress are checked on. const POLL: Duration = Duration::from_millis(10); -/// How long a cut-off escape sequence waits for the rest before it counts as the Esc key. +/// How long a lone ESC waits for more before it counts as the Esc key. A longer escape sequence +/// cut off waits for the rest up to [`PENDING_LIMIT`], and is never a key. const ESCAPE_WAIT: Duration = Duration::from_millis(50); /// Rows `j`, `k`, the arrows and Enter scroll. const STEP: u16 = 3; @@ -152,7 +153,7 @@ pub(super) fn run(tty: &Tty, doc: &Doc, name: &str, terminal: &Terminal, pages: } let mut redraw = false; if !pending.is_empty() { - // When nothing more arrived, a cut-off escape sequence was the Esc key. + // When nothing more arrived, a lone ESC was the Esc key. let (keys, used) = keys::decode(&pending, read.is_none()); pending.drain(..used.min(pending.len())); // What is left is the start of something cut off: it gets a while to complete. diff --git a/apps/pdfcraft-cli/tests/view_pager.rs b/apps/pdfcraft-cli/tests/view_pager.rs index 0904491e6c..cc5b1dd829 100644 --- a/apps/pdfcraft-cli/tests/view_pager.rs +++ b/apps/pdfcraft-cli/tests/view_pager.rs @@ -333,6 +333,38 @@ fn an_image_the_terminal_dropped_is_sent_again_and_its_answers_are_never_keys() assert!(view.finish().success()); } +#[test] +fn a_reply_cut_in_two_is_never_keys_and_esc_still_quits() { + if !pty_works() { + return; + } + let pdf = fixture("cut", 3); + let mut view = Session::start(&pdf, &["--protocol", "kitty"], 80, 60); + // Settled: page 1 shown, and every page sent. + view.wait_until("pages 1-3 sent", |t| t.contains(" page 1/3 ") && t.contains("q quit") && transmitted(t) == 3); + // A late cell size and device attributes, as over a slow connection: each cut past its CSI + // prefix, the rest coming later than an Esc waits for more. Neither part is a key: the ESC + // isn't Esc (which quits), nor are the digits a page number. + for (first, rest) in [("\x1b[6;", "25;14t"), ("\x1b[", "?62;c")] { + view.press(first); + std::thread::sleep(Duration::from_millis(250)); + assert!(view.child.try_wait().unwrap().is_none(), "{first:?} quit the pager"); + view.press(rest); + std::thread::sleep(Duration::from_millis(250)); + assert!(view.child.try_wait().unwrap().is_none(), "{first:?} then {rest:?} quit the pager"); + } + assert!(!view.text().contains("go to page"), "a reply typed a page number"); + // A key cut in two the same way is the key: Page Down. + view.press("\x1b[6"); + std::thread::sleep(Duration::from_millis(250)); + view.press("~"); + view.wait_until("page 2 shown", |t| t.contains(" page 2/3 ")); + // Esc on its own still quits. + view.press("\x1b"); + assert!(view.finish().success()); + assert!(view.text().contains("\x1b[?1049l"), "the alternate screen was left"); +} + /// The `i=` value at the start of a transmission. fn view_id(transmission: &str) -> String { transmission.trim_start_matches("a=t,f=100,t=d,q=2,i=").chars().take_while(char::is_ascii_digit).collect() From eba6af3d09fd55ebd3085135f582330a2935b231 Mon Sep 17 00:00:00 2001 From: Fernando Dobladez Date: Fri, 16 Oct 2026 19:21:43 -0300 Subject: [PATCH 4/4] CLI view tests: temporary directories owner-only whatever the umask Review of #2920: under a umask of 0002, common on Linux, the directory view_pager.rs makes for view's temporary files came out 0775. view rightly refuses a parent that others can write to without the sticky bit, so no file probe went out, and the two tests that expect one failed. The test now sets the directory to 0700; the check in files.rs is unchanged. Both tests fail under umask 0002 without this, and pass with it. Co-Authored-By: Claude Opus 5.5 --- apps/pdfcraft-cli/tests/view_pager.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/pdfcraft-cli/tests/view_pager.rs b/apps/pdfcraft-cli/tests/view_pager.rs index cc5b1dd829..5df9d376bf 100644 --- a/apps/pdfcraft-cli/tests/view_pager.rs +++ b/apps/pdfcraft-cli/tests/view_pager.rs @@ -444,11 +444,15 @@ fn pager_shows_even_one_page() { assert!(view.finish().success()); } -/// A directory of the test's own, empty, for `view`'s temporary files. +/// A directory of the test's own, empty, for `view`'s temporary files. Owner-only whatever the +/// umask: `view` rightly refuses a parent that others can write to without the sticky bit, which +/// a umask of 0002 (common on Linux) would make it. fn tmpdir(test: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; let dir = std::env::temp_dir().join(format!("pdfcraft-cli-view-tmp-{test}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); dir }