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 阅读器的阅读体验。
-## 为什么做这个
-
-本项目受 [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 `` 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,
}