diff --git a/.gitignore b/.gitignore index 48d6cbf..ee70b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # Coverage artifacts (cargo-llvm-cov) /lcov.info -# Generated stress/perf fixture — regenerate on demand, never commit (see docs/TESTING.md) +# Generated stress/perf fixture — regenerate with `make large-fixture`, never commit /fixtures/specialized/large.md *.swp diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f7bad..67a02fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`[metadata · key=value, …]`); TUI shows the same line and can expand it to an inline key/value box with the new `m` key. A blank row follows the summary for visual separation. Opt out entirely via `metadata = false` - in `~/.config/termdown/config.toml`. See `docs/adr/0001-metadata-block-handling.md`. + in `~/.config/termdown/config.toml`. ### Changed - **Config location moved to the XDG path.** termdown now reads diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 21a430c..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,54 +0,0 @@ -# termdown — Context & Glossary - -Single-context project. This file is the canonical glossary for domain terms -that appear in code, ADRs, and conversations about termdown. Keep entries short. -If you're tempted to add a term that future maintainers can derive from the -code itself (struct names, file layout, etc.), don't — only put **shared -vocabulary** here. - -## Glossary - -### Frontmatter -A block of metadata written at the **very beginning** of a Markdown file, fenced -by either `---` (YAML syntax) or `+++` (TOML syntax). Used by static site -generators (Jekyll, Hugo, Zola), note apps (Obsidian, Logseq), and agent skill -files (Anthropic, Cursor) to attach structured fields (title, author, tags, -name, description, …) to a document. Not part of CommonMark or GFM. Termdown -supports both YAML and TOML fences. - -Synonym: **metadata block**. The two terms are interchangeable in this -project — `frontmatter` is the user-facing word, `MetadataBlock` is the -pulldown-cmark event name. - -### Metadata one-line summary -The single dim line termdown renders in place of a parsed frontmatter block. -Format: `[metadata · key=value, key=value, …]` — wrapped in square brackets, -truncated to terminal width with the closing `]` preserved after the ellipsis. -Identical in both `--cat` and TUI **folded** state. Followed by one blank row -for visual separation from the body. - -### Folded / Expanded (TUI metadata) -The two display states for a metadata block in TUI mode: -- **Folded** (default): one dim line — the [[metadata one-line summary]]. -- **Expanded**: an inline box listing each key/value on its own row, pushing - body content down. Triggered by the `m` key. Second `m` collapses back. - -Cat mode has no "expanded" state — only the one-line summary or nothing. - -### `metadata` -The single top-level config knob (in `~/.config/termdown/config.toml`) -controlling whether frontmatter is visible at all. `metadata = true` (the -default, and the behavior when the key is absent) renders the [[metadata -one-line summary]] / expanded box; `metadata = false` hides the metadata block -in **both** cat and TUI. The pulldown-cmark metadata extensions are always -enabled internally regardless — it only gates rendering, never parsing. -See [[adr-0001-metadata-block-handling]]. - -### Heuristic parser -The line-based key/value extractor used to turn a raw frontmatter block into -the one-line summary. Does **not** depend on a real YAML/TOML parser; splits -each non-blank line on the first `:` (YAML) or `=` (TOML) and trims. If zero -valid key/value pairs are extracted, falls back to a raw single-line join of -the block. Rationale: keeps the dependency surface small for a use case -(quick visual summary) where parse fidelity doesn't matter. -See [[adr-0001-metadata-block-handling]]. diff --git a/Cargo.lock b/Cargo.lock index eed092c..1ffa795 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,15 +24,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -926,35 +917,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1170,7 +1132,6 @@ dependencies = [ "pulldown-cmark", "ratatui", "rayon", - "regex", "serde", "terminal_size", "toml", diff --git a/Cargo.toml b/Cargo.toml index e4887da..be29acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ notify = "8" pulldown-cmark = "0.13" ratatui = "0.29" rayon = "1" -regex = "1" serde = { version = "1", features = ["derive"] } terminal_size = "0.4" toml = "0.8" diff --git a/Makefile b/Makefile index c55b6f6..5a66c9e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ help: @echo " coverage - test coverage summary in the terminal (cargo-llvm-cov)" @echo " coverage-html - generate an HTML coverage report under target/llvm-cov/html" @echo " coverage-lcov - emit lcov.info for external tooling" - @echo " large-fixture - (re)generate the gitignored stress fixture for manual perf runs (see docs/TESTING.md)" + @echo " large-fixture - (re)generate the gitignored stress fixture for manual perf runs" fmt: $(CARGO) fmt --all diff --git a/README.md b/README.md index dd8ba23..623114a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [中文文档](README_CN.md) -Render Markdown with large-font headings in the terminal using the Kitty graphics protocol. +termdown uses the Kitty graphics protocol to render Markdown with large-font headings in the terminal, providing a reading experience closer to a GUI Markdown reader. @@ -11,22 +11,18 @@ Render Markdown with large-font headings in the terminal using the Kitty graphic
-## Motivation +## Features -Inspired by [glow](https://github.com/charmbracelet/glow) and [mdfried](https://github.com/benjajaja/mdfried). +termdown rasterizes H1-H3 headings as PNG images and draws them directly in the terminal through the Kitty graphics protocol. It provides two modes: -glow is a great terminal Markdown renderer, but headings are only distinguished by ANSI bold/color -- they can't actually be displayed at a larger size. mdfried supports image-rendered headings, but requires entering a TUI. +- **Interactive TUI** (default) -- `termdown README.md` provides a vim/less-like experience with paging, search, a table of contents, and link navigation for longer documents. +- **Direct output** -- `termdown --cat README.md` prints rendered Markdown like `cat`, making it suitable for short documents or piping to other programs. -termdown rasterizes H1-H3 headings as PNG and paints them via the Kitty graphics protocol. Two modes share the same renderer: - -- **Interactive TUI** (default when a file is given) -- vim-style browser with search, Table of Contents, and link-follow navigation for longer documents. -- **Direct output** (`--cat`, or automatic when stdout is piped / input comes from stdin) -- dump rendered Markdown straight into your terminal. - -H4-H6 headings always fall back to ANSI bold text. +H4-H6 headings always use ANSI bold text instead of simulating more font sizes and weights that could reduce readability in a terminal. ## Installation -### From crates.io (recommended, requires Rust) +### Cargo ```sh cargo install termdown @@ -34,14 +30,7 @@ cargo install termdown Installs into `~/.cargo/bin/`. Requires Rust 1.95+. -> **Linux:** no `-dev` packages or `pkg-config` are required to build — only a -> C toolchain (freetype is compiled from source when the system one isn't -> found), and fontconfig is loaded lazily at run time. For system font -> discovery (including CJK headings), install `fontconfig` plus the fonts you -> want (e.g. `apt install fontconfig fonts-noto-cjk`). Without it, termdown -> falls back to its bundled font. - -### Prebuilt binary (no Rust toolchain needed) +### Install script ```sh curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/install.sh | bash @@ -50,7 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/install.sh | b Defaults to `/usr/local/bin`. Override the target directory with `TERMDOWN_INSTALL_DIR`.
-Manual download (no script) +Manual download ```sh TARGET=aarch64-apple-darwin @@ -66,7 +55,7 @@ sudo mv termdown /usr/local/bin/
-### From git (latest development snapshot) +### Install from source ```sh cargo install --git https://github.com/rrbe/termdown @@ -106,18 +95,20 @@ termdown --help termdown --watch notes.md ``` -The full CLI reference, TUI key bindings, configuration, and known issues live in the **[Usage Guide](docs/USAGE.md)**. Configuration is optional and lives at `~/.config/termdown/config.toml` -- see [`config.example.toml`](config.example.toml) for every default. +## Documentation + +- [Usage guide](docs/USAGE.md) +- [Project overview](docs/OVERVIEW.md) +- Configuration and defaults: [`config.example.toml`](config.example.toml) +- Configuration file: `~/.config/termdown/config.toml` ## Terminal Support -Requires a terminal with **Kitty graphics protocol** support: +Requires a terminal with **Kitty graphics protocol** support, such as: -- [Ghostty](https://ghostty.org) - [Kitty](https://sw.kovidgoyal.net/kitty/) -- [WezTerm](https://wezfurlong.org/wezterm/) - [iTerm2](https://iterm2.com) - -On unsupported terminals, termdown prints a warning and heading images may not display correctly. H4-H6 headings always render as plain ANSI bold text. +- [Ghostty](https://ghostty.org) ## License diff --git a/README_CN.md b/README_CN.md index 5e03371..c7ffd81 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ [English](README.md) -在终端中以大字体标题渲染 Markdown,让观感更接近 GUI Markdown 阅读器的体验,基于 Kitty 图形协议。 +termdown 基于 Kitty 图形协议,在终端中以大字体标题渲染 Markdown,提供更接近 GUI Markdown 阅读器的阅读体验。 @@ -11,23 +11,18 @@
-## 为什么做这个 - -本项目受 [glow](https://github.com/charmbracelet/glow) 和 [mdfried](https://github.com/benjajaja/mdfried) 启发。 - -- **glow** 不支持放大标题字体 -- **mdfried** 支持放大 markdown 标题,但个人感觉可以做的更美观一点 +## 功能 termdown 将 H1-H3 标题栅格化为 PNG 图片,通过 Kitty 图形协议直接绘制到终端。提供两种使用模式: -- **交互式 TUI**(默认)—— `termdown README.md`,类 vim/less 的体验,支持常见的翻页、搜索等快捷键,支持查看 TOC、链接跳转,适合阅读较长文档。 -- **直接输出**(`--cat`,或当 stdout 被管道/重定向、输入来自 stdin 时自动启用)—— 像 `cat` 一样轻量、管道友好,把渲染后的 Markdown 直接打到终端。 +- **交互式 TUI**(默认)—— `termdown README.md`,提供类似 vim/less 的体验,支持翻页、搜索、查看目录和链接跳转,适合阅读较长文档。 +- **直接输出** —— `termdown --cat README.md`,像 `cat` 一样直接输出渲染后的 Markdown,适合查看短文档或通过管道交给其他程序处理。 -H4-H6 标题始终以 ANSI 粗体文本渲染。不想让文档加入那么多种字重,那样反而损害可读性。 +H4-H6 标题始终以 ANSI 粗体文本渲染,不再模拟更多字号和字重,以免损害终端中的可读性。 ## 安装 -### 从 crates.io(推荐,需要 Rust) +### Cargo ```sh cargo install termdown @@ -35,13 +30,13 @@ cargo install termdown 安装到 `~/.cargo/bin/`。需要 Rust 1.95+。 -### 安装脚本(无需 Rust 工具链) +### 脚本安装 ```sh curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/install.sh | bash ``` -默认装到 `/usr/local/bin`。用 `TERMDOWN_INSTALL_DIR` 覆盖安装目录。 +默认装到 `/usr/local/bin`。可以用 `TERMDOWN_INSTALL_DIR` 覆盖安装目录。
手动下载 @@ -60,7 +55,7 @@ sudo mv termdown /usr/local/bin/
-### 从源码 +### 源码安装 ```sh cargo install --git https://github.com/rrbe/termdown @@ -100,18 +95,20 @@ termdown --help termdown --watch notes.md ``` -完整的命令行参数、TUI 快捷键、配置项和已知问题都在 **[使用指南](docs/USAGE_CN.md)**。配置是可选的,位于 `~/.config/termdown/config.toml` —— 全部默认值见 [`config.example.toml`](config.example.toml)。 +## 文档 + +- [使用指南](docs/USAGE_CN.md) +- [项目概览](docs/OVERVIEW.md) +- 配置和默认值:[`config.example.toml`](config.example.toml) +- 配置文件:`~/.config/termdown/config.toml` ## 终端支持 -需要支持 **Kitty 图形协议** 的终端(目前仅在 Ghostty 和 iTerm2 上测试过): +需要支持 **Kitty 图形协议** 的终端,比如: -- [Ghostty](https://ghostty.org) - [Kitty](https://sw.kovidgoyal.net/kitty/) -- [WezTerm](https://wezfurlong.org/wezterm/) - [iTerm2](https://iterm2.com) - -不支持的终端会打印警告。H4-H6 标题始终以 ANSI 粗体文本渲染。 +- [Ghostty](https://ghostty.org) ## 许可证 diff --git a/TODO.md b/TODO.md index 6414dd1..c0eea74 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,5 @@ -- [ ] 测试 html 标签支持 -- [ ] 图片支持 -- [ ] 长文本换行时缩进的处理 -- [ ] 找出真实 MSRV 并下调 `rust-version`(当前 `1.95` 是跟本地对齐,触达面窄) - - 本地跑 `cargo install cargo-msrv && cargo msrv find`,二分出最低能编译的版本 - - 同步更新 `Cargo.toml` 的 `rust-version` 和 `README.md` 里的 "Requires Rust X.Y+" - - 在 `.github/workflows/ci.yml` 加一个 `msrv` job(`cargo check --all-targets` on pinned toolchain),防止以后 PR 悄悄抬高 MSRV -- [x] 测试 markdown metadata 支持 -- [ ] 检测文件变化 -- [x] 文件到顶、末尾时,播放声音提示,增加喇叭icon -- [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示 -- [ ] 整理项目文档 -- [x] 整理测试用的 markdown 文件,现在太乱 +- [ ] 完善 HTML 标签测试 +- [ ] 支持正文图片 +- [ ] 修复长文本换行后的缩进 +- [ ] 测定并在 CI 固定真实 MSRV +- [ ] 支持目录与正文的键盘焦点切换 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index d71dfad..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,285 +0,0 @@ -# Architecture - -## Overview - -termdown has **two output paths that share one rendering core**. `layout::build` -parses Markdown into a `RenderedDoc` — a structured, terminal-agnostic line/span -model with heading images already rasterized. The **cat** path streams that doc -to stdout once and exits; the **TUI** path drives it as an interactive, -scrollable, vim-style pager. Both consume the same `RenderedDoc`, so wrapping, -styling, and heading rendering never fork. - -``` - ┌──────────────────────┐ - │ Markdown source │ - └───────────┬──────────┘ - │ - ┌───────────▼───────────┐ - │ layout::build │ pulldown-cmark events → - │ → RenderedDoc │ lines + spans + heading - │ (rayon-parallel │ PNGs + frontmatter - │ heading PNGs) │ - └───────────┬───────────┘ - │ - ┌─────────────┴─────────────┐ - │ │ - ┌────────▼────────┐ ┌──────────▼─────────┐ - │ cat::print │ │ tui::run │ - │ ANSI → stdout │ │ ratatui pager + │ - │ (one shot) │ │ Kitty image │ - │ │ │ lifecycle │ - └─────────────────┘ └────────────────────┘ -``` - -### Mode dispatch (`main.rs`) - -TUI is the **default**. `main` selects the path: - -- **TUI** when a real file path is given *and* stdout is a TTY *and* `--cat` is - absent. -- **cat** otherwise — `--cat`, piped/redirected stdout (`termdown foo.md | less`), - or stdin input (`-` or no argument). - -`main.rs` also parses `--theme` / `--no-bell` / `--help` / `--version`, resolves -the theme (CLI flag > config file > OSC 11 auto-detect), warns on terminals -unlikely to support Kitty graphics, and manages UNIX terminal echo state for cat -mode. - -## Module Overview - -``` -src/ -├── main.rs CLI entry: arg parsing, mode dispatch, theme resolution, -│ terminal-support warning, UNIX termios echo handling -├── config.rs XDG config load (~/.config/termdown/config.toml); -│ theme/bell/metadata/font options; legacy-path migration warning -├── theme.rs Theme {Dark, Light} + OSC 11 background auto-detection -├── style.rs HeadingStyle, ANSI constants, theme-aware Colors palette, -│ strip_ansi / display_width helpers -├── font.rs Latin/CJK/emoji font resolution + per-level cache, is_cjk() -├── frontmatter.rs YAML (---) / TOML (+++) metadata-block heuristic parser + summary -├── render.rs Glyph rasterization, PNG encoding, Kitty protocol primitives -│ (transmit / place / delete), HeadingImage -├── layout.rs ★ Shared core: pulldown-cmark → RenderedDoc (Line / Span / Style) -├── cat.rs RenderedDoc → stdout ANSI stream (cat path) -└── tui/ Interactive pager (default mode) - ├── mod.rs App state, doc stack, event loop, frame rendering - ├── input.rs KeyEvent → Action mapping - ├── viewport.rs Scroll offset + width-aware wrap cache - ├── search.rs Smart-case literal substring search over RenderedDoc - └── kitty.rs Transmit-once + per-frame placement-diff image lifecycle -``` - -## The RenderedDoc model (`layout.rs`) - -`layout::build(md, config, theme)` is the single Markdown→structure step. It runs -pulldown-cmark with GFM strikethrough, tables, and YAML/TOML metadata-block -extensions enabled, and produces: - -``` -RenderedDoc -├── lines: Vec // each Line = Vec + LineKind -├── headings: Vec // ToC / heading-jump targets (level, text, line_index) -├── images: Vec // rasterized H1–H3 PNGs, referenced by id from spans -└── metadata: Option // parsed frontmatter (never leaks into `lines`) -``` - -- **`LineKind`** classifies each line: `Body`, `Heading{level, id}`, - `CodeBlock{lang}`, `BlockQuote{depth}`, `ListItem{depth}`, `Table`, - `HorizontalRule`, `Blank`. `id` is `Some` for H1–H3 (image) and `None` for - H4–H6 (ANSI bold text). -- **`Span`** is `Text{content, style}`, `Link{content, url, style}`, or - `HeadingImage{id, rows}`. Styling is structural — `Style{fg, bg, bold, italic, - underline, strikethrough, dim}` over `Color::Indexed | Rgb` — so the same doc - can be emitted as ANSI (cat) or painted as ratatui spans (TUI) without - re-parsing. -- **Heading images** are rasterized during `build` in parallel via rayon - (`par_iter` over heading text → `render::render_heading`); rasterization is the - dominant cost in a document. - -## Rendering Pipeline (heading image) - -H1–H3 headings become PNGs through this sub-pipeline; everything else stays ANSI -text. - -``` - ┌────────────────────┐ - │ Heading H1/H2/H3 │ - └─────────┬──────────┘ - │ per-character routing - ┌─────────▼──────────────────────┐ - │ is_emoji_like(ch) → emoji font │ - │ is_cjk(ch) → CJK font │ - │ else → Latin font │ - └─────────┬──────────────────────┘ - │ - ┌─────────▼─────────┐ - │ ab_glyph │ - │ rasterize → RGBA │ - └─────────┬─────────┘ - │ - ┌─────────▼─────────┐ - │ PNG encode │ - └─────────┬─────────┘ - │ - ┌─────────▼─────────┐ - │ Kitty graphics │ - └───────────────────┘ -``` - -## Font Resolution - -For each heading level a `FontSet` (Latin + CJK + optional emoji) is resolved and -cached for the process lifetime — resolution is ~30–40 ms per font on macOS, so -it is memoized per level: - -``` -1. User config [font.heading] latin / cjk / emoji - │ - ▼ -2. Explicit weight-variant family names (macOS workaround) - Core Text registers bold variants as separate families, so try - "{family} Black" / "{family} Heavy" before standard matching - │ - ▼ -3. Standard weight matching - font-kit select_best_match with Weight::BLACK / EXTRA_BOLD / BOLD - │ - ▼ -4. Platform defaults - Latin: Avenir, Avenir Next, Futura, Helvetica Neue (macOS) - Inter, Noto Sans, DejaVu Sans, Liberation Sans (Linux) - Segoe UI, Arial, Verdana (Windows) - CJK: Noto Serif CJK SC, Source Han Serif SC, … (per platform) - Emoji: Apple Color Emoji (macOS) / Noto Color Emoji (Linux) / - Segoe UI Emoji (Windows) - │ - ▼ -5. Embedded fallback - fonts/SourceSerif4-SemiBold.ttf (bundled in binary via include_bytes!) -``` - -Font data loaded from disk or Core Text is `Box::leak`-ed into `'static` -lifetime and cached in a global map to avoid repeated allocation. - -## CJK / Latin / Emoji split - -`font::is_cjk(ch)` routes characters to the CJK font by Unicode block; -`font::is_emoji_like(ch)` routes emoji and symbol glyphs to the emoji font -(rasterized as color bitmaps). Everything else (ASCII, Latin, Cyrillic, …) uses -the Latin font. - -| Range | Block | -|-------|-------| -| U+2E80..U+9FFF | CJK Radicals through Unified Ideographs (includes Hiragana, Katakana, CJK Symbols) | -| U+AC00..U+D7AF | Hangul Syllables | -| U+F900..U+FAFF | CJK Compatibility Ideographs | -| U+FE30..U+FE4F | CJK Compatibility Forms | -| U+FF00..U+FFEF | Halfwidth and Fullwidth Forms | -| U+20000..U+2FA1F | CJK Extensions B–F, Supplement | - -## Kitty Graphics Protocol - -termdown emits heading PNGs two ways depending on the path. - -**cat path — transmit-and-display inline.** A single `a=T` run transmits and -immediately displays at the cursor: - -``` -\x1b_G f=100,a=T,q=2,m=1 ; \x1b\ -\x1b_G m=1 ; \x1b\ -... -\x1b_G m=0 ; \x1b\ -``` - -- `f=100` — PNG format -- `a=T` — transmit and display -- `q=2` — suppress response (avoids the iTerm2 "OK" leak) -- `m=1/0` — more chunks / last chunk -- Chunk size: 4096 bytes base64 - -**TUI path — transmit once, place/delete per frame.** `render::transmit` (`a=t`) -uploads each PNG to the terminal exactly once, keyed by id. On each frame -`tui::kitty::ImageLifecycle` diffs the desired placement map against what is -currently placed and emits the minimum `place` (`a=p`, with `C=1` so the cursor -does not advance and scroll the screen) / `delete_placement` commands; -`delete_all_for_client` cleans up at exit. This avoids the per-frame PNG -re-transmission that makes similar tools feel sluggish. - -## ANSI Text Rendering (cat path) - -`cat::print` streams the `RenderedDoc` to stdout, wrapping to terminal width and -emitting Kitty heading images inline. Rendering is driven by each line's -`LineKind` / `Span`: - -| Element | Rendering | -|---------|-----------| -| H1–H3 | PNG via Kitty graphics | -| H4–H6 | Bold ANSI text | -| Paragraphs | Word-wrapped to terminal width | -| Ordered lists | Numbered with counter per nesting level | -| Unordered lists | Bullet (•) with indent per level | -| Blockquotes | Vertical bar (│) per nesting depth, italic gray | -| Code blocks | Buffered and padded to uniform width for a clean background rectangle | -| Inline code | Pink on dark gray | -| Links | Colored + underline, with the URL shown | -| Tables | Unicode box-drawing, ANSI-aware column width | -| Horizontal rule | ─ repeated to terminal width | -| Images | Placeholder with alt text | -| Frontmatter | Dim one-line summary `[metadata · k=v, …]` (when `metadata` enabled) | - -## TUI Mode (`tui/`) - -Interactive pager built on ratatui + crossterm. The body is painted as a ratatui -text layer; heading images float above it via the Kitty placement lifecycle. - -- **Modes:** `Normal`, `Search{…}`, `LinkSelect{…}`, `Help`. `input::map_normal` - turns key events into intent-level `Action`s; `mod.rs` dispatches them to state - mutations. -- **Navigation:** vim-style paging, `gg` / `G`, heading jumps, `/` search with - `n` / `N`. Search is smart-case literal substring matching (`search.rs`); regex - is deferred to a future version. -- **Document stack:** following a local `.md` link pushes a new `DocEntry`; - back/forward keys pop/replay the stack, each doc preserving its own scroll - position and search state. -- **Viewport** (`viewport.rs`): scroll offset plus a width-aware wrap cache - (`VisualLine`s), including synthetic rows for the foldable metadata box. -- **Table of contents:** a side panel built from `RenderedDoc.headings`. -- **Edge bell:** a terminal BEL on blocked scroll past the top/bottom (vim-style), - disabled via `--no-bell` or `bell = false`. The visible effect (beep, title-bar - 🔔, dock bounce) is the emulator's own response to BEL, not something termdown - paints. -- **Metadata box:** the frontmatter summary folds/expands inline (`m`). - -## Terminal State (UNIX, cat mode) - -iTerm2 ignores Kitty's `q=2` response-suppression flag and emits `OK` ACKs -anyway. So on UNIX, **only under iTerm2** (`TERM_PROGRAM == iTerm.app`), -`main.rs` disables `ECHO` before rendering and restores it after, then -`render::drain_iterm2_acks` waits briefly and discards the leaked bytes. Other -terminals (Ghostty, Kitty, WezTerm) respect `q=2` and are left untouched — -notably so termdown does not trip Ghostty's Secure Keyboard Entry heuristic, -which treats `~ECHO` as a password prompt. Guarded by `#[cfg(unix)]`. - -## Configuration - -Loaded once at startup from `~/.config/termdown/config.toml` (XDG: an absolute -`$XDG_CONFIG_HOME` is honored, otherwise `~/.config`). A config still sitting at -the legacy `~/.termdown/config.toml` triggers a one-line migration warning. -Unknown keys and invalid values are hard errors surfaced as warnings, not silent -fallbacks. - -``` -Config -├── theme: Option // auto (default) | dark | light; CLI --theme overrides -├── bell: Option // edge-scroll BEL, default on; CLI --no-bell overrides -├── metadata: Option // render frontmatter, default on -└── font: FontSection - └── heading: HeadingFontConfig - ├── latin: Option - ├── cjk: Option - └── emoji: Option -``` - -Missing file or fields fall back to defaults. `config.example.toml` documents the -effective defaults and is guarded by a test (`config.rs`) so the two never drift. diff --git a/docs/ITERM2_KITTY_RESPONSE_LEAK.md b/docs/ITERM2_KITTY_RESPONSE_LEAK.md deleted file mode 100644 index f72b30f..0000000 --- a/docs/ITERM2_KITTY_RESPONSE_LEAK.md +++ /dev/null @@ -1,162 +0,0 @@ -# iTerm2 Kitty 图形协议响应泄漏问题 - -## 现象 - -在 iTerm2 中运行 termdown 渲染 Markdown 时,标题图片可以正常显示,但会伴随出现大量乱码: - -``` -^[_Gi=0,p=0;OK^[\^[_Gi=0;OK^[\^[_Gi=0,p=0;OK^[\^[_Gi=0;OK^[\% -``` - -在 shell prompt 行也会出现类似内容: - -``` -README.mdGi=0,p=0;OKREADME.mdGi=0;OKREADME.mdGi=0,p=0;OKREADME.md... -``` - -在 Ghostty、Kitty、WezTerm 等终端中不会出现此问题。 - -## 原因分析 - -### Kitty 图形协议的请求-响应模型 - -Kitty 图形协议是一个**双向协议**。当应用向终端发送图片命令时: - -``` -应用 → 终端: \x1b_Gf=100,a=T,m=0;{base64_png}\x1b\\ -终端 → 应用: \x1b_Gi=0;OK\x1b\\ -``` - -终端处理完图片后,会通过 PTY 的输入端(即应用的 stdin)发回一个确认响应。 - -### iTerm2 的行为差异 - -- **Ghostty / Kitty / WezTerm**:正确处理响应,应用不读取也不会造成问题 -- **iTerm2**:发送 `OK` 响应到 PTY 输入缓冲区,由于 TTY 驱动的 echo 设置,这些字节被**回显到屏幕上** - -关键点:终端默认处于 cooked mode(canonical mode),TTY 驱动会自动 echo 所有写入输入缓冲区的内容。iTerm2 的响应经过 PTY 输入端时被 echo 机制显示,导致出现乱码。 - -### 数据流路径 - -``` -termdown → stdout → PTY master → 终端模拟器(iTerm2) - | - | 处理图片命令,生成响应 - v -termdown ← stdin ← PTY slave ← 终端模拟器(iTerm2) - | - | TTY 驱动 echo 开启 - v - 屏幕上显示响应内容(乱码) -``` - -## 排查过程 - -### 尝试 1:Kitty 协议 quiet 标志 `q=2` - -Kitty 图形协议规范定义了 `q` 参数来控制响应行为: - -- `q=0`(默认):终端发送所有响应 -- `q=1`:仅发送错误响应,抑制 OK -- `q=2`:抑制所有响应 - -修改了 `kitty_display()` 的首包格式: - -```rust -// 添加 q=2 参数 -"\x1b_Gf=100,a=T,q=2,m={m};{chunk}\x1b\\" -``` - -**结果:无效。** iTerm2 不遵守 `q=2` 标志,仍然发送 OK 响应。 - -### 尝试 2:程序退出前 `tcflush` 清空 stdin - -在 `main()` 末尾添加 `tcflush(STDIN_FILENO, TCIFLUSH)` 来丢弃 stdin 缓冲区中积累的响应: - -```rust -fn drain_terminal_responses() { - let _ = io::stdout().flush(); - std::thread::sleep(Duration::from_millis(50)); - unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH); } -} -``` - -**结果:部分有效。** 减少了乱码数量,但仍有残留。原因:`tcflush` 只能丢弃调用时刻 stdin 缓冲区中的内容。如果响应在 flush 之后才到达(因为终端处理图片需要时间),则无法被清除。 - -### 尝试 3:每张图片输出后 flush + drain - -将 drain 逻辑移到每张图片输出之后,确保 BufWriter flush 后立即清空响应: - -```rust -// markdown.rs 中,每次输出图片后 -let _ = out.flush(); -render::drain_kitty_responses(); -``` - -**结果:部分有效,但乱码位置改变。** 问题的本质没有解决——`tcflush` 丢弃的是 stdin 缓冲区中的字节,但 TTY echo 机制在字节进入缓冲区的**瞬间**就已经把它们显示到屏幕上了。`tcflush` 能阻止后续 `read()` 读到这些字节,但无法撤回已经显示在屏幕上的内容。 - -### 尝试 4(最终方案):禁用 TTY echo - -既然问题的根源是 TTY 驱动的 echo 机制,直接在渲染前禁用 echo: - -```rust -fn disable_echo() -> libc::termios { - unsafe { - let mut termios: libc::termios = std::mem::zeroed(); - libc::tcgetattr(libc::STDIN_FILENO, &mut termios); - let saved = termios; - termios.c_lflag &= !libc::ECHO; - libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios); - saved - } -} -``` - -完整流程: - -1. 保存当前 termios 状态 -2. 清除 `ECHO` 标志位 -3. 执行所有 Markdown 渲染(包括 Kitty 图形协议输出) -4. `tcflush` 清空 stdin 缓冲区中积累的响应 -5. 恢复原始 termios 状态 - -**结果:完全解决。** 终端响应仍然被写入 stdin 缓冲区,但 TTY 驱动不再将其回显到屏幕上。渲染结束后 `tcflush` 清除缓冲区,恢复 echo 后 shell 正常工作。 - -## 最终实现 - -涉及文件: - -- `src/main.rs`:渲染(`layout::build` + `cat::print`)前后管理 termios 状态 -- `src/render.rs`:`drain_iterm2_acks()` 函数 + `q=2` 保留在协议序列中 -- `Cargo.toml`:新增 `libc` 依赖 - -`q=2` 虽然对 iTerm2 无效,但保留它是正确的——对于遵守协议规范的终端(Ghostty、Kitty 等),`q=2` 可以从源头避免响应产生,echo 禁用只是作为兜底方案。 - -## 兼容性 - -| 终端 | 是否受此 bug 影响 | `q=2` 是否有效 | echo 禁用是否安全 | -|------|:-:|:-:|:-:| -| Ghostty | 否 | 是 | 是(无副作用) | -| Kitty | 否 | 是 | 是(无副作用) | -| WezTerm | 否 | 是 | 是(无副作用) | -| iTerm2 | **是** | 否 | **是(解决问题)** | - -对于不受影响的终端,禁用 echo 是一个无害操作——因为在正常渲染过程中应用本来就不需要读取用户输入。 - -## 经验总结 - -1. **终端协议规范 ≠ 终端实际行为。** iTerm2 声称支持 Kitty 图形协议,但不遵守 `q=2` quiet 标志。处理终端兼容性时不能只看规范,要实测每个终端的行为。 - -2. **理解数据流经过的每一层。** 这个 bug 涉及三层:应用层(Kitty 协议命令)→ PTY/TTY 驱动层(echo 机制)→ 终端模拟器层(响应生成)。只盯着应用层或协议层是找不到解决方案的。 - -3. **`tcflush` 清空的是缓冲区内容,不是已经显示的内容。** 这是一个容易混淆的点:丢弃 stdin 缓冲区并不能撤回 TTY echo 已经输出到屏幕上的字节。 - -4. **修复方案要在正确的层级操作。** 协议层的 `q=2` 和缓冲区层的 `tcflush` 都不够,最终需要在 TTY 驱动层禁用 echo 才能彻底解决。 - -## 更新 — echo 抑制改为仅 iTerm2 启用 - -最初的实现对**所有终端**无条件禁用 echo(上文「兼容性」表把它对 Ghostty / Kitty / WezTerm 标为「无害」)。后来发现这个前提并不成立:**Ghostty 的 Secure Keyboard Entry 启发式会把 `~ECHO`(关闭 echo)当作密码输入提示**而自动进入安全键盘模式,属于明显的副作用。 - -因此现在的代码(`src/main.rs::needs_echo_suppression`)只在 `TERM_PROGRAM == iTerm.app` 时才禁用 echo;Ghostty / Kitty / WezTerm 依赖 `q=2` 从源头抑制响应,termdown 不再改动它们的 termios。渲染结束后只在 iTerm2 路径上调用 `render::drain_iterm2_acks()`(短暂等待后丢弃泄漏的 ACK 字节)。 - -也就是说,上文「兼容性」表中「echo 禁用是否安全」一列对非 iTerm2 终端的结论应更正为:**不再禁用**——既无必要(它们遵守 `q=2`),又会误触 Ghostty 的安全键盘。 diff --git a/docs/LINK_PICKER_DESIGN.md b/docs/LINK_PICKER_DESIGN.md deleted file mode 100644 index b258b2f..0000000 --- a/docs/LINK_PICKER_DESIGN.md +++ /dev/null @@ -1,94 +0,0 @@ -# Link Picker — Design Options - -## Problem - -Current `LinkSelect` mode (`handle_link_select_key` + the status-bar overlay in `src/tui/mod.rs`): - -- Collects every link in the viewport via `visible_links`. -- Status-bar overlay shows up to **9** labels (`take(9)` + `…`), keybinding only accepts digits `1`–`9`. -- When the viewport holds many links, or labels are long, the single status-bar row truncates and the 10th+ links are unselectable. - -Two alternative designs are recorded here. Neither has been implemented yet. - ---- - -## Option C — Vimium-style inline hints - -Paint a short label directly next to each visible link, in the body itself. - -### UX - -1. User presses `Enter` on a viewport with ≥2 links. -2. Each link is prefixed with a styled label: `[1]`, `[2]`, … `[9]`, `[a]`, `[b]`, … For >26 links, use two-char labels like `[aa]`, `[ab]`. -3. User types the label. Single-char labels fire immediately; multi-char labels commit after the second keystroke. Partial matches stay pending and filter the remaining hints (like Vimium "linkHints filter"). -4. `Esc` cancels. - -### Pros - -- Scales to arbitrarily many links. -- Positional: the user sees *which* link each label points at without scanning a status bar. -- Works even when the body is dense with links. - -### Cons / open questions - -- Rendering: labels must be injected as styled prefix spans inside `clipped_spans`. Must not shift the body layout's column alignment for heading images (`MARGIN_WIDTH` gutter) or break search-match byte offsets. -- If labels are injected as real text, the wrap cache needs to reflow; probably easier to inject labels *only* for the active frame via a render-time overlay, not as layout-level spans. -- Label alphabet and length policy: prefer "home-row first" (`a s d f j k l`) like Vimium, or keep `1–9` plus `a–z`? Pick before implementing. -- Interaction with kitty heading images — labels sit in text rows, so no conflict, but the column math for image placement must ignore the injected label width. - ---- - -## Option D — Links side panel (parallel to ToC) - -Reuse the existing ToC sidebar pattern. Add a `Mode::Links` (or reuse the TOC panel slot with a tab switch) toggled by `l`. - -### UX - -1. User presses `l` → a left panel opens (same 30-col width as ToC today — `TOC_PANEL_WIDTH` in `src/tui/mod.rs`). -2. Panel lists every link in the **whole document** (not just viewport), grouped visually by heading section, each entry formatted as `` or similar. External vs local `.md` gets a type badge (`↗` external, `↪` local). -3. `j`/`k` (or arrows) moves the selection; `Enter` opens the selected link (follows existing `open_link_target` path). -4. `l` again, or `Esc`, closes the panel. -5. Selection state is per-doc (lives on `DocEntry`, like `toc_open`), so back/forward preserves where the user was in the link list. - -### Pros - -- No new overlay paradigm — mirrors the existing `t`/ToC interaction, low learning cost. -- Covers *all* links in the doc, not just the ones in the current viewport. Good for documents with many cross-references. -- Easy to show extra metadata inline (URL, type badge, maybe "visited" marker via history stack). -- Scrollable list; no label-alphabet cap. - -### Cons / open questions - -- Loses positional context — user sees a list, not "this specific link I'm looking at on the page." -- Body width shrinks while the panel is open; kitty image placements must re-register (already handled for ToC via `needs_full_redraw`). -- If user wants to pick a link that's currently under the cursor, going through a list feels heavier than pressing Enter once. -- Can `l` coexist with ToC open? Cleanest: only one left panel at a time; opening Links auto-closes ToC and vice-versa. - ---- - -## Recommendation (not final) - -Options C and D address different use cases: - -- **C** optimizes for the *"I'm reading and want to follow a specific link I can see"* flow. -- **D** optimizes for the *"I want to survey every link in this doc"* flow. - -They are complementary. A realistic plan could be: - -1. Ship **D** first — it's a straightforward extension of existing ToC infrastructure and immediately unblocks the >9-link case. -2. Revisit **C** later if the inline-hint flow feels worth the rendering complexity. - -Either way, the status-bar `take(9)` overlay should be retired once a replacement lands. - ---- - -## Update — 2026-05-22: `MARGIN_WIDTH` gutter no longer exists - -The constraint phrased as "must not shift the body layout's column -alignment for heading images (`MARGIN_WIDTH` gutter)" referenced the -4-column outer margin that cat mode and TUI body rows used to share. -That gutter was removed (along with the `MARGIN_WIDTH` constant) now -that TUI is the default mode. The underlying constraint still holds — -label/prefix spans added by any future link picker must not shift the -column where heading images land — but the alignment column is now `0` -(or `30` when ToC is open), not `4` / `34`. diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md deleted file mode 100644 index ea48b4d..0000000 --- a/docs/MARKDOWN_FEATURE_COVERAGE.md +++ /dev/null @@ -1,55 +0,0 @@ -# Markdown Feature Coverage - -Audit of `src/layout.rs` (the Markdown → `RenderedDoc` core) against pulldown-cmark 0.13 and common Markdown extensions. - -## Supported (CommonMark core) - -| Feature | Status | Notes | -|---|---|---| -| Heading H1–H6 | ✓ | H1–H3 rendered as PNG via Kitty graphics; H4–H6 fall back to ANSI bold | -| Paragraph / SoftBreak / HardBreak | ✓ | | -| Bold / Italic | ✓ | | -| Inline code | ✓ | | -| Code block (fenced & indented) | ✓ | No syntax highlighting, no language label | -| Blockquote | ✓ | Nested supported | -| Unordered / Ordered list | ✓ | Nested supported | -| Link | ✓ | URL printed next to the link text | -| Image | ⚠ | Placeholder text `[🖼 alt](url)` only — not rendered via Kitty | -| Horizontal rule | ✓ | | -| HTML blocks | ✓ | Rendered verbatim as a dim preformatted block; HTML comments dropped | -| Inline HTML | ⚠ | Format tags (`b`/`strong`, `i`/`em`, `u`, `s`/`del`/`strike`, `code`/`kbd`) map to ANSI; `
` / `
` handled; comments dropped; unknown tags stripped but their content is preserved. Attributes (e.g. `style="color:red"`, `href`) are not interpreted. | -| YAML / TOML frontmatter | ✓ | Parsed via pulldown-cmark's metadata-block extensions. Rendered as a dim one-line summary (`[metadata · key=value, …]`) in `--cat`; foldable inline box in TUI (toggle with `m`). Heuristic key/value extraction. See `docs/adr/0001-metadata-block-handling.md`. Opt out via `metadata = false` in `~/.config/termdown/config.toml`. | - -## Enabled GFM extensions - -- Strikethrough `~~x~~` -- Tables -- Task lists `[ ]` / `[x]` - -## Not supported - -### Mainstream Markdown gaps - -- **GFM autolinks** (bare URLs) — `ENABLE_GFM` not set -- **GFM alerts / admonitions** (`> [!NOTE]`, `[!WARNING]`, …) — rendered as plain blockquote -- **Footnotes** `[^1]` — `ENABLE_FOOTNOTES` not set - -### Common extensions - -- **Math** `$...$` / `$$...$$` — `ENABLE_MATH` not set -- **Definition list** — not enabled -- **Smart punctuation** — not enabled -- **Wikilinks / Subscript / Superscript** — not enabled - -### Graphical / rich-content extensions (outside pulldown-cmark — need custom handling) - -- **Mermaid diagrams** — intercept ```` ```mermaid ```` fenced blocks, pipe to an external renderer (e.g. `mmdc`), output PNG via Kitty -- **Code block syntax highlighting** — could integrate `syntect` -- **Real image rendering** — local/remote `![](img.png)` could use the existing Kitty pipeline instead of the placeholder -- **PlantUML / Graphviz / other diagrams** — not supported - -## Suggested priorities - -1. **High value (hit in everyday Markdown)**: footnotes, alert/admonition styling, GFM autolinks, at least a graceful fallback for HTML -2. **Differentiating (plays to termdown's Kitty-graphics strength)**: Mermaid rendering, real image rendering, code-block syntax highlighting -3. **Nice to have**: math (KaTeX → image), smart punctuation, definition list diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md new file mode 100644 index 0000000..1f5d064 --- /dev/null +++ b/docs/OVERVIEW.md @@ -0,0 +1,37 @@ +# Project Overview + +termdown renders Markdown in two modes backed by the same parsed document: + +- Interactive TUI for file paths on a terminal, with scrolling, search, a table + of contents, local Markdown links, metadata folding, and live reload. +- Non-interactive output for `--cat`, stdin, pipes, and redirects. + +H1-H3 headings are rasterized as PNG and displayed through the Kitty graphics +protocol. Body text, H4-H6 headings, lists, tables, code, quotes, links, and +frontmatter summaries use terminal text and ANSI styling. Theme, bell, +frontmatter, live reload, and heading fonts are configurable. + +## Code map + +- `src/layout.rs` parses Markdown into the shared `RenderedDoc` model. +- `src/cat.rs` writes that model once; `src/tui/` renders it interactively. +- `src/render.rs` and `src/font.rs` rasterize headings and emit Kitty commands. +- `src/config.rs` and `config.example.toml` define the configuration surface. +- `fixtures/` and `tests/` cover terminal output, CLI behavior, and heading PNGs. + +## Maintenance pitfalls + +- Kitty placements are identified by both image and placement IDs. Reposition + an existing placement without deleting it first, and use targeted deletion; + deleting image data forces retransmission and can make headings disappear. +- iTerm2 may return Kitty acknowledgements even with `q=2`. Cat mode suppresses + TTY echo only on iTerm2; doing this globally triggers Ghostty's secure-input + heuristic. TUI acknowledgement filtering must remain bounded by timeouts so + malformed responses cannot swallow later keyboard input. +- Heading PNG pixels vary by OS and installed fonts. Snapshot tests replace PNG + payloads with `` and separate tests validate decoded image dimensions and + non-empty pixels. +- Linux uses fontconfig through dynamic loading so builds do not require its + development package. Keep the embedded Source Serif fallback usable. +- Build, format, lint, and test commands go through the `Makefile`; `make check` + is the required local gate. diff --git a/docs/TERMINAL_PROTOCOLS.md b/docs/TERMINAL_PROTOCOLS.md deleted file mode 100644 index bf46728..0000000 --- a/docs/TERMINAL_PROTOCOLS.md +++ /dev/null @@ -1,94 +0,0 @@ -# Terminal Protocols: ANSI 与 Kitty Graphics - -termdown 写到 stdout 的字节流里同时包含两层协议——通用的 **ANSI 转义码** 和 Kitty 特有的 **graphics protocol**。理解二者的分层关系有助于阅读测试 fixture、调试渲染问题,以及看懂相关测试代码。 - -## 一、ANSI 转义码(ECMA-48 / ISO 6429) - -通用的终端控制约定。正式名是 **ECMA-48 / ISO 6429**,"ANSI" 是历史叫法(最早是 1976 年的 ANSI X3.64)。它定义了一族以 `ESC`(`\x1b`)开头的转义序列,用来在字符流里夹带控制指令: - -| 序列 | 含义 | -| ----------------- | --------------------- | -| `\x1b[31m` | 前景色设为红色 | -| `\x1b[1m` | 粗体开 | -| `\x1b[0m` | 重置所有属性 | -| `\x1b[2J` | 清屏 | -| `\x1b[H` | 光标移到左上角 | -| `\x1b[38;5;213m` | 256 色调色板前景色 213 | - -几乎所有终端(xterm、Ghostty、Kitty、iTerm、Windows Terminal、tmux 内的伪终端…)都认这些。`fixtures/expected/*.ansi` 文件里大量出现的 `^[[1m...^[[0m` 就是 ANSI 着色码(粗体开/关)。 - -## 二、Kitty Graphics Protocol - -Kitty 终端发明的**图像传输协议**,用来在文本流里夹带 PNG/RGBA 等图像数据,让终端把它当贴图渲染。它**借用了 ANSI/ECMA-48 里一种叫 APC(Application Program Command)的转义信封**: - -``` -\x1b_G ; \x1b\ -``` - -- `\x1b_` 起始、`\x1b\` 结束这两个**信封**是 ECMA-48 标准里为"应用层私有协议"专门留的口子,所有终端都认信封边界 -- 但**信封里的语义**完全是 Kitty 自定义的: - -| 键 | 含义 | -| --------- | ---------------------------------------------------------- | -| `f=100` | payload 是 PNG 数据 | -| `a=T` | transmit + display(传输并立即显示,"display 形") | -| `a=t` | transmit only(仅传输,等后续 `a=p` 放置,"lifecycle 形") | -| `a=p` | place(放置一张已传输过的图像) | -| `a=d` | delete(删除已传输的图像) | -| `i=N` | image ID | -| `m=0`/`1` | 1 = 还有后续 chunk,0 = 这是最后一帧 | - -只有 Kitty、Ghostty、WezTerm、iTerm2(部分)认这些键。其它终端看到 APC 信封会**静默丢弃**——所以非 Kitty 系终端不会显示图像,但也不会把信封内容当文本回显(前提是它正确遵守 ECMA-48;iTerm2 在响应路径上有个 echo 问题,见 `ITERM2_KITTY_RESPONSE_LEAK.md`)。 - -## 三、分层关系一图速查 - -| 层级 | 是什么 | 谁的 | -| --------------------------------------------- | ---------------------------- | --------------- | -| `\x1b[31m` 这类着色 / 光标控制 | ANSI/ECMA-48 标准转义码 | 所有终端 | -| `\x1b_G ... \x1b\` APC 信封 | ECMA-48 标准里的"应用私有"口子 | 所有终端认信封 | -| 信封里 `f=100,a=T;` 的语义 | Kitty graphics protocol | 只有 Kitty 系 | - -## 四、在 termdown 里的体现 - -### Fixture 与 snapshot - -`fixtures/expected/*.ansi` 捕获的是 termdown 写到 stdout 的整段字节流——里面**两层协议都有**: - -- ANSI 着色码(粗体、颜色、高亮等) -- Kitty APC 帧(标题被光栅化成 PNG 后通过 APC 信封传输) - -文件扩展名叫 `.ansi`,是因为 ANSI 着色码是更普遍的内容形态——叫 `.terminal` 或 `.kitty` 都不太贴。 - -### `tests/snapshots.rs::strip_kitty_images` - -字节流里的 PNG payload 是字体/OS 相关的(同一段文字,macOS 和 Linux 渲染出来的像素不一样),跨平台逐字节对比会爆。所以 snapshot 测试在比对前用 `strip_kitty_images` **把每一段连续的 APC 帧替换成一个 `` 占位符**: - -``` -\x1b_Gf=100,a=T,m=1;\x1b\\x1b_G;\x1b\\x1b_G;\x1b\ - ↓ strip_kitty_images - -``` - -这样 snapshot 比的是: -- ANSI 着色码序列完全一致 -- 图片在哪些位置出现完全一致 -- 但不比图片的 PNG 像素内容 - -### `tests/headings.rs::extract_kitty_frames` + `decode_png` - -反方向——专门**只看图片**:扫 stdout 里所有 APC 帧,按 `m=1`/`m=0` 把分片拼回完整 base64,解码出原始 PNG 字节。然后用 `image` crate 解码 PNG,断言宽高、像素非空白、H1>H2>H3 缩放正确。 - -这种"按帧解析 APC + 拼接 chunk + base64 解码"的代码在调试新光栅化逻辑时也很有用——可以脱离终端直接拿到 termdown 想画什么。 - -## 五、调试技巧 - -- **看原始字节**:`cargo run -- --cat | cat -v` 把不可见的 ESC 字符显示成 `^[`,肉眼可读 -- **只看控制流不看图片**:管道接 `sed 's/\x1b_G[^\\]*\x1b\\\\//g'`(粗糙版的 strip_kitty_images) -- **验证终端是否支持 Kitty 图形**:`printf '\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\'`——支持的终端会回 `\x1b_Gi=31;OK\x1b\`,不支持的什么都不回 -- **强制启用 / 禁用 Kitty 输出**:termdown 通过 `TERM_PROGRAM` 环境变量识别终端类型,测试里固定 `TERM_PROGRAM=ghostty` 即可强制走 Kitty 路径 - -## 参考资料 - -- ECMA-48 / ISO 6429: https://ecma-international.org/publications-and-standards/standards/ecma-48/ -- Kitty graphics protocol: https://sw.kovidgoyal.net/kitty/graphics-protocol/ -- 同目录 `ITERM2_KITTY_RESPONSE_LEAK.md`:iTerm2 上 Kitty 响应被 echo 的具体案例 diff --git a/docs/TESTING.md b/docs/TESTING.md deleted file mode 100644 index a25bc07..0000000 --- a/docs/TESTING.md +++ /dev/null @@ -1,75 +0,0 @@ -# Testing - -How termdown's tests are organized and run. Every command goes through the -[`Makefile`](../Makefile) so local runs match CI exactly (see the project's -`CLAUDE.md` for the rule — don't call `cargo test`/`clippy` directly). - -## Running - -| Command | What it does | -|---|---| -| `make test` | Run the whole suite | -| `make check` | `fmt-check` + `lint` + `test` — the CI gate; run before pushing | -| `make coverage` | Local coverage summary via `cargo-llvm-cov` (on-demand, not a CI gate) | - -## Test layout - -- **Unit tests** — inline `#[cfg(test)]` modules in `src/*.rs` (config parsing, ANSI/width helpers, font ranges, frontmatter parsing, layout, …). -- **`tests/cli.rs`** — black-box CLI: `--help`/`--version`, stdin/file input, missing-file errors, the unsupported-terminal warning. -- **`tests/snapshots.rs`** — byte-level snapshot of cat-mode stdout for each fixture, with Kitty image payloads collapsed to `` (PNG bytes are font/OS-dependent, so only the *position* of an image is compared, not its pixels). Background: [`TERMINAL_PROTOCOLS.md`](TERMINAL_PROTOCOLS.md). -- **`tests/headings.rs`** — parses the Kitty APC frames out of stdout, decodes the heading PNGs, and asserts dimensions / non-blank pixels / H1 > H2 > H3 scaling. - -Tests drive the compiled binary through `tests/common/mod.rs::run_termdown`, -which forces a ghostty-like terminal (`TERM_PROGRAM=ghostty`, so Kitty -emission is on), `--theme dark`, and clears `HOME`/`XDG_CONFIG_HOME` so a -developer's own config can't leak in. - -### Fixtures - -- `fixtures/*.md` and `fixtures/specialized/*.md` — rendering inputs. Their snapshot expectations live alongside in `fixtures/expected/**/*.ansi`. -- `fixtures/links/` — a small `.md` link graph for **manual** QA of TUI link-following; `index.md` documents the steps. Not wired into automated tests. - -### Regenerating snapshots - -When a rendering change is intentional, the snapshot test fails and prints two -paths: the expected `.ansi` file and a temp file holding the *actual* output -(`actual written to: …` — the exact temp dir is OS-dependent). Review the diff -intent-first, then accept it by copying the printed temp path over the expected -file: - -```sh -# use the exact path the test printed after "actual written to:" -cp fixtures/expected/supported-syntax.ansi -make test # confirm green -``` - -## Performance / stress testing - -`fixtures/specialized/large.md` is a deterministic stress fixture — 1 H1 + 500 -H2 sections (mixed CN/EN paragraphs, nested lists, 3×3 tables, fenced Rust code) -plus a ~200-row tail table: ~14.7k lines / ~460 KB. It exists only to eyeball -that termdown stays snappy on a large document; it is **not** wired into the -automated suite. - -Because it is generated build output with no automated consumer, it is **not -committed** — it's gitignored and produced on demand. The full workflow is -**generate → test → delete**: - -```sh -# 1. Generate (~1.7s; deterministic, so a clean re-run is byte-identical) -make large-fixture -# or directly: ./scripts/gen-large-fixture.sh > fixtures/specialized/large.md - -# 2. Test against it manually -cargo build --release -time ./target/release/termdown --cat fixtures/specialized/large.md > /dev/null # cat throughput -./target/release/termdown fixtures/specialized/large.md # TUI: scroll / search / heading-jump feel - -# 3. Delete when done (it's large and gitignored anyway) -rm fixtures/specialized/large.md -``` - -Size/shape is tunable via env vars read by the script: `SECTIONS` (default -500), `H3_EVERY` (50), `TAIL_TABLE_ROWS` (200) — e.g. -`SECTIONS=2000 make large-fixture` for an even larger document. After changing -`scripts/gen-large-fixture.sh`, re-run it to regenerate. diff --git a/docs/TUI_MODE_DESIGN.md b/docs/TUI_MODE_DESIGN.md deleted file mode 100644 index 1f4598b..0000000 --- a/docs/TUI_MODE_DESIGN.md +++ /dev/null @@ -1,433 +0,0 @@ -# TUI Mode — Design - -Design for termdown's `--tui` mode — the authoritative "what we're -building" spec. - -## Goals - -- Browse Markdown documents larger than one screen with vim-style - navigation (paging, `gg`/`G`, heading jumps, `/` search, `n`/`N`). -- Preserve termdown's Kitty-graphics heading rendering without the - per-frame re-transmission cost that makes similar tools feel sluggish. -- Share the Markdown → rendered output pipeline between the cat path - (default) and the TUI path; don't fork rendering logic. - -## Non-Goals (v1) - -- Regex search (literal substring only). -- Mouse support (mdfried's mouse-vs-text-selection tradeoff is worse - than no mouse). -- Syntax highlighting inside code blocks. -- `--tui` piped from stdin (TUI needs the terminal for both key input - and document data; requiring a file path avoids the conflict). -- Configurable key bindings. - -## Activation - -- Explicit `--tui` flag required. Default remains cat-style output. -- `termdown --tui FILE.md` — enters TUI on `FILE.md`. -- `termdown --tui` with no file or with `-` → error, exit non-zero. -- Future evolution (documented but not implemented in v1): - - Automatic mode when output is a TTY and the rendered document - exceeds terminal height (git-log-style). - - `[tui]` section in `~/.config/termdown/config.toml` to opt into automatic - mode or override defaults. - -Single binary, no cargo feature flag. TUI code is always compiled in; -strip + LTO keep the binary growth acceptable (~2-3 MB expected). - -## Module Layout - -``` -src/ -├── main.rs CLI dispatch: --tui → tui::run, else → cat::print -├── config.rs (existing) -├── font.rs (existing) -├── theme.rs (existing) -├── style.rs (existing; extend Colors with match-highlight slot) -├── render.rs (existing; add transmit + place + delete_placement) -│ -├── layout.rs [new] pulldown-cmark → RenderedDoc (Vec + -│ HeadingImage[] + HeadingEntry[]). Used by both cat -│ and tui. -├── cat.rs [new] RenderedDoc → stdout (replaces the stdout -│ write logic currently in markdown.rs). -├── markdown.rs (shrinks; event-handling logic migrates into layout.rs) -│ -└── tui/ - ├── mod.rs App struct, terminal setup, main event loop. - ├── viewport.rs Wrap cache, visible-line computation, scroll. - ├── search.rs SearchState, match list, highlight injection. - ├── kitty.rs Image id allocation, placement diff, a=T/a=p/a=d - │ protocol operations, exit cleanup. - └── input.rs Key event → Action mapping. -``` - -New dependencies: `ratatui`, `crossterm`, `tui-textarea`, `regex` (used -for smart-case literal matching via escape; regex search itself is v2). - -## Data Model - -Core types live in `layout.rs` and are consumed by both cat and tui: - -```rust -pub struct RenderedDoc { - pub lines: Vec, - pub headings: Vec, - pub images: Vec, -} - -pub struct Line { - pub spans: Vec, - pub kind: LineKind, -} - -pub enum LineKind { - Body, - Heading { level: u8, id: usize }, - CodeBlock { lang: Option }, - BlockQuote { depth: u8 }, - ListItem { depth: u8 }, - Table, - HorizontalRule, - Blank, -} - -pub enum Span { - Text { content: String, style: Style }, - HeadingImage { id: u32, rows: u16 }, - Link { content: String, url: String, style: Style }, -} - -pub struct Style { - pub fg: Option, - pub bg: Option, - pub bold: bool, - pub italic: bool, - pub underline: bool, -} - -pub struct HeadingEntry { - pub level: u8, - pub text: String, // plain-text form, used by search & ToC - pub line_index: usize, // index into RenderedDoc.lines -} - -pub struct HeadingImage { - pub id: u32, - pub png: Vec, - pub cols: u16, - pub rows: u16, -} -``` - -Key points: - -- **Lines are logical (unwrapped).** One Markdown paragraph = one `Line`. - Wrapping happens in `viewport.rs` against the current terminal width, - cached, and re-run only on resize. -- **Spans carry structured `Style`, not ANSI strings.** cat converts - `Style` to ANSI on output; tui converts to `ratatui::style::Style`. - Search highlight injects a background-color override without parsing - escapes. -- **`HeadingImage` is stored once and referenced by id.** The tui path - transmits each PNG to the terminal once at load time and only emits - placement commands on scroll. -- **`HeadingEntry` is the ToC data source.** No need to rescan `lines` - to build the outline panel. - -### Edge Cases - -- **Code blocks**: one `Line { kind: CodeBlock, … }` per source line. - Search can hit text inside code. -- **Tables**: the existing `markdown.rs` table renderer is lifted into - `layout.rs`. Each rendered table row becomes a `Line { kind: Table }`. - Tables do not wrap; over-wide tables truncate with `…`. -- **Image placeholders (non-heading)**: stay as `[image: alt text]` text - spans — same behavior as cat today. -- **Blank lines**: `LineKind::Blank` so search can skip them. - -## Cat Path Rewrite - -`markdown.rs` today writes to stdout as it walks the pulldown-cmark -event stream. Under the new design: - -1. `layout.rs` owns the event walk and produces a `RenderedDoc`. -2. `cat.rs` turns `RenderedDoc` into ANSI bytes on stdout. -3. The orchestration in `main.rs` stays the same for cat mode - (termios save/restore around the emit). - -This is a real refactor of cat output. Byte-level output may shift -(whitespace, ANSI reset timing). Regression protection: - -- Freeze the current cat output for every file in `fixtures/` into - `fixtures/expected/*.ansi` **before** the refactor lands. -- `make test` runs a snapshot comparison against that frozen baseline. -- Diffs are reviewed intent-first — byte-identical is not required, - but visible behavior must match. - -## Runtime State - -```rust -pub struct App { - docs: Vec, - cursor: usize, // active DocEntry index - history: Vec, // back stack of cursor values - forward: Vec, // forward stack - mode: Mode, - term_size: (u16, u16), - next_image_id: u32, -} - -enum Mode { - Normal, - Search { query: String, direction: Direction }, - Toc, - LinkSelect, // overlay shown after `f` -} - -pub struct DocEntry { - source_path: PathBuf, - doc: RenderedDoc, - viewport: Viewport, - wrap_cache: WrapCache, // keyed by terminal cols - search: Option, - placed_images: HashMap, // id → current (col, row) -} - -pub struct Viewport { - top_visual_line: usize, - height: u16, -} -``` - -**Per-doc state** (`viewport`, `search`, `wrap_cache`, `placed_images`) -is intentional. When the user follows a link from A to B and later -presses `o` (back), A reopens at its previous scroll position with its -search still highlighted, mirroring browser back behavior. Memory cost -is a few tens of KB per doc — negligible. - -## Event Loop - -```text -loop { - event = poll(16ms) | tick; - action = input::map(app.mode, event); - dirty = apply(&mut app, action); - - if dirty { - terminal.draw(|f| render_text(f, &app))?; // ratatui writes text cells - kitty::sync_images(&mut app)?; // we diff + place/delete - writer.flush()?; - } -} -``` - -Event polling with a 16ms budget coalesces bursts of held-key repeats -into a single redraw per frame. - -### Layered Rendering - -ratatui owns the text layer; we own the image layer. Coordination: - -1. In `terminal.draw`, every image row is filled with a custom - "ImageReserve" widget whose `render` is a no-op — ratatui's diff - engine will *not* touch those cells, so images underneath survive. -2. After ratatui flushes, `kitty::sync_images` walks the visible region - once, computes the desired `{id → (col, row)}` map, diffs against - `placed_images`, and emits: - - `a=d, d=i, i=ID` for ids that left the viewport, - - `a=p, i=ID, x, y` for ids that entered, - - `delete + place` for ids whose position changed (Kitty does not - treat repeated `a=p` of the same id as "move" — it stacks). -3. `placed_images` is updated. - -## Key Bindings - -| Key | Mode | Action | -|---|---|---| -| `j` / `↓` | Normal | Down 1 line | -| `k` / `↑` | Normal | Up 1 line | -| `d` / `Ctrl-d` | Normal | Down half screen | -| `u` / `Ctrl-u` | Normal | Up half screen | -| `f` / `Ctrl-f` / `Space` / `PageDown` | Normal | Down full screen | -| `b` / `Ctrl-b` / `PageUp` | Normal | Up full screen | -| `g g` | Normal | Jump to top | -| `G` | Normal | Jump to bottom | -| `]]` | Normal | Next heading | -| `[[` | Normal | Previous heading | -| `t` | Normal | Toggle ToC panel | -| `/` | Normal → SearchForward | Open search prompt | -| `?` | Normal → SearchBackward | Reverse search prompt | -| `Enter` | Search | Commit query, jump to first match | -| `Esc` | Search / Toc / LinkSelect | Back to Normal | -| `n` | Normal | Next match | -| `N` | Normal | Previous match | -| `Enter` | Normal | Open link: 0 links visible → nop; 1 → open; >1 → enter LinkSelect | -| digit | LinkSelect | Open numbered link | -| `o` | Normal | Back (previous doc) | -| `i` | Normal | Forward | -| `q` / `Ctrl-c` | Any | Quit | - -`o`/`i` repurpose vim's `Ctrl-o`/`Ctrl-i` jump semantics as bare keys — -termdown has no insert mode to conflict with. - -### Link Opening - -Links follow "open first if unambiguous, otherwise select": - -- Viewport contains 0 visible links → Enter is a no-op. -- Viewport contains 1 link → Enter opens it via `open`/`xdg-open`. -- Viewport contains multiple links → Enter enters LinkSelect mode. - Each visible link gets a bracketed digit overlay (`[1]foo`, `[2]bar`); - pressing a digit opens that link. Esc exits LinkSelect. - -## Search - -```rust -pub struct SearchState { - query: String, - direction: Direction, - matches: Vec, - current: Option, -} - -pub struct MatchPos { - line_index: usize, - byte_range: Range, -} -``` - -### Matching (v1) - -- Literal substring, smart case (case-insensitive unless the query has - at least one uppercase letter; same rule as vim with `smartcase`). -- Searches `Span::Text.content`, `Span::Link.content`, and - `HeadingEntry.text`. Skips `HeadingImage` (image-rendered heading - text is searchable via the corresponding `HeadingEntry`). -- Full scan once on commit — O(N) over the document. 10k-line docs - complete in single-digit ms. - -### Navigation - -- `n` advances `current`; wrap-around at the end shows - `search hit BOTTOM, continuing at TOP` in the status line. -- `N` reverses. -- Jumping centers the match at ~1/3 from the viewport top (vim default), - not the exact center — reads better. - -### Highlight - -Drawing each visible line, if the line's `line_index` has matches, the -corresponding byte ranges have their `Style.bg` overwritten: - -- Non-current matches: theme-provided "match" background. -- Current match: theme-provided "current match" background (more vivid). - -Colors slot into `style.rs::Colors`, getting auto-resolved for light vs -dark theme like the rest of termdown's palette. - -### Edge Cases - -- Empty query (press `/` then Enter): no-op. -- Zero matches: status line shows `Pattern not found: `, stay - in Normal mode, don't clear prior `SearchState`. -- Re-running `/` replaces the query (no nested search). -- If the user `n`s to a match and then scrolls away with `j`, - `current` is preserved — subsequent `n` continues from `current`, - not from the current viewport position (vim behavior). - -## Kitty Image Lifecycle - -Three primitive operations added to `render.rs`: - -```rust -fn transmit(id: u32, png: &[u8]); // a=T, i=ID, f=100, q=2, chunked -fn place(id: u32, col: u16, row: u16); // a=p, i=ID, x=COL, y=ROW, q=2 -fn delete_placement(id: u32); // a=d, d=i, i=ID, q=2 -``` - -### Timeline - -1. **Load doc.** `layout.rs` produces `RenderedDoc` with PNGs in memory. -2. **Register images.** For each `HeadingImage` in the new `DocEntry`, - call `transmit(id, png)` once. The terminal caches the data. -3. **Event loop.** On each dirty frame, `kitty::sync_images` diffs the - desired placement set against `placed_images` and emits - delete/place commands (no PNG data). -4. **Resize.** `sync_images` runs with new cell coordinates — terminal - scales the image to the new cell size; no re-transmission needed. -5. **Exit.** Send `a=d, d=A` to delete all placements *and free the - stored image data* this process created, then restore termios. - (`d=a` would delete placements but leave data cached in the - terminal — wasteful across repeated opens.) - -### Why delete + place instead of re-place - -Kitty does not treat a second `a=p` of the same id as "move" — it -stacks a second placement. To move an image, the old placement must be -deleted first. Cost per frame: `O(visible_images)` delete + place -command pairs, each a few dozen bytes. Negligible compared to PNG -re-transmission. - -### Exit Cleanup - -`a=d, d=A` clears all placements and frees image data this client has -made. Trade-off: if the user has two `termdown --tui` processes sharing -one terminal (e.g. tmux panes), exiting one wipes the other's images. -Not worth the id-range partitioning in v1; if it becomes a real -complaint, switch to id-scoped deletion. - -## Testing Strategy - -| Layer | Test kind | Coverage | -|---|---|---| -| `layout.rs` | Unit, text snapshot | pulldown-cmark event → `Vec` correctness per Markdown element | -| `cat.rs` | Snapshot | `RenderedDoc` → ANSI bytes. Frozen `fixtures/expected/*.ansi` baseline before refactor | -| `viewport.rs` | Unit | Wrap on CJK, long URLs (no break), scroll bounds, height changes | -| `tui/search.rs` | Unit | Substring, smart case, n/N wrap, byte-range correctness | -| `tui/kitty.rs` | Unit (mock writer) | Diff algorithm + protocol byte format (`\x1b_G...\x1b\\`) | -| `tui/mod.rs` event loop | Manual | Ghostty, iTerm2 real terminals | - -`make check` additions: - -- `cargo test` picks up the snapshot tests automatically. -- A new `fixtures/expected/` directory under version control. - -### Manual Pre-merge Checklist - -Run against both Ghostty and iTerm2: - -- Short (< 1 screen), mid (README-size), long (20+ screen) docs. -- Heading-dense docs. -- Mixed-script text, emoji, wide tables, long code blocks. -- Held-`j` for 10 s: no flicker, no lag, no image residue. -- Search hit / miss / wrap / re-center at 1/3. -- Multi-file back/forward, per-doc state preserved. -- Resize mid-session. -- Link open: 0/1/>1 visible cases. -- `q` exit: no image residue on terminal. - -## Open Questions (Deferred) - -1. Regex search (v2). -2. Code-block syntax highlighting — would add `syntect`; out of scope. -3. Mouse support — deferred to avoid mdfried's selection/scroll trade. -4. TUI reading from stdin — requires pty multiplexing; rejected for v1. -5. Configurable key bindings — hardcoded for v1. -6. Performance SLO: held-`j` on a 100-screen doc should not stutter. - Instrument only if we miss the target. -7. Font-size changes mid-session (not the same event as resize) — - v1 requires a reopen. - -## Migration Plan - -1. Freeze cat output snapshots from current `master`. -2. Introduce `layout.rs` + `cat.rs`; wire `main.rs` to use them for the - default path. Run snapshot diffs; resolve intent differences. -3. Add `tui/` modules behind the `--tui` flag. Core event loop, - viewport, input, kitty image sync. -4. Search (v1). -5. Heading nav, ToC panel, status line. -6. Back/forward across multiple docs, link following. -7. Manual pre-merge checklist on both Ghostty and iTerm2. diff --git a/docs/adr/0001-metadata-block-handling.md b/docs/adr/0001-metadata-block-handling.md deleted file mode 100644 index a901d69..0000000 --- a/docs/adr/0001-metadata-block-handling.md +++ /dev/null @@ -1,148 +0,0 @@ -# ADR 0001 — Metadata block (frontmatter) handling - -- **Date**: 2026-05-28 -- **Status**: Accepted -- **Owners**: shawn - -## Context - -Markdown files in the wild — agent skill files (Anthropic/Cursor), static-site -posts (Jekyll/Hugo/Zola), and notes (Obsidian/Logseq) — commonly start with a -**frontmatter** block fenced by `---` (YAML) or `+++` (TOML). Termdown today -does not enable pulldown-cmark's metadata-block extensions, so the opening -fence is parsed as a horizontal rule and the YAML body collapses into a setext -H2 heading — which then gets rasterized into a large PNG via the Kitty -graphics pipeline. The result is loud, ugly, and useless. - -Documented prior state: `docs/MARKDOWN_FEATURE_COVERAGE.md:40` notes -"YAML / TOML frontmatter — not enabled; currently leaks into body"; -`fixtures/supported-syntax.md:13-15` calls it out as a known roadmap item. - -This ADR records how termdown will handle metadata blocks going forward. - -## Decision - -Termdown will treat frontmatter as a **first-class lightweight element**, not -as body content and not as completely invisible noise: - -1. **Parsing**: Enable `ENABLE_YAML_STYLE_METADATA_BLOCKS` **and** - `ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS` on the pulldown-cmark parser. - Capture the raw block text via the `MetadataBlock` event pair. -2. **One-line summary**: A heuristic parser splits the raw block into - `key=value, …` pairs, joined into a single dim line wrapped as - `[metadata · key=value, …]`, truncated to terminal width — the closing - `]` is preserved after the truncation ellipsis. No real YAML/TOML parser - is introduced. -3. **Fallback**: If the heuristic extracts zero valid key/value pairs, fall - back to a raw single-line join of the block content (so something useful is - still shown for malformed or exotic input). -4. **Cat mode**: Render exactly the one-line summary at the document's top. -5. **TUI mode**: Render the same one-line summary by default (**folded**). The - `m` key toggles to an **expanded** inline box listing each key/value on its - own row. The box is part of the scrolling content (not pinned), not part - of search, and not part of the Table of Contents. Default state is folded. -6. **Config gate**: A single boolean knob `[metadata] show` (default `true`) - in `~/.config/termdown/config.toml`. When `false`, frontmatter is **completely - hidden** in both cat and TUI; `m` becomes a no-op. The pulldown extensions - remain enabled — `show` gates rendering only, never parsing, so frontmatter - never leaks back into body regardless of config. - -## Alternatives considered - -### Visibility (what to show) - -- **Hide completely, glow-style**. glow (the most-used CLI Markdown renderer) - silently strips frontmatter — no chip, no line, gone. Simplest possible - implementation. *Rejected*: termdown is positioned as a richer - terminal-reader experience; silently dropping authored metadata sacrifices - the agent-skill / Hugo-blog reading use case where the title/author/date - is genuinely informative. -- **Render as a `yaml` code block**. Keeps the original text fully visible. - *Rejected*: defeats the goal — the whole reason this is annoying today is - that the raw text dominates the top of the document. -- **One-line summary only, no expanded state**. Always-folded, no `m` key. - *Rejected*: users opening agent-skill files often want to see all fields at - once; the expanded state addresses that without burning screen real estate - by default. - -### Parsing strategy - -- **Full YAML parser** (e.g. `yaml-rust2`, `saphyr` — `serde_yaml` is - unmaintained). *Rejected*: adds a non-trivial dependency (~100–200 KB to - the binary) for a feature whose output is a 1-line summary. Termdown is a - terminal reader, not a YAML validator. The 5% of real-world files with - multi-line strings or deep nesting are not the target audience. -- **Treat each block as opaque text, no parsing**. *Rejected*: rejected as a - primary mode (loses the `key=value` structure that makes the summary - readable) but **accepted as the fallback** when the heuristic fails. - -### Config surface - -- **No config knob**. *Rejected*: at least one knob is justified for users who - consistently don't want any frontmatter UI noise (the glow-style preference). -- **Multiple knobs** (`default_state`, `cat_format`, …). *Rejected*: YAGNI. - The `m` key already handles per-document fold/expand preference; a single - master switch covers the only opt-out we can name a real user for. - -### Key binding - -- `m` is unused in the current TUI map (`src/tui/input.rs`), short, and a - natural mnemonic. Alternatives considered: `z`-style vim folds (multi-key - chord, overkill), `F` for fold (less obvious mnemonic). - -## Consequences - -- **New behavior visible in every fixture that has frontmatter.** - `fixtures/supported-syntax.md` already carries one; its golden snapshot - `fixtures/expected/supported-syntax.ansi` will change in the implementation - PR — reviewers should expect a large diff there and verify the new top - line reads `· metadata · …`. -- **New keybinding** in TUI: `m`. Must be added to the `?` help screen. -- **New config field** in `config.toml`. Backward compatible — missing field - defaults to `true`. Schema change in `src/config.rs`. -- **No new dependencies**. Both pulldown-cmark options already ship in the - pinned 0.13 release. -- **Edge cases handled implicitly by pulldown-cmark**: stray `---` mid-doc - is still a horizontal rule; frontmatter requires column 1, line 1; missing - closing fence consumes to EOF (rare; documented behavior, not - specially handled). -- **What's explicitly out of scope**: parsing frontmatter fields for - *functional* use (e.g. piping `title` to the TUI title bar, using `tags` - for filtering). Tracked separately if and when demand emerges. - -## Test plan - -- Snapshot diff on existing `fixtures/supported-syntax.md` (mutates golden). -- New focused fixtures under `fixtures/specialized/`: - - `metadata-yaml.md` — standard YAML block, exercises heuristic happy path. - - `metadata-toml.md` — standard TOML block, proves both syntaxes work. - - `metadata-malformed.md` — multi-line / nested values that trip the - heuristic, exercises fallback. - - `metadata-none.md` — body containing mid-document `---` thematic - breaks but no frontmatter; regression guard against false positives. -- Unit tests for the heuristic in `src/frontmatter.rs` (or wherever it lands): - empty block, single field, multiple fields, value containing `:` / `=`, - zero-valid-pairs → fallback. -- TUI fold/expand toggle: manual verification, not automated. Pure render - state, no branching logic worth automating. - -## Open follow-ups - -- Migration of the config dir from `~/.termdown/` to XDG `~/.config/termdown/` - is handled alongside this change (same branch); see `config.example.toml` - and the `## Configuration` section of the README. -- "Use `title` field in TUI title bar" — sketched as a future enhancement, - not committed. - -## Amendment — 2026-05-29 - -The config gate (decision #6) was originally implemented as a nested table -`[metadata] show = true`. Before the feature shipped in a release, it was -flattened to a top-level `metadata = true` boolean. Rationale: a single on/off -toggle does not earn its own `[section]`, and the nested form was inconsistent -with the sibling top-level switches (`theme`, `bell`) — the ADR itself rejected -extra metadata knobs as YAGNI, which undercut the only justification for the -table. Implemented as `Option` exactly like `bell`: `None` (key absent) -and `Some(true)` render, `Some(false)` hides. If functional frontmatter -features (e.g. piping `title` to the title bar) ever land, the knob can be -re-promoted to a `[metadata]` table at that point. diff --git a/scripts/gen-large-fixture.sh b/scripts/gen-large-fixture.sh index 90a189d..ab231f6 100755 --- a/scripts/gen-large-fixture.sh +++ b/scripts/gen-large-fixture.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # gen-large-fixture.sh — produce fixtures/specialized/large.md, a stress/perf # fixture for termdown. The output is gitignored and never committed: generate -# it on demand, perf-test, then delete it (see docs/TESTING.md). Output is +# it on demand, perf-test, then delete it. Output is # deterministic (no $RANDOM, no timestamps), so a clean re-run is byte-identical. # # Usage: diff --git a/src/config.rs b/src/config.rs index 95b2dd6..34e4839 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,7 +24,6 @@ pub struct Config { /// one-line summary in cat / TUI-folded, with the TUI `m` key to expand. /// `Some(false)` hides metadata entirely; parsing still runs so the block /// never leaks into body content. - /// See `docs/adr/0001-metadata-block-handling.md`. pub metadata: Option, /// Watch the file and live-reload the TUI preview when it changes on disk. diff --git a/src/frontmatter.rs b/src/frontmatter.rs index 713ed4a..2a736bf 100644 --- a/src/frontmatter.rs +++ b/src/frontmatter.rs @@ -2,8 +2,7 @@ //! //! We never feed the block to a real YAML/TOML parser. The block's destination //! is a single dim summary line (cat / TUI folded) or an inline expanded box -//! (TUI), so fidelity beyond "key = value" doesn't matter. See -//! `docs/adr/0001-metadata-block-handling.md`. +//! (TUI), so fidelity beyond "key = value" doesn't matter. use pulldown_cmark::MetadataBlockKind; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; diff --git a/src/layout.rs b/src/layout.rs index 6f75710..2d89be4 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -618,7 +618,7 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc { // Walk in document order so image IDs are deterministic across runs. let mut next_image_id: u32 = 1; for (p, result) in pending_headings.into_iter().zip(results) { - if let Some((png, px_width, px_height)) = result { + if let Some((png, _, px_height)) = result { let id = next_image_id; next_image_id += 1; // Conservative upper bound; TUI refines once it knows cell pixel height. @@ -630,9 +630,7 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc { images.push(HeadingImage { id, png, - cols: 0, rows, - px_width, px_height, }); let line = &mut lines[p.line_index]; diff --git a/src/render.rs b/src/render.rs index e558bcb..2cf3dee 100644 --- a/src/render.rs +++ b/src/render.rs @@ -497,7 +497,7 @@ pub fn delete_all_for_client(w: &mut W) -> std::io::Result<()> { // ─── Shared Image Record ──────────────────────────────────────────────────── -/// PNG data + cell dimensions for a rendered heading image. +/// PNG data + vertical dimensions for a rendered heading image. /// Stored by id in `RenderedDoc` and transmitted to the terminal /// once per TUI session (or emitted directly in cat mode). /// @@ -506,15 +506,11 @@ pub fn delete_all_for_client(w: &mut W) -> std::io::Result<()> { /// refined by `tui::mod::refine_image_rows` once the real terminal cell /// pixel height is known. `px_height` preserves the exact PNG height so /// the refinement step can compute `ceil(px_height / cell_pixel_height)`. -// TODO: remove #[allow(dead_code)] once Task 1.5 wires up image production -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct HeadingImage { pub id: u32, pub png: Vec, - pub cols: u16, pub rows: u16, - pub px_width: u32, pub px_height: u32, } diff --git a/src/tui/input.rs b/src/tui/input.rs index f7aa6f7..2868a82 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -41,8 +41,7 @@ pub fn map_normal(key: KeyEvent) -> Action { // Note: the bare-char arms below intentionally match regardless of // modifiers, so `Ctrl-d/u/f/b` hit the same actions as `d/u/f/b`. - // This matches the design-doc key table and is exercised by - // `ctrl_modifier_variants_match_bare_letter`. + // This is exercised by `ctrl_modifier_variants_match_bare_letter`. KeyCode::Char('d') => Action::ScrollHalfPage(1), KeyCode::Char('u') => Action::ScrollHalfPage(-1), @@ -50,7 +49,7 @@ pub fn map_normal(key: KeyEvent) -> Action { KeyCode::Char('b') | KeyCode::PageUp => Action::ScrollPage(-1), KeyCode::Char('G') => Action::JumpEnd, - // Note: `g` alone is not a JumpStart — Task 4.1 adds the `gg` two-key sequence. + // `g` alone is handled by the event loop as the first key of `gg`. KeyCode::Char(']') => Action::NextHeading, KeyCode::Char('[') => Action::PrevHeading, @@ -198,8 +197,8 @@ mod tests { #[test] fn ctrl_modifier_variants_match_bare_letter() { - // Design doc promises Ctrl-d/u/f/b as aliases. The match expression - // on key.code (without modifier guards) gives us this for free, but + // The match expression on key.code (without modifier guards) makes + // Ctrl-d/u/f/b aliases of their bare letters, but // a regression test makes the contract explicit so a future refactor // that adds `if !ctrl` guards won't silently break vim muscle memory. assert!(matches!( diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f1b8d77..11850b6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1183,7 +1183,7 @@ fn clipped_spans( let mut out: Vec> = Vec::new(); let mut cursor = 0usize; - // Highlight styles. Task 8/theme follow-up: pull from style::Colors instead. + // Search highlight styles are intentionally distinct from document colors. let current_style = RStyle::default().bg(RColor::Yellow).fg(RColor::Black); let other_style = RStyle::default() .bg(RColor::Rgb(80, 80, 0)) @@ -1871,17 +1871,13 @@ mod renumber_tests { crate::render::HeadingImage { id: 1, png: vec![], - cols: 0, rows: 3, - px_width: 1, px_height: 1, }, crate::render::HeadingImage { id: 2, png: vec![], - cols: 0, rows: 3, - px_width: 1, px_height: 1, }, ], diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs index b5feb28..5dabd49 100644 --- a/src/tui/viewport.rs +++ b/src/tui/viewport.rs @@ -1,7 +1,6 @@ //! Scroll state + wrap cache for the TUI body. //! -//! v1 wrap is a no-op (one visual line per logical line). Task 4.4 replaces -//! `wrap_all` with a width-aware breaker. +//! Logical lines are split into width-aware visual lines for display. use crate::layout::{Line, RenderedDoc, Span}; @@ -24,7 +23,7 @@ pub struct VisualLine { pub is_spacer: bool, /// Set on rows that visualize the document's frontmatter metadata block. /// `logical_index` is [`NO_LOGICAL`] for these rows — `draw()` consults - /// `doc.metadata` instead. See `docs/adr/0001-metadata-block-handling.md`. + /// `doc.metadata` instead. pub metadata_row: Option, }