diff --git a/CHANGELOG.md b/CHANGELOG.md index 575f949..3fc452a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Live reload (`--watch` / `-w`).** `termdown --watch FILE` watches the file + and re-renders the TUI preview whenever it changes on disk — built for a + two-pane "edit on one side, preview on the other" workflow. Scroll position, + Table-of-Contents and metadata fold state, and any active search are + preserved across reloads, and a `[watch]` marker shows in the status bar. + Editor atomic saves (write-temp-then-rename) are handled by watching the + parent directory. Also configurable via `watch = true` in + `~/.config/termdown/config.toml` (default off; TUI only). +- **Heading-image cache.** Rasterized H1–H3 heading PNGs are memoized by + `(level, theme, text)`, so a live reload re-rasterizes only headings whose + text actually changed — a body-only edit re-renders near-instantly. + ## [0.6.1] - 2026-06-08 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 4b9ef9a..b6591b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,6 +435,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "getopts" version = "0.2.24" @@ -516,6 +525,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.12" @@ -544,6 +573,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -649,6 +698,33 @@ dependencies = [ "pxfm", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1090,6 +1166,7 @@ dependencies = [ "font-kit", "image", "libc", + "notify", "pulldown-cmark", "ratatui", "rayon", @@ -1289,7 +1366,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1307,14 +1393,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1323,48 +1426,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 92b33ca..4806882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ base64 = "0.22" crossterm = "0.28" font-kit = "0.14" image = { version = "0.25", default-features = false, features = ["png"] } +notify = "8" pulldown-cmark = "0.13" ratatui = "0.29" rayon = "1" diff --git a/README.md b/README.md index a054a1f..dd8ba23 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ cat notes.md | termdown # Pick a theme; show help termdown --theme light README.md termdown --help + +# Live preview: re-render on every save (edit in your editor, watch here) +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. diff --git a/README_CN.md b/README_CN.md index 42fb306..5e03371 100644 --- a/README_CN.md +++ b/README_CN.md @@ -95,6 +95,9 @@ cat notes.md | termdown # 指定主题;查看帮助 termdown --theme light README.md termdown --help + +# 实时预览:保存即重新渲染(在编辑器里改,在这里看) +termdown --watch notes.md ``` 完整的命令行参数、TUI 快捷键、配置项和已知问题都在 **[使用指南](docs/USAGE_CN.md)**。配置是可选的,位于 `~/.config/termdown/config.toml` —— 全部默认值见 [`config.example.toml`](config.example.toml)。 diff --git a/config.example.toml b/config.example.toml index effc011..43871c4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,6 +24,11 @@ bell = true # (it is still parsed, so it never leaks into body content). metadata = true +# Watch the file and live-reload the preview when it changes on disk. Default +# false. Useful for "edit in your editor, preview in termdown" side by side. +# Only applies to the interactive TUI. CLI override: --watch / -w. +watch = false + [font.heading] # Fonts for image-rendered headings (H1–H3). When unset, termdown uses # platform defaults and falls back to an embedded SourceSerif4 font. Body diff --git a/docs/USAGE.md b/docs/USAGE.md index 9b6acaa..038c0e6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -15,6 +15,7 @@ termdown [OPTIONS] [FILE] | `--cat` | Force non-interactive cat-style output (pipe-friendly). | | `--theme ` | Color theme. Default `auto` detects the terminal background via OSC 11. | | `--no-bell` | Disable the edge-scroll terminal bell (also `bell = false` in config). | +| `-w`, `--watch` | Watch the file and live-reload the TUI on save (also `watch = true` in config). TUI only. | | `-h`, `--help` | Show help. | | `-V`, `--version` | Show version. | @@ -41,6 +42,9 @@ termdown --theme light README.md # Disable the edge-scroll bell termdown --no-bell README.md + +# Live preview: edit in your editor, watch it re-render on save +termdown --watch notes.md ``` ## TUI mode @@ -48,6 +52,17 @@ termdown --no-bell README.md The TUI launches automatically whenever you pass a file and stdout is a real terminal. It requires a file path; stdin input is not supported. +### Live reload (`--watch`) + +`termdown --watch FILE` re-renders the preview whenever the file changes on +disk — ideal for a two-pane workflow: edit the Markdown in your editor (e.g. +vim) on one side, keep `termdown --watch` open on the other. Scroll position, +the open/closed Table of Contents, the metadata fold state, and any active +search are preserved across reloads. A `[watch]` marker appears in the status +bar. Editor atomic saves (write-temp-then-rename) are handled. Rasterized +headings are cached, so a save that only touches body text re-renders almost +instantly; only headings whose text actually changed are re-rasterized. + | Key | Action | |---|---| | `j` / `↓` | Scroll down one line | @@ -91,6 +106,10 @@ bell = true # into body content). metadata = true +# Watch the file and live-reload the preview on save. Default false. TUI only. +# CLI: `--watch` / `-w`. +watch = false + [font.heading] # English heading font (sans-serif recommended) latin = "Inter" diff --git a/docs/USAGE_CN.md b/docs/USAGE_CN.md index 1e1827c..3ff26b7 100644 --- a/docs/USAGE_CN.md +++ b/docs/USAGE_CN.md @@ -15,6 +15,7 @@ termdown [选项] [文件] | `--cat` | 强制使用非交互的 cat 风格输出(管道友好)。 | | `--theme ` | 配色主题。默认 `auto`,通过 OSC 11 检测终端背景色。 | | `--no-bell` | 关闭到顶/到底的终端提示铃(也可在配置中设 `bell = false`)。 | +| `-w`, `--watch` | 监听文件变动并在保存时实时刷新预览(也可在配置中设 `watch = true`)。仅 TUI 模式生效。 | | `-h`, `--help` | 显示帮助。 | | `-V`, `--version` | 显示版本。 | @@ -41,6 +42,9 @@ termdown --theme light README.md # 关闭到顶/到底时的提示铃声 termdown --no-bell README.md + +# 实时预览:在编辑器里改,保存即重新渲染 +termdown --watch notes.md ``` ## TUI 模式 @@ -48,6 +52,14 @@ termdown --no-bell README.md 当传入文件且 stdout 为真实终端时自动进入 TUI。TUI 模式需要指定文件路径,不支持从 stdin 读取。 +### 实时刷新(`--watch`) + +`termdown --watch 文件` 会在文件发生变动时重新渲染预览 —— 非常适合左右分屏的工作流: +一侧用编辑器(如 vim)编辑 Markdown,另一侧开着 `termdown --watch` 实时预览。重新加载 +会保留滚动位置、目录面板的开合状态、元数据折叠状态以及正在进行的搜索;状态栏会显示 +`[watch]` 标记。编辑器的原子保存(先写临时文件再重命名覆盖)也能正确处理。标题图片会被 +缓存,因此只改正文的保存几乎瞬间刷新,只有标题文字真正变化时才会重新栅格化。 + | 按键 | 动作 | |---|---| | `j` / `↓` | 向下滚动一行 | @@ -88,6 +100,10 @@ bell = true # 隐藏元数据(仍会解析,因此不会泄漏进正文)。 metadata = true +# 监听文件变动并在保存时实时刷新预览。默认 false,仅 TUI 模式生效。 +# 命令行可用 `--watch` / `-w` 开启。 +watch = false + [font.heading] # 英文标题字体(推荐无衬线字体) latin = "Inter" diff --git a/src/config.rs b/src/config.rs index 743ddd6..95b2dd6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,13 @@ pub struct Config { /// 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. + /// Unlike `bell` / `metadata`, the default is **off**: `None` (key absent) + /// and `Some(false)` both disable watching; `Some(true)` enables it. CLI + /// `--watch` / `-w` overrides to `Some(true)`. Only meaningful in the + /// interactive TUI — cat mode renders once and stdin has no path to re-read. + pub watch: Option, } /// Color theme selection. `Auto` (the default when the key is absent) detects @@ -142,6 +149,9 @@ mod tests { // default explicitly as `Some(true)` (a missing key parses as `None`, // which is also treated as "show"). assert_eq!(parsed.metadata, Some(true)); + // `watch` defaults to off; the example spells that out as `false` + // (a missing key parses as `None`, which is also treated as "off"). + assert_eq!(parsed.watch, Some(false)); // Font overrides are commented out, so they must parse as unset. assert!(parsed.font.heading.latin.is_none()); assert!(parsed.font.heading.cjk.is_none()); diff --git a/src/main.rs b/src/main.rs index 0d4371e..b7ca6eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ fn main() { println!(" --theme Color theme (default: auto-detect)"); println!(" --cat Force non-interactive cat-style output"); println!(" --no-bell Disable the edge-scroll terminal bell"); + println!(" -w, --watch Watch the file and live-reload on save (TUI only)"); println!(); println!("By default, passing FILE opens it in the interactive TUI."); println!("Piped/redirected stdout and stdin input automatically use cat mode."); @@ -50,6 +51,7 @@ fn main() { let cat_flag = args.iter().any(|a| a == "--cat"); let no_bell_flag = args.iter().any(|a| a == "--no-bell"); + let watch_flag = args.iter().any(|a| a == "--watch" || a == "-w"); check_terminal_support(); @@ -57,6 +59,9 @@ fn main() { if no_bell_flag { config.bell = Some(false); } + if watch_flag { + config.watch = Some(true); + } // Parse --theme flag (takes precedence over config). An unrecognized value // warns and falls through to config/auto instead of being silently @@ -100,9 +105,17 @@ fn main() { let want_tui = !cat_flag && matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty(); + // Watching only makes sense for the interactive TUI: cat mode renders once + // and stdin has no path to re-read. Warn only when the user explicitly + // asked for it via the flag (a config `watch = true` stays silent). + if watch_flag && !want_tui { + eprintln!("termdown: --watch only applies to the interactive TUI; ignoring"); + } + if want_tui { let path = file_arg.expect("want_tui implies a file path"); - tui::run(&path, &config, theme); + let watch = config.watch.unwrap_or(false); + tui::run(&path, &config, theme, watch); return; } diff --git a/src/render.rs b/src/render.rs index f322254..737eccd 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use ab_glyph::{point, Font, GlyphId, GlyphImageFormat, PxScale, ScaleFont}; use base64::Engine; use image::codecs::png::PngEncoder; @@ -268,18 +271,65 @@ fn draw_raster_glyph( // ─── Heading Rendering ────────────────────────────────────────────────────── +/// Process-global cache of rasterized heading PNGs, keyed on +/// `(level, theme, font fingerprint, text)`. Rasterization (glyph drawing + PNG +/// encoding) is the dominant cost of a render, so live-reload re-renders only +/// headings whose text actually changed — everything else is served from here. +/// The configured heading fonts are folded into the key via +/// [`font_fingerprint`]: a normal CLI run has one constant config, but keying on +/// it keeps the global cache correct if the same text is ever rendered under +/// different font configs in one process (e.g. shared test runs, or future +/// library use). Mirrors the `FONT_DATA_CACHE` pattern in `font.rs`. +/// Rasterized heading: PNG bytes plus pixel `(width, height)`. +type RenderedHeading = (Vec, u32, u32); +/// `(level, theme, font fingerprint, text)` — see [`HEADING_CACHE`]. +type HeadingCacheKey = (u8, Theme, String, String); +static HEADING_CACHE: OnceLock>> = OnceLock::new(); + +/// Soft cap so a long editing session that churns through many distinct +/// heading texts can't grow the cache without bound. Each entry is a few KB. +const HEADING_CACHE_CAP: usize = 1024; + +/// Fold the configured heading fonts into a single stable string so the heading +/// cache key distinguishes renders made under different font configs. The unit +/// separator (`\u{1f}`) can't appear in a font name, so the three slots never +/// collide. Resolved platform defaults aren't included: they're constant for +/// the process, so an all-unset config is correctly shared. +fn font_fingerprint(config: &Config) -> String { + let h = &config.font.heading; + format!( + "{}\u{1f}{}\u{1f}{}", + h.latin.as_deref().unwrap_or(""), + h.cjk.as_deref().unwrap_or(""), + h.emoji.as_deref().unwrap_or(""), + ) +} + /// Render heading text to a PNG image. Returns `None` if font loading or /// PNG encoding fails (caller should fall back to ANSI text). /// /// Returns the PNG bytes and the image's pixel dimensions `(width, height)`. /// The TUI needs the pixel height to compute how many terminal rows the image /// occupies (`ceil(height / cell_pixel_height)`); cat mode ignores it. +/// +/// Results are memoized in [`HEADING_CACHE`]; a cache hit skips all +/// rasterization. The lock is held only for the brief get / insert, never +/// across the expensive rasterization, so distinct headings still rasterize in +/// parallel under `layout::build`'s rayon `par_iter`. pub fn render_heading( text: &str, level: u8, config: &Config, theme: Theme, ) -> Option<(Vec, u32, u32)> { + let cache = HEADING_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let key: HeadingCacheKey = (level, theme, font_fingerprint(config), text.to_owned()); + if let Ok(map) = cache.lock() { + if let Some(hit) = map.get(&key) { + return Some(hit.clone()); + } + } + let st = style::heading_style(level, theme); let fonts = font::get_fonts(level, config)?; let scale = PxScale { @@ -315,7 +365,15 @@ pub fn render_heading( PngEncoder::new(&mut buf) .write_image(img.as_raw(), img_w, img_h, image::ExtendedColorType::Rgba8) .ok()?; - Some((buf, img_w, img_h)) + + let result = (buf, img_w, img_h); + if let Ok(mut map) = cache.lock() { + if map.len() >= HEADING_CACHE_CAP { + map.clear(); + } + map.insert(key, result.clone()); + } + Some(result) } // ─── Kitty Graphics Protocol ──────────────────────────────────────────────── @@ -514,3 +572,48 @@ mod kitty_tests { assert_eq!(s, "\x1b_Ga=d,d=A,q=2;\x1b\\"); } } + +#[cfg(test)] +mod heading_cache_tests { + use super::*; + use crate::config::Config; + + #[test] + fn render_heading_is_cached_keyed_by_level_theme_font_text() { + let cfg = Config::default(); + let fp = font_fingerprint(&cfg); + // A unique string so this test never aliases another's cache entry. + let text = "Heading Cache Probe — δοκιμή 测试"; + + let first = render_heading(text, 1, &cfg, Theme::Dark).expect("heading should render"); + + // The (level, theme, font, text) key is now memoized. + { + let map = HEADING_CACHE.get().unwrap().lock().unwrap(); + assert!(map.contains_key(&(1u8, Theme::Dark, fp.clone(), text.to_owned()))); + } + + // A second call returns the identical bytes (served from cache). + let second = render_heading(text, 1, &cfg, Theme::Dark).expect("heading should render"); + assert_eq!(first, second, "cached render must be byte-identical"); + + // Theme is part of the key: a different theme is a distinct entry. + let _light = render_heading(text, 1, &cfg, Theme::Light).expect("heading should render"); + let map = HEADING_CACHE.get().unwrap().lock().unwrap(); + assert!(map.contains_key(&(1u8, Theme::Light, fp, text.to_owned()))); + } + + #[test] + fn font_fingerprint_distinguishes_configs() { + let mut a = Config::default(); + a.font.heading.latin = Some("Inter".to_string()); + let mut b = Config::default(); + b.font.heading.latin = Some("Charter".to_string()); + assert_ne!(font_fingerprint(&a), font_fingerprint(&b)); + // An all-unset config is stable and shared. + assert_eq!( + font_fingerprint(&Config::default()), + font_fingerprint(&Config::default()) + ); + } +} diff --git a/src/theme.rs b/src/theme.rs index 1721076..ca526da 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,6 +1,6 @@ use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Theme { Dark, Light, diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 084ec26..20f3b60 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -82,6 +82,9 @@ struct App { /// that may leave stale terminal cells behind (scroll, toc toggle, doc /// switch, resize). needs_full_redraw: bool, + /// Whether live-reload watching is active for this session. Drives the + /// status-bar indicator; the watcher itself lives in `run_ui`/`event_loop`. + watch: bool, } impl App { @@ -107,6 +110,7 @@ impl App { theme, cell_px_height: 0, needs_full_redraw: true, + watch: false, }; app.push_new_doc(path, doc); app @@ -125,33 +129,7 @@ impl App { /// that point at them) from the global allocator so ids never collide /// across docs in a single session. fn push_new_doc(&mut self, path: String, mut doc: layout::RenderedDoc) -> usize { - let offset = self.next_image_id; - // layout::build() assigns ids starting at 1; shift each by (offset - 1) - // so the first image of this doc becomes `offset`. - let mut id_map: std::collections::HashMap = std::collections::HashMap::new(); - for img in &mut doc.images { - let new_id = offset + (img.id - 1); - id_map.insert(img.id, new_id); - img.id = new_id; - } - if let Some(max) = doc.images.iter().map(|i| i.id).max() { - self.next_image_id = max + 1; - } - // Patch Span::HeadingImage and LineKind::Heading { id } references. - for line in &mut doc.lines { - for span in &mut line.spans { - if let layout::Span::HeadingImage { id, .. } = span { - if let Some(&new) = id_map.get(id) { - *id = new; - } - } - } - if let layout::LineKind::Heading { id: Some(hid), .. } = &mut line.kind { - if let Some(&new) = id_map.get(hid) { - *hid = new; - } - } - } + renumber_doc_images(&mut doc, &mut self.next_image_id); let (width, height) = self.term_size; let viewport = Viewport::new(height, width); let mut entry = DocEntry { @@ -224,7 +202,217 @@ impl App { } } -pub fn run(path: &str, config: &Config, theme: Theme) { +/// Renumber a freshly-built doc's image ids from the global allocator so they +/// never collide with ids already loaded in the session, patching the +/// `Span::HeadingImage` and `LineKind::Heading { id }` references to match. +/// `layout::build` always assigns ids starting at 1; this shifts each so the +/// first image becomes `*next_image_id`, then advances `*next_image_id` past +/// the highest id assigned. Shared by initial/link loads (`push_new_doc`) and +/// live reload (`reload_active_doc`). +fn renumber_doc_images(doc: &mut layout::RenderedDoc, next_image_id: &mut u32) { + let offset = *next_image_id; + let mut id_map: HashMap = HashMap::new(); + for img in &mut doc.images { + let new_id = offset + (img.id - 1); + id_map.insert(img.id, new_id); + img.id = new_id; + } + if let Some(max) = doc.images.iter().map(|i| i.id).max() { + *next_image_id = max + 1; + } + for line in &mut doc.lines { + for span in &mut line.spans { + if let layout::Span::HeadingImage { id, .. } = span { + if let Some(&new) = id_map.get(id) { + *id = new; + } + } + } + if let layout::LineKind::Heading { id: Some(hid), .. } = &mut line.kind { + if let Some(&new) = id_map.get(hid) { + *hid = new; + } + } + } +} + +/// Canonicalize `path` and derive its parent directory, applying the fallbacks +/// the watcher relies on: an unresolvable path is used verbatim (it may not +/// exist yet, e.g. mid atomic-rename), and a missing/empty parent becomes `.`. +fn canonical_target_and_dir(path: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let target = std::fs::canonicalize(path).unwrap_or_else(|_| std::path::PathBuf::from(path)); + let dir = target + .parent() + .filter(|d| !d.as_os_str().is_empty()) + .map(|d| d.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + (target, dir) +} + +/// Live-reload file watcher whose target follows the active document. +/// +/// Watches a **directory** (non-recursively) rather than the file itself: +/// editors commonly save by writing a temp file and renaming it over the +/// target, which swaps the inode and would silently break a watch bound to the +/// original file. The callback filters directory events down to the current +/// target and pings the channel `rx` drains. The target lives behind an +/// `Arc>` shared with the callback thread so [`retarget`](Self::retarget) +/// can re-point it (and move the directory watch when needed) as the user +/// follows `.md` links or navigates back/forward — keeping live reload bound to +/// whatever doc is on screen. +struct LiveWatch { + watcher: notify::RecommendedWatcher, + rx: std::sync::mpsc::Receiver<()>, + /// The file the callback currently matches against. Shared with the + /// callback thread; updated by [`retarget`](Self::retarget). + target: std::sync::Arc>, + /// The directory currently watched (the target's parent). Tracked so + /// `retarget` only re-issues watch/unwatch when the directory changes. + watched_dir: std::path::PathBuf, +} + +impl LiveWatch { + /// Point the watch at `path` (the now-active document): update the shared + /// target the callback filters on and, when `path` lives in a different + /// directory, move the directory watch too. Best effort — a failed re-watch + /// restores the previous watch rather than aborting the session. Finally + /// drains any events already queued for the old target so a navigation + /// doesn't trigger a spurious reload of the freshly-loaded doc. + fn retarget(&mut self, path: &str) { + use notify::{RecursiveMode, Watcher}; + let (new_target, new_dir) = canonical_target_and_dir(path); + if let Ok(mut t) = self.target.lock() { + *t = new_target; + } + if new_dir != self.watched_dir { + let _ = self.watcher.unwatch(&self.watched_dir); + match self.watcher.watch(&new_dir, RecursiveMode::NonRecursive) { + Ok(()) => self.watched_dir = new_dir, + // Couldn't watch the new dir; try to restore the previous watch + // so we at least keep reloading the old location. + Err(_) => { + let _ = self + .watcher + .watch(&self.watched_dir, RecursiveMode::NonRecursive); + } + } + } + while self.rx.try_recv().is_ok() {} + } +} + +/// Spawn a [`LiveWatch`] for `path`'s live reload. See the struct docs for why +/// it watches the parent directory and how the target follows the active doc. +fn setup_watcher(path: &str) -> notify::Result { + use notify::{RecursiveMode, Watcher}; + + let (tx, rx) = std::sync::mpsc::channel(); + let (target_path, dir) = canonical_target_and_dir(path); + let target = std::sync::Arc::new(std::sync::Mutex::new(target_path)); + let cb_target = std::sync::Arc::clone(&target); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + // Ignore events that don't change file contents. `reload_active_doc` + // reads the target via `read_to_string`; on Linux/inotify that read + // surfaces as `Access(Open)` (and, when atime is bumped, `Modify(Metadata)`) + // for the very file we just loaded. Reacting to either would queue another + // reload and spin a self-sustaining loop. Everything that marks a real + // content change — `Create`, `Modify(Data/Name/Any)`, `Remove`, and the + // coarse `Any`/`Other` kinds some backends (e.g. macOS FSEvents) emit — + // still reloads. + use notify::event::{EventKind, ModifyKind}; + if matches!( + event.kind, + EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) + ) { + return; + } + // Snapshot the current target; it can change under us when the user + // navigates to another doc (see `LiveWatch::retarget`). + let want = match cb_target.lock() { + Ok(t) => t.clone(), + Err(_) => return, + }; + let hit = event.paths.iter().any(|p| { + // Prefer canonical comparison; during a rename the path may not + // resolve, so fall back to matching the file name. + std::fs::canonicalize(p) + .map(|c| c == want) + .unwrap_or_else(|_| p.file_name() == want.file_name()) + }); + if hit { + let _ = tx.send(()); + } + } + })?; + + watcher.watch(&dir, RecursiveMode::NonRecursive)?; + Ok(LiveWatch { + watcher, + rx, + target, + watched_dir: dir, + }) +} + +/// Re-read the active doc's file and rebuild it in place, preserving scroll +/// position, the ToC / metadata fold state, and any active search. On a +/// transient read error — e.g. the brief window mid atomic-rename when the file +/// is absent — the current render is kept and the follow-up event retries. +fn reload_active_doc(app: &mut App) { + let path = app.active().path.clone(); + let source = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => return, + }; + + let mut new_doc = layout::build(&source, &app.config, app.theme); + refine_image_rows(&mut new_doc, app.cell_px_height); + renumber_doc_images(&mut new_doc, &mut app.next_image_id); + + let show_metadata = app.config.metadata.unwrap_or(true); + let expanded = app.active().metadata_expanded; + // Anchor: where the viewport top sits now, so we can land near the same + // content after the line set shifts. + let anchor_logical = app.active().viewport.top_logical(); + // Rebuild the match set against the new content if a search is active, + // keeping focus near the previously-current match. + let new_search = app + .active() + .search + .as_ref() + .map(|s| s.rebuilt_for(&new_doc)); + + { + let active = app.active_mut(); + active.doc = new_doc; + active.search = new_search; + active.viewport.invalidate_wrap(); + active + .viewport + .ensure_wrap(&active.doc, show_metadata, expanded); + if let Some(logical) = anchor_logical { + if let Some(vi) = active.viewport.visual_line_for_logical(logical) { + active.viewport.top = vi; + } + } + // Clamp into range for the (possibly shorter) new doc. + active.viewport.scroll_by(0); + } + + // Evict the old doc's images + placements from the terminal so cached PNG + // data doesn't accumulate across reloads; the `needs_full_redraw` branch + // then re-transmits and re-places the new doc's images. + { + let mut out = io::stdout().lock(); + let _ = app.images.cleanup(&mut out); + let _ = out.flush(); + } + app.needs_full_redraw = true; +} + +pub fn run(path: &str, config: &Config, theme: Theme, watch: bool) { let source = match std::fs::read_to_string(path) { Ok(s) => s, Err(e) => { @@ -234,13 +422,19 @@ pub fn run(path: &str, config: &Config, theme: Theme) { }; let doc = layout::build(&source, config, theme); - if let Err(e) = run_ui(doc, path.to_string(), config.clone(), theme) { + if let Err(e) = run_ui(doc, path.to_string(), config.clone(), theme, watch) { eprintln!("termdown: tui error: {e}"); std::process::exit(1); } } -fn run_ui(doc: layout::RenderedDoc, path: String, config: Config, theme: Theme) -> io::Result<()> { +fn run_ui( + doc: layout::RenderedDoc, + path: String, + config: Config, + theme: Theme, + watch: bool, +) -> io::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); crossterm::execute!(stdout, EnterAlternateScreen)?; @@ -249,6 +443,7 @@ fn run_ui(doc: layout::RenderedDoc, path: String, config: Config, theme: Theme) let size = terminal.size()?; let body_height = size.height.saturating_sub(1); let mut app = App::new_with_initial_doc(path, doc, body_height, size.width, config, theme); + app.watch = watch; // Query the real terminal cell pixel height up-front so heading image // `rows` estimates are accurate from the first frame onward. If the OS // doesn't report it, `cell_px_height` stays 0 and we keep per-level @@ -268,7 +463,22 @@ fn run_ui(doc: layout::RenderedDoc, path: String, config: Config, theme: Theme) out.flush()?; } - let result = event_loop(&mut terminal, &mut app); + // Start the file watcher (live reload). The `LiveWatch` owns the + // `notify::Watcher`, so holding it for the whole event loop keeps the watch + // alive (dropping a `notify::Watcher` stops it). A setup failure disables + // watching rather than aborting the session. + let mut live_watch: Option = None; + if watch { + match setup_watcher(&app.active().path) { + Ok(lw) => live_watch = Some(lw), + Err(e) => { + eprintln!("termdown: file watch disabled: {e}"); + app.watch = false; + } + } + } + + let result = event_loop(&mut terminal, &mut app, live_watch); { let mut out = io::stdout().lock(); @@ -282,8 +492,26 @@ fn run_ui(doc: layout::RenderedDoc, path: String, config: Config, theme: Theme) result } -fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Result<()> { +fn event_loop( + terminal: &mut Terminal, + app: &mut App, + mut live_watch: Option, +) -> io::Result<()> { loop { + // Live reload: drain any pending file-change notifications, coalescing + // the burst of events a single save produces into one reload. Checked + // every iteration (not just when a key arrives) so a save with no + // keyboard activity still triggers within one ~50ms tick. + if let Some(lw) = &live_watch { + let mut changed = false; + while lw.rx.try_recv().is_ok() { + changed = true; + } + if changed { + reload_active_doc(app); + } + } + // Sync viewport dimensions to the current terminal size. Handles // initial startup and terminal resizes. Any change invalidates the // wrap cache (`ensure_wrap` re-wraps when `self.width != cache_width`). @@ -346,6 +574,7 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu app.needs_full_redraw = true; continue; } + let cursor_before = app.cursor; match &mut app.mode { Mode::Normal => handle_normal_key(app, &ev)?, Mode::Search { .. } => handle_search_key(app, ev)?, @@ -355,6 +584,13 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu if app.should_quit { return Ok(()); } + // If navigation (link / back / forward) switched the active doc, + // re-point the watcher so live reload follows the doc on screen. + if app.cursor != cursor_before { + if let Some(lw) = &mut live_watch { + lw.retarget(&app.active().path); + } + } // Scroll / mode-change / search events rely on ratatui's cell // diff + `images.sync()` for correctness — no full clear. Only // the handlers that actually need a clear (resize, toc toggle, @@ -1249,7 +1485,13 @@ fn render_status_bar(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, ap label } Mode::Help => String::from(" Help — press ? / Esc / q to close "), - Mode::Normal => String::new(), + Mode::Normal => { + if app.watch { + String::from(" [watch] ") + } else { + String::new() + } + } }; let left_w = left_text.width(); // Guarantee at least one blank column between left and right when both @@ -1488,6 +1730,78 @@ mod open_link_tests { } } +#[cfg(test)] +mod renumber_tests { + use super::*; + use crate::layout::{Line, LineKind, RenderedDoc, Span}; + + fn heading_doc() -> RenderedDoc { + // Two H1/H2 images with ids 1 and 2 as layout::build would assign, + // plus matching Span::HeadingImage / LineKind::Heading references. + let line = |id: u32, level: u8| Line { + spans: vec![Span::HeadingImage { id, rows: 3 }], + kind: LineKind::Heading { + level, + id: Some(id), + }, + }; + RenderedDoc { + lines: vec![line(1, 1), line(2, 2)], + headings: vec![], + images: vec![ + 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, + }, + ], + metadata: None, + } + } + + #[test] + fn renumber_shifts_ids_and_patches_refs() { + let mut doc = heading_doc(); + let mut next = 5; + renumber_doc_images(&mut doc, &mut next); + + // ids 1,2 → 5,6; allocator advances past the highest. + assert_eq!(doc.images[0].id, 5); + assert_eq!(doc.images[1].id, 6); + assert_eq!(next, 7); + + // Span and LineKind references are remapped in lockstep. + for (line, expected) in doc.lines.iter().zip([5u32, 6]) { + assert!(matches!(line.spans[0], Span::HeadingImage { id, .. } if id == expected)); + assert!(matches!(line.kind, LineKind::Heading { id: Some(id), .. } if id == expected)); + } + } + + #[test] + fn renumber_empty_doc_leaves_allocator_untouched() { + let mut doc = RenderedDoc { + lines: vec![], + headings: vec![], + images: vec![], + metadata: None, + }; + let mut next = 9; + renumber_doc_images(&mut doc, &mut next); + assert_eq!(next, 9); + } +} + #[cfg(test)] mod help_popup_tests { use super::{help_popup_intrinsic_size, help_popup_rect}; diff --git a/src/tui/search.rs b/src/tui/search.rs index b96a570..27fce9c 100644 --- a/src/tui/search.rs +++ b/src/tui/search.rs @@ -12,6 +12,9 @@ pub struct MatchPos { } pub struct SearchState { + /// The committed query string, retained so the match set can be rebuilt + /// against new content after a live reload. + pub query: String, pub matches: Vec, pub current: Option, } @@ -19,10 +22,45 @@ pub struct SearchState { impl SearchState { pub fn new(query: &str, doc: &RenderedDoc) -> Self { Self { + query: query.to_string(), matches: find_all(query, doc), current: None, } } + + /// Rebuild the match set against `doc` from the retained query — used after + /// a live reload — keeping focus on the match nearest the previously-current + /// one so `n` / `N` resume roughly where the user was rather than snapping + /// back to the top. + pub fn rebuilt_for(&self, doc: &RenderedDoc) -> Self { + let matches = find_all(&self.query, doc); + let current = self + .current + .and_then(|i| self.matches.get(i)) + .and_then(|prev| nearest_match_index(&matches, prev)); + Self { + query: self.query.clone(), + matches, + current, + } + } +} + +/// Index of the first match at or after `anchor` in document order (line, then +/// byte offset), falling back to the last match when `anchor` sits past the end. +/// `None` only when there are no matches. +fn nearest_match_index(matches: &[MatchPos], anchor: &MatchPos) -> Option { + if matches.is_empty() { + return None; + } + let key = |m: &MatchPos| (m.line_index, m.byte_range.start); + let a = key(anchor); + Some( + matches + .iter() + .position(|m| key(m) >= a) + .unwrap_or(matches.len() - 1), + ) } /// Scan the document and return every match position (line index + byte range). @@ -156,4 +194,40 @@ mod tests { assert_eq!(m.len(), 1); assert_eq!(m[0].byte_range, 0..5); } + + #[test] + fn rebuilt_for_keeps_focus_near_previous_current() { + // Three matches; focus the middle one, then reload against a doc where + // a line was inserted above so the same text shifts down by one line. + let before = doc_with(&["foo", "bar", "foo", "baz", "foo"]); + let mut st = SearchState::new("foo", &before); + assert_eq!(st.matches.len(), 3); + st.current = Some(1); // the "foo" on line 2 + + let after = doc_with(&["NEW", "foo", "bar", "foo", "baz", "foo"]); + let rebuilt = st.rebuilt_for(&after); + assert_eq!(rebuilt.matches.len(), 3); + // The previously-current match now lives on line 3; focus follows it. + let cur = rebuilt.current.expect("focus should be retained"); + assert_eq!(rebuilt.matches[cur].line_index, 3); + } + + #[test] + fn rebuilt_for_drops_focus_when_matches_disappear() { + let before = doc_with(&["foo", "foo"]); + let mut st = SearchState::new("foo", &before); + st.current = Some(1); + let after = doc_with(&["nothing here"]); + let rebuilt = st.rebuilt_for(&after); + assert!(rebuilt.matches.is_empty()); + assert_eq!(rebuilt.current, None); + } + + #[test] + fn rebuilt_for_with_no_prior_focus_stays_unfocused() { + let before = doc_with(&["foo", "foo"]); + let st = SearchState::new("foo", &before); // current is None + let rebuilt = st.rebuilt_for(&before); + assert_eq!(rebuilt.current, None); + } } diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs index b7c030a..b5feb28 100644 --- a/src/tui/viewport.rs +++ b/src/tui/viewport.rs @@ -126,6 +126,17 @@ impl Viewport { .position(|vl| vl.logical_index == logical) } + /// Logical index of the line currently at the top of the viewport, or + /// `None` when that row is a metadata/sentinel row ([`NO_LOGICAL`]). Pairs + /// with [`visual_line_for_logical`](Self::visual_line_for_logical) to + /// restore the scroll position after the document is rebuilt on reload. + pub fn top_logical(&self) -> Option { + self.visual_lines + .get(self.top) + .map(|vl| vl.logical_index) + .filter(|&l| l != NO_LOGICAL) + } + /// Move the viewport to the next heading line after `after_visual`. /// No-op if no heading exists further in the document. pub fn jump_to_next_heading(&mut self, doc: &RenderedDoc, after_visual: usize) { @@ -406,6 +417,26 @@ mod tests { assert_eq!(vp.total_visual_lines(), 0); } + #[test] + fn top_logical_round_trips_with_visual_line_for_logical() { + let doc = make_doc(10); + let mut vp = Viewport::new(4, 40); + vp.ensure_wrap(&doc, false, false); + // Short lines: one visual row per logical line, so top == logical here. + vp.scroll_by(3); + let logical = vp.top_logical().expect("top row maps to a logical line"); + assert_eq!(logical, 3); + assert_eq!(vp.visual_line_for_logical(logical), Some(vp.top)); + } + + #[test] + fn top_logical_is_none_on_empty_doc() { + let doc = make_doc(0); + let mut vp = Viewport::new(4, 40); + vp.ensure_wrap(&doc, false, false); + assert_eq!(vp.top_logical(), None); + } + #[test] fn height_exceeds_total_shows_all() { let doc = make_doc(3);