diff --git a/Cargo.toml b/Cargo.toml
index 9e9380d..92769ce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,6 @@ rust-version = "1.95"
exclude = [
"CLAUDE.md",
"TODO.md",
- "TEST_PLAN.md",
"docs/",
".github/",
"install.sh",
diff --git a/README.md b/README.md
index 73b6d49..bb61a10 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@ Render Markdown with large-font headings in the terminal using the Kitty graphic
-
-
+
+
@@ -87,111 +87,16 @@ rm -rf ~/.config/termdown
# Open a file in the interactive TUI (default)
termdown README.md
-# Force plain cat-style output (non-interactive, pipe-friendly)
+# Plain cat-style output (non-interactive, pipe-friendly)
termdown --cat README.md
-
-# Pipe from stdin (always cat-style — TUI needs a real file)
cat notes.md | termdown
-# Piped or redirected stdout also falls back to cat
-termdown README.md | less
-
-# Use a specific theme instead of auto-detect
+# Pick a theme; show help
termdown --theme light README.md
-
-# Disable the edge-scroll bell (also configurable via `bell = false`)
-termdown --no-bell README.md
-
-# View help
termdown --help
-termdown --version
```
-### TUI mode
-
-The TUI launches automatically whenever you pass a file and stdout is a real
-terminal:
-
-```sh
-termdown README.md
-```
-
-Key bindings:
-
-| Key | Action |
-|---|---|
-| `j` / `↓` | Scroll down one line |
-| `k` / `↑` | Scroll up one line |
-| `d` / `u` | Half page down / up |
-| `f` / `Space` / `PgDn` | Full page down |
-| `b` / `PgUp` | Full page up |
-| `gg` / `G` | Jump to start / end |
-| `]` / `[` | Next / previous heading |
-| `t` | Toggle Table of Contents panel |
-| `/` | Search forward |
-| `n` / `N` | Next / previous match |
-| `?` | Toggle keyboard-shortcut help overlay |
-| `Enter` | Follow link (overlay picker if multiple visible) |
-| `o` / `i` | Back / forward across followed `.md` links |
-| `q` / `Ctrl-C` | Quit |
-
-TUI mode requires a file path; stdin input is not supported.
-
-## Configuration
-
-termdown reads configuration from `~/.config/termdown/config.toml` (or
-`$XDG_CONFIG_HOME/termdown/config.toml` if `XDG_CONFIG_HOME` is set). All
-settings are optional; see [`config.example.toml`](config.example.toml) for a
-copy-pasteable file with every default.
-
-```toml
-# Theme: "auto" (default), "dark", or "light"
-# Auto-detection queries the terminal background color via OSC 11.
-theme = "auto"
-
-# Vim-style edge bell: emit a terminal BEL when you scroll past the
-# top/bottom of the document. The terminal emulator decides the visible
-# effect (audible beep, title-bar 🔔, dock bounce, …). Default true.
-# CLI: `--no-bell`.
-bell = true
-
-[font.heading]
-# English heading font (sans-serif recommended)
-latin = "Inter"
-
-# CJK heading font
-cjk = "LXGW WenKai"
-
-# Emoji / symbol fallback font for image-rendered headings (optional)
-emoji = "Apple Color Emoji"
-```
-
-Headings with mixed scripts (e.g. "Hello 世界") will render each character with the appropriate font automatically.
-Standalone emoji in H1-H3 headings are also rendered via font fallback where possible.
-
-> **Note:** Body text is rendered as plain ANSI text -- its font is determined by your terminal emulator settings, not by termdown. To change the body font, configure your terminal directly.
-
-If no config file exists, termdown uses platform-specific defaults and falls back to an embedded SourceSerif4 font.
-
-### Platform default heading fonts
-
-**Latin** (sans-serif):
-
-| macOS | Linux | Windows |
-|-------|-------|---------|
-| Avenir | Inter | Segoe UI |
-| Avenir Next | Noto Sans | Arial |
-| Futura | DejaVu Sans | Verdana |
-| Helvetica Neue | Liberation Sans | |
-
-**CJK**:
-
-| macOS | Linux | Windows |
-|-------|-------|---------|
-| Noto Serif CJK SC | Noto Serif CJK SC | SimSun |
-| Source Han Serif SC | Source Han Serif SC | KaiTi |
-| Songti SC | Noto Serif | Microsoft YaHei |
-| STSong | DejaVu Serif | |
+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.
## Terminal Support
@@ -202,16 +107,7 @@ Requires a terminal with **Kitty graphics protocol** support:
- [WezTerm](https://wezfurlong.org/wezterm/)
- [iTerm2](https://iterm2.com)
-On unsupported terminals, termdown will print a warning and heading images may not display correctly. H4-H6 headings always render as plain ANSI bold text.
-
-## Known Issues
-
-- **Line wrapping** -- long lines may not wrap correctly when mixed with ANSI escape sequences
-- **Terminal compatibility** -- only tested on Ghostty and iTerm2; other Kitty-protocol terminals may behave differently
-- **Font selection & fallback** -- weight matching relies on platform font APIs (Core Text / fontconfig) which may not always resolve to the expected variant
-- **Theme detection** -- auto-detection relies on OSC 11 terminal responses; if your terminal does not support this, use `--theme` or the config file to set the theme manually
-- **Complex emoji sequences** -- ZWJ-heavy emoji sequences (family/grouping variants, some skin-tone combinations) may still render as separate glyphs because heading layout does not perform full text shaping
-- **TUI help popup vs heading images** -- in TUI mode, the `?` help overlay is drawn on the text layer, while heading images live on Kitty's graphics layer (always on top of text). Any heading image overlapping the popup area is temporarily removed while the popup is open and restored when it closes -- this is a Kitty graphics protocol limitation, not a bug
+On unsupported terminals, termdown prints a warning and heading images may not display correctly. H4-H6 headings always render as plain ANSI bold text.
## License
diff --git a/README_CN.md b/README_CN.md
index 6eb9ee3..42fb306 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -1,11 +1,13 @@
# Termdown
+[English](README.md)
+
在终端中以大字体标题渲染 Markdown,让观感更接近 GUI Markdown 阅读器的体验,基于 Kitty 图形协议。
-
-
+
+
@@ -25,7 +27,15 @@ H4-H6 标题始终以 ANSI 粗体文本渲染。不想让文档加入那么多
## 安装
-### 安装脚本
+### 从 crates.io(推荐,需要 Rust)
+
+```sh
+cargo install termdown
+```
+
+安装到 `~/.cargo/bin/`。需要 Rust 1.95+。
+
+### 安装脚本(无需 Rust 工具链)
```sh
curl -fsSL https://raw.githubusercontent.com/rrbe/termdown/master/install.sh | bash
@@ -50,14 +60,12 @@ sudo mv termdown /usr/local/bin/
-### 源码安装
+### 从源码
```sh
cargo install --git https://github.com/rrbe/termdown
```
-安装到 `~/.cargo/bin/`。
-
## 卸载
```sh
@@ -80,107 +88,16 @@ rm -rf ~/.config/termdown
# 默认进入交互式 TUI
termdown README.md
-# 强制使用 cat 风格的纯输出(非交互、管道友好)
+# cat 风格纯输出(非交互、管道友好)
termdown --cat README.md
-
-# 从 stdin 管道输入(始终是 cat 模式 —— TUI 需要真实文件)
cat notes.md | termdown
-# stdout 被管道/重定向时也会自动回退到 cat
-termdown README.md | less
-
-# 指定主题(不使用终端亮色、暗色主题自动检测)
+# 指定主题;查看帮助
termdown --theme light README.md
-
-# 关闭文档到顶/到底时的提示铃声(也可在配置中设 `bell = false`)
-termdown --no-bell README.md
-
-# 查看帮助
termdown --help
-termdown --version
-```
-
-### TUI 模式
-
-当传入文件且 stdout 为真实终端时,自动进入 TUI:
-
-```sh
-termdown README.md
```
-按键绑定:
-
-| 按键 | 动作 |
-| ---------------------- | -------------------------------------------- |
-| `j` / `↓` | 向下滚动一行 |
-| `k` / `↑` | 向上滚动一行 |
-| `d` / `u` | 半屏向下 / 向上 |
-| `f` / `Space` / `PgDn` | 整屏向下 |
-| `b` / `PgUp` | 整屏向上 |
-| `gg` / `G` | 跳到文档开头 / 末尾 |
-| `]` / `[` | 下一个 / 上一个标题 |
-| `t` | 切换目录面板 |
-| `/` | 正向搜索 |
-| `n` / `N` | 下一个 / 上一个匹配 |
-| `?` | 切换快捷键帮助弹窗 |
-| `Enter` | 打开链接(屏幕中有多个链接时显示序号选择器) |
-| `o` / `i` | 在已跳转的 `.md` 文档之间前进 / 后退 |
-| `q` / `Ctrl-C` | 退出 |
-
-TUI 模式需要指定文件路径,不支持从 stdin 读取。
-
-## 配置
-
-配置文件位于 `~/.config/termdown/config.toml`(若设置了 `XDG_CONFIG_HOME`,则为
-`$XDG_CONFIG_HOME/termdown/config.toml`)。所有配置项均为可选;仓库根目录的
-[`config.example.toml`](config.example.toml) 提供了一份包含全部默认值、可直接复制的示例。
-
-```toml
-# 主题:"auto"(默认)、"dark" 或 "light"
-# 自动检测通过 OSC 11 查询终端背景色。
-theme = "auto"
-
-# 文档到顶/到底时向终端发一次 BEL。具体表现(响铃、标题栏 🔔、
-# dock 弹跳等)由终端模拟器决定。默认 true,命令行可用 `--no-bell` 关闭。
-bell = true
-
-[font.heading]
-# 英文标题字体(推荐无衬线字体)
-latin = "Inter"
-
-# 中文标题字体
-cjk = "LXGW WenKai"
-
-# 图片标题里的 emoji / 符号 fallback 字体(可选)
-emoji = "Apple Color Emoji"
-```
-
-混合语言标题(如 "Hello 世界")会自动按字符选择对应字体渲染。
-H1-H3 标题中的单个 emoji 也会通过 fallback 字体尽量渲染出来。
-
-> **注意:** 正文以 ANSI 纯文本输出,字体由终端模拟器决定,不受 termdown 控制。
-
-未配置时使用平台默认字体,最终回退到内嵌的 SourceSerif4 字体。
-
-### 平台默认标题字体
-
-**Latin**(无衬线):
-
-| macOS | Linux | Windows |
-| -------------- | --------------- | -------- |
-| Avenir | Inter | Segoe UI |
-| Avenir Next | Noto Sans | Arial |
-| Futura | DejaVu Sans | Verdana |
-| Helvetica Neue | Liberation Sans | |
-
-**CJK**:
-
-| macOS | Linux | Windows |
-| ------------------- | ------------------- | --------------- |
-| Noto Serif CJK SC | Noto Serif CJK SC | SimSun |
-| Source Han Serif SC | Source Han Serif SC | KaiTi |
-| Songti SC | Noto Serif | Microsoft YaHei |
-| STSong | DejaVu Serif | |
+完整的命令行参数、TUI 快捷键、配置项和已知问题都在 **[使用指南](docs/USAGE_CN.md)**。配置是可选的,位于 `~/.config/termdown/config.toml` —— 全部默认值见 [`config.example.toml`](config.example.toml)。
## 终端支持
@@ -193,14 +110,6 @@ H1-H3 标题中的单个 emoji 也会通过 fallback 字体尽量渲染出来。
不支持的终端会打印警告。H4-H6 标题始终以 ANSI 粗体文本渲染。
-## 已知问题
-
-- **换行显示** -- 含 ANSI 转义序列的长行可能无法正确换行
-- **字体选择与降级** -- 字体粗细匹配依赖平台 API(Core Text / fontconfig),不一定能解析到预期的字重变体
-- **主题检测** -- 自动检测依赖终端对 OSC 11 的响应;如终端不支持,请通过 `--theme` 或配置文件手动指定主题
-- **复杂 emoji 序列** -- 依赖 ZWJ 的复杂 emoji(例如家庭/群组类组合、部分肤色组合)目前仍可能拆成多个字形,因为标题渲染还没有完整文本 shaping
-- **TUI 帮助弹窗与标题图片** -- TUI 模式下 `?` 帮助弹窗绘制在文字层,而标题图片位于 Kitty graphics 层(始终覆盖在文字之上)。与弹窗区域重叠的标题图片会在弹窗打开时被临时移除,关闭后自动恢复 —— 这是 Kitty graphics 协议的限制
-
## 许可证
[Apache-2.0](LICENSE)
diff --git a/TEST_PLAN.md b/TEST_PLAN.md
deleted file mode 100644
index 051a347..0000000
--- a/TEST_PLAN.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Test Plan
-
-## Current Status
-
-The repository currently has two kinds of automated tests:
-
-- Unit tests in source files such as `src/style.rs`, `src/markdown.rs`, and `src/font.rs`
-- Black-box CLI tests in `tests/cli.rs`
-
-These existing tests cover:
-
-- ANSI stripping and display-width handling
-- Markdown text wrapping and table alignment
-- Emoji font selection
-- CLI help/version output
-- Basic stdin and file input paths
-- Missing-file error handling
-- Warning output on unsupported terminals
-
-## Important Gap
-
-The project's signature feature is large image-rendered H1-H3 headings via the Kitty graphics protocol.
-
-That feature is **not yet covered by a true integration test**.
-
-The current CLI tests do not yet:
-
-- read a real Markdown fixture and compare the full rendered output against an expected fixture
-- verify that H1-H3 actually produce Kitty graphics protocol output
-- extract the embedded PNG payload from terminal output
-- decode the PNG and assert image properties
-- compare heading output against stable golden fixtures
-
-So while the current suite does exercise the CLI, it does **not** yet prove that the main heading-rendering pipeline works end to end.
-
-## What "Real" Integration Testing Should Mean Here
-
-A real integration test for this project should validate the full path:
-
-`Markdown heading -> heading rasterization -> PNG generation -> Kitty protocol output`
-
-At minimum, that means testing that:
-
-1. A Markdown fixture containing H1-H3 headings is rendered by the compiled binary.
-2. The output contains Kitty graphics protocol payloads instead of only plain ANSI text.
-3. The PNG payload can be extracted and decoded successfully.
-4. The decoded image is non-empty and matches expected characteristics.
-
-## Proposed Phases
-
-### Phase 1: Fixture-Based Text Integration Tests
-
-Add fixture-based CLI tests for stable text output only.
-
-Scope:
-
-- Read Markdown from files under `fixtures/`
-- Run the binary in a controlled environment
-- Strip ANSI when appropriate
-- Compare output against checked-in expected text fixtures
-
-Recommended targets:
-
-- paragraphs
-- lists
-- blockquotes
-- tables
-- horizontal rules
-- H4-H6 headings
-
-This will give true fixture-based regression coverage for the text-rendering parts of the program.
-
-### Phase 2: Heading Rendering Integration Tests
-
-Add integration tests specifically for H1-H3 image-rendered headings.
-
-Scope:
-
-- Use Markdown fixtures containing representative headings
-- Capture stdout from the compiled binary
-- Detect Kitty graphics protocol escape sequences
-- Extract the embedded base64 PNG payload
-- Decode the PNG
-- Assert basic image properties:
- - width and height are greater than zero
- - the image is not fully transparent
- - different heading levels produce different image sizes
-
-Recommended heading cases:
-
-- ASCII heading
-- CJK heading
-- mixed Latin/CJK heading
-- emoji-containing heading
-- long heading
-
-### Phase 3: Stable Golden Validation for Headings
-
-Once heading rendering is made deterministic enough, add stronger regression checks.
-
-Options:
-
-- compare decoded PNG hashes
-- compare image dimensions plus sampled pixel statistics
-- compare against checked-in golden PNG fixtures
-
-This phase should only be enabled once font selection and rendering are stable across environments.
-
-## Main Technical Risk
-
-Heading rendering currently depends on system font resolution.
-
-That makes full image snapshots potentially unstable across:
-
-- operating systems
-- installed fonts
-- font versions
-- fontconfig/Core Text differences
-
-Because of that, heading snapshot tests may be flaky unless the test environment is made deterministic.
-
-## Recommended Prerequisite for Reliable Heading Tests
-
-Before adding strict heading snapshots, make the heading test environment font-stable.
-
-Possible approaches:
-
-- allow tests to point configuration at repo-owned font files
-- add a test-only font loading path that prefers bundled fonts
-- introduce a deterministic fixture config used only in tests
-
-Without this, true heading integration tests can still check for protocol and decodable PNG output, but not reliable pixel-perfect snapshots.
-
-## Short-Term Next Step
-
-The next useful step is:
-
-1. convert part of the current CLI coverage to fixture-based expected-output tests for stable text rendering
-2. add a first headings integration test that extracts and decodes the Kitty PNG payload, without yet requiring exact image snapshots
-
-That would be the first version of a real end-to-end test for the project's main feature.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 633e31d..d71dfad 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,75 +1,145 @@
# 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 point, arg parsing, terminal state management
-├── config.rs ~/.config/termdown/config.toml loading (serde)
-├── font.rs Font resolution, caching, CJK/Latin detection
-├── style.rs HeadingStyle, ANSI constants, display width utilities
-├── render.rs Text measurement, glyph drawing, PNG encoding, Kitty protocol
-└── markdown.rs pulldown-cmark event handling, terminal output, word wrapping
+├── 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
```
-## Rendering Pipeline
+## 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:
```
- ┌─────────────────────┐
- │ Markdown source │
- └──────────┬──────────┘
- │
- ┌──────────▼──────────┐
- │ pulldown-cmark │
- │ event stream │
- └──────────┬──────────┘
- │
- ┌───────────────┴───────────────┐
- │ │
- ┌─────────▼─────────┐ ┌──────────▼──────────┐
- │ H1 / H2 / H3 │ │ Everything else │
- │ (image heading) │ │ (ANSI text out) │
- └─────────┬─────────┘ └─────────────────────┘
+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 │
+ └─────────┬─────────┘
│
- ┌────────────▼────────────┐
- │ Per-character routing │
- │ is_cjk(ch) ? │
- ├────────┬───────────────┤
- │ Latin │ CJK │
- │ font │ font │
- └────┬───┴───────┬──────┘
- │ │
- ┌────▼───────────▼──────┐
- │ ab_glyph rasterize │
- │ → RGBA pixel buffer │
- └───────────┬───────────┘
- │
- ┌───────────▼───────────┐
- │ PNG encode (image) │
- └───────────┬───────────┘
- │
- ┌───────────▼───────────┐
- │ Kitty graphics │
- │ base64 → 4KB chunks │
- │ \x1b_G...;\x1b\\ │
- └───────────┬───────────┘
- │
- ┌───────────▼───────────┐
- │ stdout │
- └───────────────────────┘
+ ┌─────────▼─────────┐
+ │ PNG encode │
+ └─────────┬─────────┘
+ │
+ ┌─────────▼─────────┐
+ │ Kitty graphics │
+ └───────────────────┘
```
## Font Resolution
-For each heading, a `FontPair` (Latin + CJK) is resolved:
+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 = "..."
+1. User config [font.heading] latin / cjk / emoji
│
▼
-2. Explicit weight variants (macOS workaround)
- Try "{family} Black", "{family} Heavy" as separate family names
- (Core Text registers bold variants as separate families)
+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
@@ -77,21 +147,27 @@ For each heading, a `FontPair` (Latin + CJK) is resolved:
│
▼
4. Platform defaults
- Latin: Avenir, Futura, Helvetica Neue (macOS)
- Inter, Noto Sans, DejaVu Sans (Linux)
- Segoe UI, Arial (Windows)
- CJK: Noto Serif CJK SC, Source Han Serif SC ... (per platform)
+ 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
- SourceSerif4-SemiBold.ttf (bundled in binary via include_bytes!)
+ 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 `HashMap` to avoid repeated allocations.
+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 Split
+## CJK / Latin / Emoji split
-`font::is_cjk(ch)` checks Unicode ranges:
+`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 |
|-------|-------|
@@ -100,59 +176,110 @@ Font data loaded from disk or Core Text is `Box::leak`-ed into `'static` lifetim
| 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 |
-
-All other characters (ASCII, Latin, Cyrillic, etc.) use the Latin font.
+| U+20000..U+2FA1F | CJK Extensions B–F, Supplement |
## Kitty Graphics Protocol
-Headings are transmitted as:
+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 f=100,a=T,q=2,m=1 ; \x1b\
+\x1b_G m=1 ; \x1b\
...
-\x1b_G m=0 ; \x1b\\
+\x1b_G m=0 ; \x1b\
```
- `f=100` — PNG format
- `a=T` — transmit and display
-- `q=2` — suppress response (avoids iTerm2 "OK" leak)
+- `q=2` — suppress response (avoids the iTerm2 "OK" leak)
- `m=1/0` — more chunks / last chunk
- Chunk size: 4096 bytes base64
-## ANSI Text Rendering
+**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)
-Non-heading content goes through `markdown.rs`:
+`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 |
|---------|-----------|
-| H4-H6 | Bold ANSI text |
+| 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 for uniform-width background |
+| Code blocks | Buffered and padded to uniform width for a clean background rectangle |
| Inline code | Pink on dark gray |
-| Links | Cyan underline + gray URL |
+| Links | Colored + underline, with the URL shown |
| Tables | Unicode box-drawing, ANSI-aware column width |
-| Horizontal rule | ─ repeated to terminal width (max 60) |
+| 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 only)
+## Terminal State (UNIX, cat mode)
-On UNIX, `main.rs` temporarily disables terminal echo before rendering and restores it after. This prevents Kitty protocol acknowledgment responses (notably from iTerm2) from appearing as visible text. Guarded by `#[cfg(unix)]`.
+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
-`~/.config/termdown/config.toml` is deserialized via serde:
+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
- └── font: FontSection
- └── heading: HeadingFontConfig
- ├── latin: Option
- └── cjk: Option
+├── 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 config file or missing fields silently fall back to defaults.
+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
index bcf737c..f72b30f 100644
--- a/docs/ITERM2_KITTY_RESPONSE_LEAK.md
+++ b/docs/ITERM2_KITTY_RESPONSE_LEAK.md
@@ -126,8 +126,8 @@ fn disable_echo() -> libc::termios {
涉及文件:
-- `src/main.rs`:在 `markdown::render()` 前后管理 termios 状态
-- `src/render.rs`:`drain_kitty_responses()` 函数 + `q=2` 保留在协议序列中
+- `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 禁用只是作为兜底方案。
@@ -152,3 +152,11 @@ fn disable_echo() -> libc::termios {
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
index 0fe5302..b258b2f 100644
--- a/docs/LINK_PICKER_DESIGN.md
+++ b/docs/LINK_PICKER_DESIGN.md
@@ -2,7 +2,7 @@
## Problem
-Current `LinkSelect` mode (`src/tui/mod.rs:451-462`, `:924-932`):
+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`.
@@ -44,7 +44,7 @@ Reuse the existing ToC sidebar pattern. Add a `Mode::Links` (or reuse the TOC pa
### UX
-1. User presses `l` → a left panel opens (same 30-col width as ToC today, see `src/tui/mod.rs:805-842`).
+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.
diff --git a/docs/MARKDOWN_FEATURE_COVERAGE.md b/docs/MARKDOWN_FEATURE_COVERAGE.md
index 42ffa27..ea48b4d 100644
--- a/docs/MARKDOWN_FEATURE_COVERAGE.md
+++ b/docs/MARKDOWN_FEATURE_COVERAGE.md
@@ -1,6 +1,6 @@
# Markdown Feature Coverage
-Audit of `src/markdown.rs` against pulldown-cmark 0.13 and common Markdown extensions.
+Audit of `src/layout.rs` (the Markdown → `RenderedDoc` core) against pulldown-cmark 0.13 and common Markdown extensions.
## Supported (CommonMark core)
diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md
deleted file mode 100644
index 8d10f98..0000000
--- a/docs/RESEARCH.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# 终端渲染大字号 Markdown 标题 — 技术调研
-
-## 问题
-
-终端中渲染 Markdown 时,标题无法显示为更大字号。现有工具(glow、frogmouth 等)只能通过加粗/颜色区分标题层级,无法改变字体大小。这是终端字符网格(character-cell)架构的根本限制——每个字符占固定大小的格子,字体大小由终端模拟器全局控制,应用程序无权改变单个字符的大小。
-
-## Frogmouth 调研结论(已排除)
-
-https://github.com/Textualize/frogmouth
-
-- 基于 Textual TUI 框架,底层仍是字符网格渲染
-- 标题处理方式:加粗 + 颜色 + 上下留白,和 glow 本质相同
-- **无法改变字号**,此方案不可行
-
-## 已有项目(竞品调研)
-
-### mdfried — 与本 idea 完全重合
-
-https://github.com/benjajaja/mdfried (300+ stars,Rust)
-
-- 核心做法和我们的 idea 一模一样:用字体光栅化把标题渲染成图片,通过 Sixel/Kitty/iTerm2 协议显示
-- H1-H6 按比例缩放字号(H1 约 2x)
-- Rust 实现,使用 `ratatui-image` 库处理终端图片协议
-- 首次运行时选择系统字体(建议匹配终端字体)
-- 在 Kitty 0.40+ 上优先使用 OSC 66 协议(无需光栅化)
-- 不支持图片协议的终端 fallback 到 Chafa(ASCII/ANSI art)
-- 兼容终端:Kitty, WezTerm, iTerm2, Ghostty, Foot, xterm (vt340)
-
-### 其他相关项目
-
-| 项目 | 方案 | 语言 | 说明 |
-|------|------|------|------|
-| [mkd](https://github.com/amatsuda/mkd) | 纯 OSC 66 | Ruby | 无图片 fallback,仅 Kitty |
-| [presenterm](https://github.com/mfontanini/presenterm) | OSC 66 幻灯片 | Rust | 终端演示工具,标题用 font_size 1-7 |
-| [cb-headscale.nvim](https://github.com/CbBelmante/cb-headscale.nvim) | OSC 66 in Neovim | Lua | 早期阶段 |
-| [mcat](https://github.com/Skardyy/mcat) | Markdown 内嵌图片 | Rust | 标题仍是 ANSI 文本,不光栅化 |
-
-### 主流工具均不支持大字号标题
-
-glow、mdcat、rich-cli、frogmouth、mdless、terminal_markdown_viewer 等均只用 ANSI 加粗/颜色渲染标题。
-
-## Kitty Text Sizing Protocol (OSC 66) — 新兴方案
-
-Kitty 0.40(2025-03)引入的终端协议,可**不用渲染图片**直接显示大字号文本:
-
-```bash
-printf "\e]66;s=2;双倍大小的文字\a\n\n" # 2x
-printf "\e]66;s=3;三倍大小的文字\a\n\n\n" # 3x
-```
-
-规范:https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
-
-- `s` 参数(scale, 1-7):终端将每个字符渲染在 s×s 的 cell 块中
-- 终端原生缩放字体,无需应用层光栅化
-- **终端支持状态**:
- - Kitty 0.40+:完整支持(scale + width)
- - Foot:部分支持(仅 width)
- - **Ghostty:开发中(PR 进行中,优先支持 width)**
- - 其他终端:暂不支持
-
-**结论:OSC 66 是更优雅的未来方向。** 等 Ghostty 支持后,标题放大可以零开销实现。但当前阶段仍需图片协议作为 fallback。
-
-## 可行方案对比
-
-| 方案 | 能否真正放大标题 | 复杂度 | 效果 |
-|------|:---:|:---:|------|
-| TUI 框架(Frogmouth/Textual/glow) | 否 | 中 | 加粗+颜色,本质相同 |
-| FIGlet/ASCII Art | 视觉上是 | 低 | 用多行字符拼出大字,粗糙但兼容所有终端 |
-| **Sixel/Kitty 图片协议** | **是** | 高 | 真正的大字号,依赖终端图片协议支持 |
-| **OSC 66 Text Sizing** | **是** | 低 | 终端原生缩放,最优方案,但终端支持有限 |
-
-## 选定方案:Kitty 图片协议
-
-### 原理
-
-1. 解析 Markdown,识别标题(`#` / `##` / `###`)
-2. 用字体光栅化库将标题文字渲染为 PNG 图片(透明背景,不同层级对应不同字号)
-3. 将 PNG base64 编码后,通过 Kitty 图片协议转义序列(`\033_Gf=100,a=T,...;base64data\033\\`)输出到终端
-4. 非标题内容原样输出为终端文本
-
-### 终端兼容性
-
-支持 Kitty 图片协议的终端:
-- **Ghostty**(当前使用的终端)
-- Kitty
-- WezTerm
-- iTerm2(也支持自有的 inline image 协议)
-
-Sixel 协议可作为 fallback,兼容更多终端。
-
-### Python MVP 已验证
-
-`mdrender.py` 是一个 Python 概念验证,使用 Pillow 做字体渲染 + Kitty 协议输出。已验证方案可行。
-
-配置参数:
-- 字体:PingFang(支持中英文)
-- 字号:h1=48px, h2=36px, h3=28px
-- 颜色:h1 亮白, h2 柔蓝, h3 柔绿
-- 透明背景(继承终端背景色)
-
-## 正式实现:推荐 Rust
-
-目标是保持单二进制分发、体积尽量小。
-
-### 语言选型对比
-
-| | Rust | Go | Node.js |
-|--|------|-----|---------|
-| 单二进制 | 是 | 是 | 需 bun compile/pkg 打包 |
-| 二进制大小 | ~2-3MB | ~6-8MB | ~30MB+ |
-| 字体渲染 | `ab_glyph` + `image`(纯 Rust,零系统依赖) | `golang.org/x/image/font`(纯 Go) | `node-canvas`(依赖 Cairo) |
-| 交叉编译 | 方便 | 方便 | 麻烦 |
-
-**结论:选 Rust。** 二进制最小,纯 Rust 字体光栅化无系统依赖,最适合"尽量小"的目标。
-
-### Rust 关键 crate
-
-- `ab_glyph` 或 `fontdue` — 字体光栅化(纯 Rust)
-- `image` — PNG 编码
-- `base64` — base64 编码
-- Markdown 解析可用 `pulldown-cmark`
-
-## 下一步决策
-
-### 选项 A:直接使用 mdfried
-- 已经是成熟的 Rust 实现,和我们的 idea 完全重合
-- 可以 fork 后定制,或直接使用
-
-### 选项 B:自己实现(差异化方向)
-如果要做,需要找到和 mdfried 的差异点,可能的方向:
-- 更轻量(mdfried 基于 ratatui 全功能 TUI,我们可以做纯 pipeline 工具)
-- OSC 66 优先 + 图片协议 fallback 的策略
-- 更好的配置化(字体/颜色/字号)
-- 作为 library 提供,而非独立应用
-
-### 选项 C:关注 OSC 66 生态
-- 等 Ghostty 支持 OSC 66 后,用最简方案实现
-- 不需要图片渲染,代码量和二进制体积都极小
-
-## 其他改进方向(如果自己实现)
-
-- 颜色/字号可配置化
-- inline 样式支持(`**bold**`、`*italic*` 等)
-- 检测终端宽度,自动换行长标题
-- Sixel 协议 fallback(兼容更多终端)
-- 检测终端是否支持图片协议,不支持时退化为纯文本样式
diff --git a/docs/TERMINAL_PROTOCOLS.md b/docs/TERMINAL_PROTOCOLS.md
index 51a37e0..bf46728 100644
--- a/docs/TERMINAL_PROTOCOLS.md
+++ b/docs/TERMINAL_PROTOCOLS.md
@@ -74,7 +74,7 @@ Kitty 终端发明的**图像传输协议**,用来在文本流里夹带 PNG/RG
- 图片在哪些位置出现完全一致
- 但不比图片的 PNG 像素内容
-### `tests/headings.rs::extract_kitty_pngs`
+### `tests/headings.rs::extract_kitty_frames` + `decode_png`
反方向——专门**只看图片**:扫 stdout 里所有 APC 帧,按 `m=1`/`m=0` 把分片拼回完整 base64,解码出原始 PNG 字节。然后用 `image` crate 解码 PNG,断言宽高、像素非空白、H1>H2>H3 缩放正确。
diff --git a/docs/TUI_MODE_DEBUG_LOG.md b/docs/TUI_MODE_DEBUG_LOG.md
deleted file mode 100644
index 28ebfa4..0000000
--- a/docs/TUI_MODE_DEBUG_LOG.md
+++ /dev/null
@@ -1,212 +0,0 @@
-# TUI Mode — Debug Log
-
-Running log of bugs surfaced during manual QA of `--tui` and the fixes
-applied. This document exists so that a fresh session can resume
-debugging without re-deriving the investigation.
-
-Paired with:
-- `docs/TUI_MODE_DESIGN.md` — the authoritative design.
-- `docs/TUI_MODE_RESEARCH.md` — library / protocol background.
-- `docs/TUI_MODE_PLAN.md` — task-by-task implementation plan.
-
----
-
-## Branch & Status
-
-- Working branch: `feature/tui-mode` (37 implementation commits on top
- of `master`).
-- `make check` is green — 77 tests, 0 clippy warnings.
-- Phase 8.1 (manual QA in Ghostty/iTerm2) is the current step. All
- code-authored phases are complete but real-terminal testing has
- surfaced the bugs below.
-
----
-
-## Round 1 — First Manual Run
-
-**Symptom.** All heading images rendered stacked on row 0 across the
-top of the terminal, side-by-side, overlapping each other. Right edge
-showed fragments of heading text as single stacked letters.
-
-**Root cause.** `src/render.rs::place` was encoding cell coordinates
-into the Kitty APC `x=`/`y=` parameters. Those keys are
-**source-image pixel offsets (for cropping)**, not terminal cells.
-Kitty `a=p` places at the **current cursor position**; to place at a
-specific cell you must first emit a CUP (`\x1b[R;CH`) and *then* send
-`a=p`.
-
-Also: `transmit` used `a=T` (transmit **and display**), polluting the
-startup screen. The right form for the TUI lifecycle is `a=t`
-(transmit only, place later via `a=p`).
-
-**Fix (`30fc57e`).**
-
-```rust
-// src/render.rs
-pub fn place(w: &mut W, id: u32, col: u16, row: u16) -> io::Result<()> {
- // Move cursor first (1-indexed), then place the image there.
- write!(w, "\x1b[{};{}H\x1b_Ga=p,i={id},q=2;\x1b\\", row + 1, col + 1)
-}
-```
-
-And changed both `a=T` occurrences in `transmit` to `a=t`.
-
-Tests were updated (`transmit_produces_a_eq_t_with_id`,
-`place_produces_cursor_move_then_a_eq_p`) and the
-`sync_enters_new_moves_and_leaves` test in `tui/kitty.rs` was updated
-to expect the new CUP prefix.
-
----
-
-## Round 2 — Second Manual Run
-
-**Symptoms reported.**
-
-1. First frame looked mostly correct, but:
- - Body text started at column 0 — no left margin (cat mode uses 4
- columns).
- - A red vertical pixel line appeared on the far-left edge — likely
- a Kitty image artifact bleeding past its row budget.
- - The bottom-most H2 heading ("TUI 模式" in the Chinese README)
- visibly overlapped the status bar.
-
-2. After pressing `j`/`k` to scroll, the screen **degraded
- progressively** with each keypress:
- - Old text from previous frames persisted on screen while new text
- layered on top.
- - Right ~30–40% of the terminal went blank.
- - Status bar drifted upward out of the bottom row.
- - Chinese and English snippets stacked visibly.
-
-**Hypotheses investigated** (opus pass).
-
-| ID | Hypothesis | Verdict |
-|----|------------|---------|
-| H1 | Ratatui's incremental diff leaves stale cells when RLines change shape | Confirmed |
-| H2 | `Viewport::width` cached once at startup, never resyncs on resize | Confirmed |
-| H3 | No `MARGIN_WIDTH` prefix on TUI body RLines | Confirmed |
-| H4 | Hardcoded `rows` estimates (H1=6, H2=4, H3=3) under-count real PNG cell height | Confirmed |
-| H5 | Cat mode's output changed | Not changed — snapshots still pass |
-| H6 | Kitty `a=p` advances cursor past image; can scroll the visible region when placing near bottom | **This was the biggest offender.** Needed `C=1` on every place. |
-
-**Fix (`bec090a`).**
-
-Multi-file change in `src/render.rs`, `src/layout.rs`,
-`src/tui/kitty.rs`, `src/tui/mod.rs`. Highlights:
-
-- `render_heading` now returns `Option<(Vec, u32, u32)>` (png +
- pixel width + pixel height). `HeadingImage` gained `px_width` /
- `px_height` fields. Layout threads these through.
-- `place` now emits `C=1` (don't advance cursor after placement) in
- addition to the CUP prefix:
- `\x1b[{row+1};{col+1}H\x1b_Ga=p,i={id},q=2,C=1;\x1b\\`.
-- New `ImageLifecycle::reset_placements` method — deletes every
- current placement without discarding the cached PNG data.
-- Event loop now:
- - Re-queries `terminal.size()` each iteration, syncs
- `viewport.width`/`height`, invalidating the wrap cache on change.
- - Poll timeout raised from 16ms to 50ms (less CPU spin).
- - Every processed event sets `needs_full_redraw = true`; the next
- iteration calls `terminal.clear()` + `reset_placements` before
- `terminal.draw`. Belt-and-braces guard against the stale-cell
- class of bugs.
-- `draw` prepends a 4-space `RSpan` to every body RLine so the text
- column aligns with image column (`MARGIN_WIDTH = 4`).
-- Heading image `rows` are now computed from the queried terminal
- cell pixel height when available (via
- `crossterm::terminal::window_size()`):
- `rows = ceil(png_height_px / cell_pixel_height)`. Fall back to the
- hardcoded per-level estimates if the terminal reports pixel size 0.
-- `ToggleToc` no longer manually adjusts viewport width — the
- event-loop resync handles it.
-
-**Status.** Committed; `make check` green. **Not yet verified by the
-user in a real terminal.** The user hit a usage limit before re-testing.
-
----
-
-## Round 3 — Flicker when holding `j`
-
-**Symptom.** Scrolling by tapping `j` looks correct, but holding `j`
-for ~1-2 seconds (until macOS key-autorepeat kicks in at ~30 Hz)
-degrades into violent whole-screen flicker.
-
-**Root cause.** Round 2 had set `app.needs_full_redraw = true` after
-*every* processed event as a belt-and-braces safety net. At autorepeat
-rate the event loop fires `terminal.clear()` (emits `\x1b[2J`) plus a
-full PNG re-transmission ~30 times per second, which is exactly what a
-flicker looks like. The `C=1` placement flag landed in Round 2 already
-prevents the cursor-advance cascade the blanket was guarding against,
-so the blanket is now pure harm.
-
-**Fix (`46d7503`).**
-
-- Removed the blanket `needs_full_redraw = true` at the end of
- `event_loop`.
-- Set `needs_full_redraw = true` only where body geometry or content
- actually changes: `ToggleToc` (width shift), `Back` / `Forward` /
- `open_link_target` (doc swap). Resize already had an explicit path.
-- Scroll / search / mode-change events now rely purely on ratatui's
- cell diff + `images.sync()` — which is what `TUI_MODE_DESIGN.md`
- originally specified.
-
-Held-`j` should now match the design acceptance bar ("no flicker, no
-lag, no image residue").
-
----
-
-## Open Risks / What To Verify Next
-
-Residual items from Rounds 1-3. Test on both Ghostty and iTerm2:
-
-1. **Red vertical line on the left edge.** Most plausibly a cascading
- side-effect of the cursor-advance bug, addressed by `C=1` in
- `bec090a`. Likely resolved; reopen if it reappears.
-
-2. **Bottom-heading overlap with the status bar.** Resolved by
- `32455c9` (spacer `VisualLine`s + placement clipping against
- `body_height`). If still present on a terminal that reports zero
- pixel size: the per-level fallback undercount may leak through —
- see #3 below.
-
-3. **Terminals that don't report pixel size.** `window_size()` can
- return 0 for pixel fields. Our fallback keeps the old per-level
- estimates, which under-count. Consider bumping those fallbacks to
- H1=8, H2=6, H3=4, or add an APC-based `\x1b[16t` probe.
-
-4. **Performance on long docs.** When a full redraw *does* fire
- (ToC toggle, doc swap, resize), `reset_placements` + re-transmit
- runs over every cached image. For a 30-heading doc that's a
- measurable stall on the ToC-toggle keystroke. Only worth
- optimizing if it becomes user-visible — normal scroll no longer
- takes this path.
-
----
-
-## If Everything Is Fine After Round 2
-
-- Close out Phase 8.1 and merge `feature/tui-mode` to `master`.
-- Delete this file or archive it under `docs/archive/`.
-
-## If Further Rounds Are Needed
-
-- Add a new `### Round N — ...` section above this one.
-- Always name the exact commit SHAs applied and the specific
- hypotheses tested.
-- Keep `make check` green at each round; manual-only behavior fixes
- still need snapshot validation that cat mode is unaffected.
-
----
-
-## Update — 2026-05-22: `MARGIN_WIDTH` is gone
-
-This log references `MARGIN_WIDTH` in the H3 bug analysis ("No
-`MARGIN_WIDTH` prefix on TUI body RLines", "column aligns with image
-column (`MARGIN_WIDTH = 4`)"). That constant — along with the 4-space
-outer margin that mirrored `glow` — has been removed now that TUI is the
-default mode. Body rows in both cat and TUI start at column 0; heading
-images are placed at column 0 (or column 30 when the ToC panel is open).
-
-The H3 fix described in the original log is unaffected in spirit: heading
-images still need to be placed at the same column where the text would
-have rendered. The number is just `0`/`30` now instead of `4`/`34`.
diff --git a/docs/TUI_MODE_DESIGN.md b/docs/TUI_MODE_DESIGN.md
index d24fc47..1f4598b 100644
--- a/docs/TUI_MODE_DESIGN.md
+++ b/docs/TUI_MODE_DESIGN.md
@@ -1,8 +1,7 @@
# TUI Mode — Design
-Design for termdown's `--tui` mode. Research and approach comparison
-that led to this design lives in `TUI_MODE_RESEARCH.md`. Read that first
-if you want the "why this stack"; this doc is the "what we're building".
+Design for termdown's `--tui` mode — the authoritative "what we're
+building" spec.
## Goals
diff --git a/docs/TUI_MODE_PLAN.md b/docs/TUI_MODE_PLAN.md
deleted file mode 100644
index e61f8ad..0000000
--- a/docs/TUI_MODE_PLAN.md
+++ /dev/null
@@ -1,3328 +0,0 @@
-# TUI Mode Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a `--tui` mode to termdown providing vim-style browsing (paging, search, heading nav, back/forward, link open) for long Markdown files.
-
-**Architecture:** `layout.rs` produces a structured `RenderedDoc` consumed by both the existing cat path (`cat.rs`, replacing current `markdown.rs` stdout logic) and a new `tui::App` (ratatui text layer + self-managed Kitty image placement via `a=T` / `a=p` / `a=d`). Per-doc viewport/search state is preserved across back/forward navigation.
-
-**Tech Stack:** Rust 2021, pulldown-cmark 0.13, ratatui + crossterm (new), tui-textarea (new), regex (new), existing `ab_glyph` + `image` + Kitty graphics protocol.
-
-**Spec reference:** `docs/TUI_MODE_DESIGN.md` — the authoritative design. This plan implements that spec in order.
-
-**Conventional commits:** Every commit uses the project's Conventional Commits format (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`). Scope prefix optional.
-
-**Verification gate:** Every phase ends with `make check` passing (fmt-check + lint + test). Never skip — this is the project's CI gate.
-
----
-
-## Phase 0 — Snapshot Baseline
-
-**Why first:** Tasks in Phase 1 refactor cat output. Before we touch it, freeze the current stdout bytes for every fixture so we have a byte-level regression baseline. Any drift later is reviewed intent-first.
-
-### Task 0.1: Freeze cat output snapshots
-
-**Files:**
-- Create: `fixtures/expected/emoji-test.ansi`
-- Create: `fixtures/expected/full-syntax-zh.ansi`
-- Create: `fixtures/expected/full-syntax.ansi`
-- Create: `fixtures/expected/tasklist.ansi`
-- Create: `fixtures/expected/unsupported-syntax.ansi`
-- Create: `tests/snapshots.rs`
-
-- [ ] **Step 1: Build the current binary**
-
-Run: `make build`
-Expected: exits 0.
-
-- [ ] **Step 2: Capture each fixture's current output**
-
-Run (from repo root):
-
-```sh
-for f in fixtures/*.md; do
- name=$(basename "$f" .md)
- TERM_PROGRAM=ghostty target/debug/termdown "$f" > "fixtures/expected/${name}.ansi"
-done
-```
-
-Expected: five `.ansi` files exist, each non-empty.
-
-- [ ] **Step 3: Write the snapshot test**
-
-`tests/snapshots.rs`:
-
-```rust
-use std::fs;
-use std::path::Path;
-use std::process::{Command, Stdio};
-
-fn binary_path() -> &'static str {
- env!("CARGO_BIN_EXE_termdown")
-}
-
-fn render(path: &Path) -> String {
- let out = Command::new(binary_path())
- .arg(path)
- .env("TERM_PROGRAM", "ghostty")
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .output()
- .expect("termdown should run");
- assert!(out.status.success(), "termdown failed on {path:?}");
- String::from_utf8(out.stdout).expect("valid utf-8")
-}
-
-fn check_snapshot(fixture: &str) {
- let md = Path::new("fixtures").join(format!("{fixture}.md"));
- let expected_path = Path::new("fixtures/expected").join(format!("{fixture}.ansi"));
- let expected = fs::read_to_string(&expected_path).expect("expected file");
- let actual = render(&md);
- if actual != expected {
- let tmp = std::env::temp_dir().join(format!("termdown-snapshot-{fixture}.ansi"));
- fs::write(&tmp, &actual).ok();
- panic!(
- "snapshot mismatch for {fixture}\n expected: {}\n actual written to: {}",
- expected_path.display(),
- tmp.display()
- );
- }
-}
-
-#[test] fn snapshot_emoji_test() { check_snapshot("emoji-test"); }
-#[test] fn snapshot_full_syntax_zh() { check_snapshot("full-syntax-zh"); }
-#[test] fn snapshot_full_syntax() { check_snapshot("full-syntax"); }
-#[test] fn snapshot_tasklist() { check_snapshot("tasklist"); }
-#[test] fn snapshot_unsupported_syntax(){ check_snapshot("unsupported-syntax"); }
-```
-
-- [ ] **Step 4: Verify snapshot tests pass against frozen output**
-
-Run: `make check`
-Expected: all tests pass, including the five new `snapshot_*` tests.
-
-- [ ] **Step 5: Commit**
-
-```sh
-git add fixtures/expected tests/snapshots.rs
-git commit -m "test: freeze cat output snapshots as refactor baseline"
-```
-
----
-
-## Phase 1 — Layout Refactor (cat path)
-
-**Why:** `markdown.rs` today is 800 lines of tangled state machine that writes directly to stdout. We split it into (a) `layout.rs` that produces a `RenderedDoc` and (b) `cat.rs` that serializes `RenderedDoc` → ANSI stdout. The TUI path in later phases will reuse `layout.rs`.
-
-### Task 1.1: Introduce core data types in layout.rs
-
-**Files:**
-- Create: `src/layout.rs`
-- Modify: `src/main.rs:1-6` (add `mod layout;`)
-
-- [ ] **Step 1: Write a compile-only test**
-
-Append to `src/layout.rs`:
-
-```rust
-use crate::render::HeadingImage;
-
-#[derive(Debug, Clone)]
-pub struct RenderedDoc {
- pub lines: Vec,
- pub headings: Vec,
- pub images: Vec,
-}
-
-#[derive(Debug, Clone)]
-pub struct Line {
- pub spans: Vec,
- pub kind: LineKind,
-}
-
-#[derive(Debug, Clone)]
-pub enum LineKind {
- Body,
- Heading { level: u8, id: Option }, // id = Some for H1-H3, None for H4-H6
- CodeBlock { lang: Option },
- BlockQuote { depth: u8 },
- ListItem { depth: u8 },
- Table,
- HorizontalRule,
- Blank,
-}
-
-#[derive(Debug, Clone)]
-pub enum Span {
- Text { content: String, style: Style },
- HeadingImage { id: u32, rows: u16 },
- Link { content: String, url: String, style: Style },
-}
-
-#[derive(Debug, Clone, Default)]
-pub struct Style {
- pub fg: Option,
- pub bg: Option,
- pub bold: bool,
- pub italic: bool,
- pub underline: bool,
- pub strikethrough: bool,
- pub dim: bool,
-}
-
-#[derive(Debug, Clone, Copy)]
-pub enum Color {
- /// 256-color index (what the existing style.rs already emits)
- Indexed(u8),
- /// Truecolor fallback for future use
- Rgb(u8, u8, u8),
-}
-
-#[derive(Debug, Clone)]
-pub struct HeadingEntry {
- pub level: u8,
- pub text: String,
- pub line_index: usize,
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn types_compile() {
- let _ = RenderedDoc {
- lines: vec![Line {
- spans: vec![Span::Text {
- content: "hi".into(),
- style: Style::default(),
- }],
- kind: LineKind::Body,
- }],
- headings: vec![],
- images: vec![],
- };
- }
-}
-```
-
-- [ ] **Step 2: Move HeadingImage into render.rs**
-
-Add to `src/render.rs` (after the existing `kitty_display` function):
-
-```rust
-/// PNG data + cell dimensions for a rendered heading image.
-/// Stored by id in `RenderedDoc` and transmitted to the terminal
-/// once per TUI session.
-#[derive(Debug, Clone)]
-pub struct HeadingImage {
- pub id: u32,
- pub png: Vec,
- pub cols: u16,
- pub rows: u16,
-}
-```
-
-- [ ] **Step 3: Register the module**
-
-Edit `src/main.rs:1-6`:
-
-```rust
-mod config;
-mod font;
-mod layout;
-mod markdown;
-mod render;
-mod style;
-mod theme;
-```
-
-- [ ] **Step 4: Verify**
-
-Run: `cargo test --lib layout::tests::types_compile`
-Expected: PASS.
-
-Run: `make check`
-Expected: all tests pass (including the snapshot tests from Phase 0).
-
-- [ ] **Step 5: Commit**
-
-```sh
-git add src/layout.rs src/render.rs src/main.rs
-git commit -m "feat(layout): introduce RenderedDoc/Line/Span types"
-```
-
-### Task 1.2: Port markdown event walk into layout.rs
-
-**Files:**
-- Modify: `src/layout.rs` (add the `build` function)
-
-This is the core port. The existing `markdown::render` function walks pulldown-cmark events and writes to stdout. We lift the walk into a pure function that returns `RenderedDoc`. No behavior change yet — cat still uses the old path until Task 1.4 wires in `cat.rs`.
-
-- [ ] **Step 1: Write the first failing test (plain paragraph)**
-
-Append to the `#[cfg(test)] mod tests` in `src/layout.rs`:
-
-```rust
-use crate::config::Config;
-use crate::theme::Theme;
-
-fn build_plain(md: &str) -> RenderedDoc {
- let cfg = Config::default();
- super::build(md, &cfg, Theme::Dark)
-}
-
-#[test]
-fn build_single_paragraph() {
- let doc = build_plain("hello world\n");
- // One body line + one blank separator — match current cat behavior.
- assert!(doc
- .lines
- .iter()
- .any(|l| matches!(l.kind, LineKind::Body) && spans_plain_text(&l.spans) == "hello world"));
-}
-
-fn spans_plain_text(spans: &[Span]) -> String {
- let mut out = String::new();
- for s in spans {
- match s {
- Span::Text { content, .. } => out.push_str(content),
- Span::Link { content, .. } => out.push_str(content),
- Span::HeadingImage { .. } => {}
- }
- }
- out
-}
-```
-
-- [ ] **Step 2: Run the test — it fails because `build` doesn't exist**
-
-Run: `cargo test --lib layout::tests::build_single_paragraph`
-Expected: FAIL with "cannot find function `build`".
-
-- [ ] **Step 3: Stub `Config::default`**
-
-If `Config::default` doesn't exist, add it in `src/config.rs`:
-
-```rust
-impl Default for Config {
- fn default() -> Self {
- Self { theme: None, font: Default::default() }
- }
-}
-```
-
-(Inspect the existing `Config` struct first — if it already derives `Default`, skip. Add `#[derive(Default)]` on nested structs as needed.)
-
-- [ ] **Step 4: Implement `build` (minimal — handles paragraph only)**
-
-Add to `src/layout.rs`:
-
-```rust
-use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
-use crate::config::Config;
-use crate::theme::Theme;
-
-pub fn build(md: &str, _config: &Config, _theme: Theme) -> RenderedDoc {
- let mut opts = Options::empty();
- opts.insert(Options::ENABLE_STRIKETHROUGH);
- opts.insert(Options::ENABLE_TABLES);
- opts.insert(Options::ENABLE_TASKLISTS);
- let parser = Parser::new_ext(md, opts);
-
- let mut lines: Vec = Vec::new();
- let mut current = String::new();
-
- for event in parser {
- match event {
- Event::Start(Tag::Paragraph) => {}
- Event::End(TagEnd::Paragraph) => {
- lines.push(Line {
- spans: vec![Span::Text {
- content: std::mem::take(&mut current),
- style: Style::default(),
- }],
- kind: LineKind::Body,
- });
- }
- Event::Text(t) => current.push_str(&t),
- _ => {}
- }
- }
-
- RenderedDoc { lines, headings: vec![], images: vec![] }
-}
-```
-
-- [ ] **Step 5: Run — test passes**
-
-Run: `cargo test --lib layout::tests::build_single_paragraph`
-Expected: PASS.
-
-- [ ] **Step 6: Commit the first slice**
-
-```sh
-git add src/layout.rs src/config.rs
-git commit -m "feat(layout): build handles plain paragraphs"
-```
-
-### Task 1.3: Port inline text, emphasis, strong, strikethrough
-
-**Files:**
-- Modify: `src/layout.rs`
-
-The cat output for inline formatting today embeds ANSI escapes into the text buffer. Our new model emits them as separate `Span::Text` entries with `Style` flags set.
-
-- [ ] **Step 1: Failing test**
-
-Append to the `tests` module in `src/layout.rs`:
-
-```rust
-#[test]
-fn build_inline_bold_and_italic() {
- let doc = build_plain("hello **bold** and *it*\n");
- let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap();
-
- // Expect at least: "hello ", "bold" (bold), " and ", "it" (italic)
- let bold_span = line.spans.iter().find(|s| matches!(s, Span::Text { style, .. } if style.bold));
- let italic_span = line.spans.iter().find(|s| matches!(s, Span::Text { style, .. } if style.italic));
- assert!(matches!(bold_span, Some(Span::Text { content, .. }) if content == "bold"));
- assert!(matches!(italic_span, Some(Span::Text { content, .. }) if content == "it"));
-}
-```
-
-- [ ] **Step 2: Extend `build` to track inline state**
-
-Replace the body of `build` in `src/layout.rs`:
-
-```rust
-pub fn build(md: &str, _config: &Config, _theme: Theme) -> RenderedDoc {
- let mut opts = Options::empty();
- opts.insert(Options::ENABLE_STRIKETHROUGH);
- opts.insert(Options::ENABLE_TABLES);
- opts.insert(Options::ENABLE_TASKLISTS);
- let parser = Parser::new_ext(md, opts);
-
- let mut lines: Vec = Vec::new();
- let mut spans: Vec = Vec::new();
- let mut text_buf = String::new();
- let mut style = Style::default();
-
- // Flush pending plain-text buffer into a span with the current style.
- let flush_text = |text_buf: &mut String, spans: &mut Vec, style: &Style| {
- if !text_buf.is_empty() {
- spans.push(Span::Text {
- content: std::mem::take(text_buf),
- style: style.clone(),
- });
- }
- };
-
- for event in parser {
- match event {
- Event::Start(Tag::Paragraph) => {}
- Event::End(TagEnd::Paragraph) => {
- flush_text(&mut text_buf, &mut spans, &style);
- lines.push(Line {
- spans: std::mem::take(&mut spans),
- kind: LineKind::Body,
- });
- }
- Event::Start(Tag::Strong) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.bold = true;
- }
- Event::End(TagEnd::Strong) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.bold = false;
- }
- Event::Start(Tag::Emphasis) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.italic = true;
- }
- Event::End(TagEnd::Emphasis) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.italic = false;
- }
- Event::Start(Tag::Strikethrough) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.strikethrough = true;
- }
- Event::End(TagEnd::Strikethrough) => {
- flush_text(&mut text_buf, &mut spans, &style);
- style.strikethrough = false;
- }
- Event::Text(t) => text_buf.push_str(&t),
- _ => {}
- }
- }
-
- RenderedDoc { lines, headings: vec![], images: vec![] }
-}
-```
-
-- [ ] **Step 3: Run both tests**
-
-Run: `cargo test --lib layout::tests`
-Expected: both `build_single_paragraph` and `build_inline_bold_and_italic` PASS.
-
-- [ ] **Step 4: Commit**
-
-```sh
-git add src/layout.rs
-git commit -m "feat(layout): support inline strong/emphasis/strikethrough"
-```
-
-### Task 1.4: Port links, inline code, autolinks
-
-**Files:**
-- Modify: `src/layout.rs`
-
-- [ ] **Step 1: Failing test**
-
-```rust
-#[test]
-fn build_link_becomes_link_span() {
- let doc = build_plain("see [docs](https://example.com) now\n");
- let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap();
- let link = line.spans.iter().find_map(|s| match s {
- Span::Link { content, url, .. } => Some((content.clone(), url.clone())),
- _ => None,
- });
- assert_eq!(link, Some(("docs".into(), "https://example.com".into())));
-}
-
-#[test]
-fn build_inline_code_has_code_style_flag() {
- // Inline code shows up as a Text span with a fg/bg Style; we distinguish
- // by a dedicated flag or by both colors being set. Use bg.is_some() as proxy.
- let doc = build_plain("run `ls` now\n");
- let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap();
- let code = line.spans.iter().find_map(|s| match s {
- Span::Text { content, style } if content == "ls" && style.bg.is_some() => Some(()),
- _ => None,
- });
- assert!(code.is_some());
-}
-```
-
-- [ ] **Step 2: Add Link + Code handling to `build`**
-
-Inside the match in `src/layout.rs::build`, add these arms (place before `Event::Text`):
-
-```rust
-Event::Start(Tag::Link { dest_url, .. }) => {
- flush_text(&mut text_buf, &mut spans, &style);
- // Stash url; the following Event::Text is the link text.
- pending_link_url = Some(dest_url.to_string());
-}
-Event::End(TagEnd::Link) => {
- if let Some(url) = pending_link_url.take() {
- let content = std::mem::take(&mut text_buf);
- spans.push(Span::Link { content, url, style: style.clone() });
- }
-}
-Event::Code(code) => {
- flush_text(&mut text_buf, &mut spans, &style);
- let mut code_style = style.clone();
- code_style.bg = Some(Color::Indexed(236));
- code_style.fg = Some(Color::Indexed(213));
- spans.push(Span::Text {
- content: code.to_string(),
- style: code_style,
- });
-}
-```
-
-At the top of `build` add:
-
-```rust
-let mut pending_link_url: Option = None;
-```
-
-- [ ] **Step 3: Run tests**
-
-Run: `cargo test --lib layout::tests`
-Expected: all four layout tests PASS.
-
-- [ ] **Step 4: Commit**
-
-```sh
-git add src/layout.rs
-git commit -m "feat(layout): support links and inline code"
-```
-
-### Task 1.5: Port headings (H1-H3 image, H4-H6 text)
-
-**Files:**
-- Modify: `src/layout.rs`
-- Modify: `src/render.rs` (expose an id-allocation helper)
-
-The key addition: H1-H3 render to PNG via the existing `render::render_heading`, get assigned a unique `image_id`, and the heading `Line` gets a `Span::HeadingImage { id, rows }`. `rows` is derived from image height divided by an estimated cell height (we use a reasonable constant per level until we wire terminal cell size in Phase 3).
-
-- [ ] **Step 1: Failing test**
-
-```rust
-#[test]
-fn build_h1_emits_heading_image_span_and_entry() {
- let md = "# Title\n\nbody\n";
- let doc = build_plain(md);
-
- // Heading line has a HeadingImage span (assuming fonts resolve on this platform;
- // if not, assert that kind is Heading with a text-fallback span instead).
- let heading_line = doc
- .lines
- .iter()
- .find(|l| matches!(l.kind, LineKind::Heading { level: 1, .. }))
- .expect("heading line should exist");
-
- // Must have either a HeadingImage span or a plain-text fallback span.
- let ok = heading_line.spans.iter().any(|s| {
- matches!(s, Span::HeadingImage { .. })
- || matches!(s, Span::Text { content, .. } if content.contains("Title"))
- });
- assert!(ok);
-
- // HeadingEntry present with matching text.
- let entry = doc.headings.iter().find(|h| h.level == 1);
- assert!(matches!(entry, Some(e) if e.text == "Title"));
-}
-```
-
-- [ ] **Step 2: Add heading-level state to `build`**
-
-At the top of `build`, add:
-
-```rust
-let mut heading_level: u8 = 0;
-let mut heading_text = String::new();
-let mut next_image_id: u32 = 1;
-let mut images: Vec = Vec::new();
-let mut headings: Vec = Vec::new();
-```
-
-Inside the match, add (before the catch-all `_ => {}`):
-
-```rust
-Event::Start(Tag::Heading { level, .. }) => {
- heading_level = match level {
- pulldown_cmark::HeadingLevel::H1 => 1,
- pulldown_cmark::HeadingLevel::H2 => 2,
- pulldown_cmark::HeadingLevel::H3 => 3,
- pulldown_cmark::HeadingLevel::H4 => 4,
- pulldown_cmark::HeadingLevel::H5 => 5,
- pulldown_cmark::HeadingLevel::H6 => 6,
- };
- heading_text.clear();
-}
-Event::End(TagEnd::Heading(_)) => {
- let text = std::mem::take(&mut heading_text);
- headings.push(HeadingEntry {
- level: heading_level,
- text: text.clone(),
- line_index: lines.len(),
- });
-
- let heading_spans: Vec = if heading_level <= 3 {
- match crate::render::render_heading(&text, heading_level, _config, _theme) {
- Some(png) => {
- let id = next_image_id;
- next_image_id += 1;
- // Estimated rows: H1≈6, H2≈4, H3≈3 (refined in Phase 3 with real cell height).
- let rows = match heading_level { 1 => 6, 2 => 4, _ => 3 };
- images.push(HeadingImage { id, png, cols: 0, rows });
- vec![Span::HeadingImage { id, rows }]
- }
- None => vec![Span::Text {
- content: text.clone(),
- style: Style { bold: true, ..Style::default() },
- }],
- }
- } else {
- vec![Span::Text {
- content: text.clone(),
- style: Style { bold: true, ..Style::default() },
- }]
- };
-
- let id_for_kind = if heading_level <= 3 {
- images.last().map(|img| img.id)
- } else {
- None
- };
- lines.push(Line {
- spans: heading_spans,
- kind: LineKind::Heading { level: heading_level, id: id_for_kind },
- });
-}
-```
-
-Then redirect text during heading to `heading_text`. Modify the `Event::Text` arm:
-
-```rust
-Event::Text(t) => {
- if heading_level > 0 {
- heading_text.push_str(&t);
- } else {
- text_buf.push_str(&t);
- }
-}
-```
-
-And make sure `Event::End(TagEnd::Heading)` resets `heading_level = 0` at the end of its arm (already clears `heading_text` via `take`; add `heading_level = 0;` as the last line).
-
-- [ ] **Step 3: Update `build` signature to pass config/theme through**
-
-Change the leading underscores to real bindings:
-
-```rust
-pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
-```
-
-And use `config`/`theme` in the heading arm above.
-
-- [ ] **Step 4: Fix the remaining RenderedDoc return**
-
-Replace `RenderedDoc { lines, headings: vec![], images: vec![] }` with `RenderedDoc { lines, headings, images }`.
-
-- [ ] **Step 5: Run tests**
-
-Run: `cargo test --lib layout::tests`
-Expected: all five layout tests PASS.
-
-- [ ] **Step 6: Commit**
-
-```sh
-git add src/layout.rs src/render.rs
-git commit -m "feat(layout): support headings with image ids"
-```
-
-### Task 1.6: Port blockquotes, lists, task lists, rules, code blocks
-
-**Files:**
-- Modify: `src/layout.rs`
-
-Mirror the state logic from `src/markdown.rs:334-388` and `src/markdown.rs:391-414`. Each block element becomes one or more `Line` entries with the appropriate `LineKind`.
-
-- [ ] **Step 1: Failing tests (one per element)**
-
-Append to `src/layout.rs` tests:
-
-```rust
-#[test]
-fn build_blockquote_carries_depth() {
- let doc = build_plain("> quoted\n");
- assert!(doc.lines.iter().any(|l| matches!(l.kind, LineKind::BlockQuote { depth: 1 })));
-}
-
-#[test]
-fn build_unordered_list_item_has_depth() {
- let doc = build_plain("- a\n- b\n");
- let items: Vec<_> = doc.lines.iter().filter(|l| matches!(l.kind, LineKind::ListItem { depth: 1 })).collect();
- assert_eq!(items.len(), 2);
-}
-
-#[test]
-fn build_rule_emits_horizontal_rule_line() {
- let doc = build_plain("---\n");
- assert!(doc.lines.iter().any(|l| matches!(l.kind, LineKind::HorizontalRule)));
-}
-
-#[test]
-fn build_code_block_emits_codeblock_lines() {
- let doc = build_plain("```rust\nfn main() {}\n```\n");
- let lang_ok = doc.lines.iter().any(|l| matches!(
- &l.kind,
- LineKind::CodeBlock { lang: Some(s) } if s == "rust"
- ));
- assert!(lang_ok);
-}
-```
-
-- [ ] **Step 2: Add state + arms for these elements**
-
-Add to `build`'s state:
-
-```rust
-let mut quote_depth: u8 = 0;
-struct ListState { ordered: bool, counter: u64 }
-let mut list_stack: Vec = Vec::new();
-let mut in_code_block: Option