From e5af27240de0e5441903deafcbe588ffb9ce0159 Mon Sep 17 00:00:00 2001 From: Xiaofei Zhang Date: Thu, 24 Sep 2026 18:20:15 +0800 Subject: [PATCH 1/2] feat(images): render local images in Kitty TUI Add aspect-aware Markdown image blocks, background decoding, bounded caching, and Kitty graphics rendering. Preserve readable fallbacks for unsupported images and non-Kitty terminals. --- ARCHITECTURE.md | 31 +- CHANGELOG.md | 7 + Cargo.lock | 439 ++++++++++++++++++++++- Cargo.toml | 2 + README.md | 23 ++ TESTING.md | 23 ++ src/app/code_blocks.rs | 12 +- src/app/content.rs | 15 +- src/app/mod.rs | 8 +- src/app/theme_picker.rs | 20 +- src/image_runtime.rs | 540 +++++++++++++++++++++++++++++ src/main.rs | 47 +-- src/markdown/footnotes.rs | 57 ++- src/markdown/images.rs | 206 +++++++++++ src/markdown/mod.rs | 235 +++++++++++-- src/markdown/spans.rs | 8 + src/markdown/table_layout.rs | 22 +- src/markdown/tables.rs | 107 +++++- src/render/content.rs | 1 + src/runtime/mod.rs | 4 + src/tests/fixtures/kitty-image.png | Bin 0 -> 73 bytes src/tests/markdown_images.rs | 159 +++++++++ src/tests/mod.rs | 1 + src/tests/theme.rs | 43 +++ 24 files changed, 1926 insertions(+), 84 deletions(-) create mode 100644 src/image_runtime.rs create mode 100644 src/markdown/images.rs create mode 100644 src/tests/fixtures/kitty-image.png create mode 100644 src/tests/markdown_images.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f336b77..4cf9910 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -35,6 +35,7 @@ - `syntax.rs` : syntect code highlighting and language resolution - `fences.rs` : code fence normalization (nested fences, tilde fences) - `links.rs` : link span detection and construction + - `images.rs` : Markdown image block metadata, aspect-aware height calculation, and text placeholder layout - `tables.rs` : table construction, event handling, and rendering - `table_layout.rs` : table cell sizing, wrapping, and alignment algorithms - `latex.rs` : LaTeX-to-Unicode conversion: `unicodeit` + postprocessing for `\frac`, `\sqrt`, `^{}`, `_{}` @@ -87,6 +88,12 @@ - ANSI/plain format resolution and line wrapping - ratatui Style-to-ANSI escape code serialization +- `src/image_runtime.rs` + - Kitty-only capability detection and terminal protocol setup + - local image path resolution, safety limits, and decode cache keys + - background image decode/resize/encode worker and result polling + - scroll-aware Kitty `SlicedImage` placement inside the TUI content area + - `src/terminal.rs` - raw mode / alternate screen lifecycle - terminal restore guarantees @@ -105,6 +112,8 @@ - `markdown_blocks.rs` : headings, TOC, blockquotes, code blocks, rules - `markdown_embedded.rs` : LaTeX and Mermaid rendering tests - `markdown_links.rs` : link detection and search highlight tests + - `markdown_images.rs` : image block parsing, context prefixes, table/footnote fallback, and source-line mapping + - `image_runtime.rs` : Kitty detection, remote-source rejection, safe decode, and TestBackend placeholder rendering - `toc.rs` : TOC extraction, normalization, active section tracking, and display level tests - `editor.rs` : editor detection and classification - `render.rs` : table and code block border alignment @@ -121,14 +130,15 @@ - a file argument, or - `stdin`, or - the file picker if no input is provided interactively. -3. `markdown/` parses the source into rendered lines + TOC. -4. If `--inline` is active, `inline.rs` writes lines to stdout and exits. -5. `App` stores the state and caches. -6. `runtime.rs` runs the event loop: +3. `markdown/` parses the source into rendered lines, TOC, link spans, code blocks, and image block metadata. Local image headers are probed for dimensions so each image reserves an aspect-aware text placeholder area before full decoding begins. +4. If `--inline` is active, `inline.rs` writes the text representation to stdout and exits; Kitty image sequences are intentionally excluded from this path. +5. `App` stores the state, image blocks, and the Kitty image runtime. +6. After terminal initialization, `image_runtime.rs` detects Kitty only when the document contains renderable local images. The runtime queues visible images to its background worker and polls completed protocol states from the main event loop. +7. `runtime.rs` runs the event loop: - processes the pending picker queue and spawns the loading thread - - polls picker loading, installing results when ready + - polls picker loading and image worker results, installing them when ready - handles input events through mode-aware branching -7. `render/` draws each frame from `App`. +8. `render/` draws each frame from `App`: text first, visible Kitty image slices second, and scrollbar/chrome last. ## Application modes @@ -147,8 +157,13 @@ - document reload / open: - source changes - - rendered lines and TOC are rebuilt - - caches are refreshed + - rendered lines, TOC, and image block metadata are rebuilt + - caches are refreshed and image base paths are updated + +- image load: + - visible local images are fingerprinted by path, file metadata, and target cell size + - changed or newly visible images are decoded and Kitty-encoded in the background + - failed, remote, SVG, oversized, and non-Kitty images retain the text placeholder - resize: - effective render width is recomputed diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d77559..3d57a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## Unreleased + +### Added + +- render local PNG, JPEG, GIF, and WebP images in the interactive Kitty TUI with background decoding and scroll-aware placement +- retain readable image placeholders for unsupported terminals, invalid images, tables, footnotes, and `--inline` output + ## [[1.28.2](https://github.com/RivoLink/leaf/releases/tag/1.28.2)] - 2026-09-14 ### Docs diff --git a/Cargo.lock b/Cargo.lock index 7a766a4..9c4745d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bincode" version = "1.3.3" @@ -151,6 +161,18 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -177,6 +199,26 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" @@ -255,6 +297,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -321,7 +369,7 @@ dependencies = [ "filedescriptor", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -498,6 +546,15 @@ dependencies = [ "regex", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -574,6 +631,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -682,6 +745,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -906,6 +979,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfb5a63225620b59df34a235d1fb56ff7b766909c3212a8ff927511a22b181d" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -939,6 +1022,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1052,9 +1163,11 @@ dependencies = [ "anyhow", "crossterm", "fs2", + "image", "mmdflux", "pulldown-cmark", "ratatui", + "ratatui-image", "reqwest", "semver", "serde", @@ -1093,6 +1206,12 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1229,6 +1348,16 @@ dependencies = [ "xterm-color", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.29.0" @@ -1285,6 +1414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1339,6 +1469,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "palette" version = "0.7.7" @@ -1346,6 +1491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", + "bytemuck", "libm", "palette_derive", "palette_math", @@ -1508,6 +1654,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1576,6 +1735,36 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quantette" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5d37e94c17b8870a5b936001d2845a6782a22ebdb1706c84294eca777f6729" +dependencies = [ + "bitvec", + "bytemuck", + "libm", + "num-traits", + "ordered-float 5.5.0", + "palette", + "rand 0.10.3", + "rand_xoshiro", + "ref-cast", + "wide", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -1652,12 +1841,20 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1667,10 +1864,29 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1686,6 +1902,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1696,6 +1915,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xoshiro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -1746,6 +1980,24 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "ratatui-image" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9593d10c61ec2007f429d1fafc4f4a19fecdbae4d06b531a2d505227fb82f5ea" +dependencies = [ + "base64-simd", + "flate2", + "icy_sixel", + "image", + "rand 0.8.6", + "ratatui", + "rustix 0.38.44", + "self_cell", + "thiserror 1.0.69", + "windows", +] + [[package]] name = "ratatui-macros" version = "0.7.2" @@ -1806,6 +2058,26 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "regex" version = "1.12.3" @@ -1904,6 +2176,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1913,7 +2198,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1964,6 +2249,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1979,6 +2273,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -2207,6 +2507,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2245,6 +2556,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "termina" version = "0.3.3" @@ -2253,7 +2570,7 @@ checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ "bitflags 2.13.1", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "windows-sys 0.61.2", ] @@ -2300,7 +2617,7 @@ dependencies = [ "nix", "num-derive", "num-traits", - "ordered-float", + "ordered-float 4.6.0", "pest", "pest_derive", "phf", @@ -2700,6 +3017,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vtparse" version = "0.6.2" @@ -2870,6 +3193,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2912,7 +3241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" dependencies = [ "log", - "ordered-float", + "ordered-float 4.6.0", "strsim", "thiserror 1.0.69", "wezterm-dynamic-derive", @@ -2942,6 +3271,16 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2973,12 +3312,76 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3164,6 +3567,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xterm-color" version = "1.0.2" @@ -3278,3 +3690,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 1c4e49f..9f583c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" [dependencies] ratatui = "0.30" crossterm = "0.29" +ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] } +image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } pulldown-cmark = "0.12" syntect = { version = "5.2", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } anyhow = "1.0" diff --git a/README.md b/README.md index fbce2c1..e4b31ef 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,20 @@ claude "explain Rust lifetimes" | leaf cat TESTING.md | leaf ``` +## Inline Images (Kitty TUI) + +When running the interactive TUI in [Kitty](https://sw.kovidgoyal.net/kitty/), Leaf renders local Markdown images with Kitty's graphics protocol: + +```markdown +![Architecture diagram](images/architecture.png) +``` + +Supported formats are PNG, JPEG, GIF, and WebP. Animated images display their first frame. Relative paths are resolved from the Markdown file's directory; files read from stdin are resolved from the current working directory. + +Images are decoded and encoded in a background worker, then rendered inside an aspect-aware document block so scrolling, search, line numbers, TOC resizing, reloads, and terminal resizing keep working. Local image dimensions determine the reserved height, clamped to 12–40 rows; unavailable dimensions use the 12-row minimum. Missing, oversized, remote, SVG, and unsupported images keep a readable image placeholder with their alt text and source. + +This first stage intentionally supports only Kitty in the interactive TUI. Other terminals and `--inline` output show the same text placeholder and never receive Kitty control sequences. + ## Inline Mode Render Markdown directly to **stdout** without the interactive TUI: @@ -349,6 +363,7 @@ See [`gruvbox.toml`](gruvbox.toml) for a complete example with all available col - **Editor integration** : *Open the current file in your preferred editor*. - **Frontmatter support** : *YAML frontmatter rendered as a table (horizontal or vertical based on key count)*. - **Rich Markdown rendering** : *Tables, lists, blockquotes, rules, bold, italic, and strikethrough*. +- **Kitty images** : *Local PNG, JPEG, GIF, and WebP images render in the interactive Kitty TUI with scroll-aware placeholders*. - **GitHub extras** : *Alert callouts, task list checkboxes, and `==mark==` text highlighting*. - **Inline HTML tags** : *Common inline formatting tags (``, ``, ``, ...) rendered with proper styling*. - **Extra file types** : *Open any file; code files get syntax highlighting, text files render as Markdown*. @@ -378,6 +393,14 @@ leaf --watch notes.md ## Troubleshooting +### Images remain placeholders + +- Confirm the interactive TUI is running in Kitty, not `--inline` mode. +- Use a local PNG, JPEG, GIF, or WebP path; HTTP(S), data URLs, and SVG are not downloaded or rendered. +- Relative image paths are resolved from the Markdown file's directory. +- Images larger than 20 MiB, wider or taller than 16,384 pixels, or requiring more than 128 MiB to decode are rejected. +- Images in tables and footnotes remain text placeholders in this first stage. + ### Windows: missing Visual C++ runtime If `leaf.exe` does not start on Windows or reports a missing MSVC runtime, install the latest supported Microsoft Visual C++ Redistributable from Microsoft Learn: diff --git a/TESTING.md b/TESTING.md index b70db8f..5024a01 100644 --- a/TESTING.md +++ b/TESTING.md @@ -51,6 +51,19 @@ Confirm these render correctly: - inline math formulas with `$...$` - display math blocks with `$$...$$` +### Kitty Images + +Run these checks in Kitty with `leaf TESTING.md`: + +- confirm the local preview image renders inside its bordered block +- scroll down until the image is partially visible, then scroll through and past it +- toggle line numbers and confirm the image shifts with the text gutter +- toggle the TOC and resize the terminal; the image should reflow without stale pixels +- reload the document and confirm the image remains stable +- confirm the missing-image example remains a readable placeholder +- run the same document in a non-Kitty terminal and confirm no Kitty control sequences appear +- run `leaf --inline TESTING.md` and confirm images remain text placeholders in both ANSI and plain output + ### Navigation And Search Use these keys while viewing the fixture: @@ -141,6 +154,16 @@ This line mixes **bold**, *italic*, ~~strikethrough~~, and `inline code` in a si This paragraph also includes a [link to Rust](https://www.rust-lang.org/) so link styling and the leading link marker can be checked. +### Images + +The local image should render in Kitty and remain a bordered placeholder elsewhere. + +![Leaf preview](images/preview.png) + +The missing image should keep a readable fallback with its alt text and source. + +![Missing image](images/does-not-exist.png) + ### Blockquote > This is a blockquote with *emphasis* and `inline code`. diff --git a/src/app/code_blocks.rs b/src/app/code_blocks.rs index 290faaf..a0e9d6a 100644 --- a/src/app/code_blocks.rs +++ b/src/app/code_blocks.rs @@ -1,11 +1,21 @@ use super::{App, CodeBlockFlash}; -use crate::{clipboard::copy_to_clipboard, markdown::CodeBlockInfo}; +use crate::{ + clipboard::copy_to_clipboard, + markdown::{CodeBlockInfo, ImageBlockInfo}, +}; impl App { pub(crate) fn set_code_blocks(&mut self, code_blocks: Vec) { self.code_blocks = code_blocks; } + pub(crate) fn set_image_blocks(&mut self, image_blocks: Vec) { + self.image_runtime.sync_blocks(&image_blocks); + self.image_blocks = image_blocks; + self.store_current_theme_preview(); + self.try_enable_kitty_images(); + } + pub(crate) fn is_code_select_mode(&self) -> bool { self.code_select.is_some() } diff --git a/src/app/content.rs b/src/app/content.rs index 103c0ac..8148aaa 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -1,6 +1,6 @@ use super::App; use crate::markdown::{ - hash_file_contents, hash_str, parse_markdown_with_width, read_file_state, ParseResult, + hash_file_contents, hash_str, parse_markdown_with_width_and_path, read_file_state, ParseResult, }; use std::{ path::PathBuf, @@ -34,6 +34,7 @@ impl App { line_number_map, source_line_map, code_blocks, + image_blocks, } = parsed; self.plain_lines = build_searchable_lines(&lines) @@ -47,6 +48,7 @@ impl App { self.link_spans_by_line = super::links::link_spans_to_map(link_spans); self.hovered_link = None; self.set_code_blocks(code_blocks); + self.set_image_blocks(image_blocks); self.code_select = None; self.set_line_maps(line_number_map, source_line_map); self.refresh_static_caches(); @@ -95,7 +97,7 @@ impl App { self.file_mode = is_code_file; let theme = current_syntect_theme(themes); let at = app_theme(); - let parsed = parse_markdown_with_width( + let parsed = parse_markdown_with_width_and_path( &src, ss, theme, @@ -103,6 +105,7 @@ impl App { &at.markdown, self.file_mode, self.code_line_numbers, + Some(&path), ); let first_load = self.filepath.is_none(); @@ -111,6 +114,7 @@ impl App { if let Some(n) = self.file_history_length.filter(|n| *n > 0) { super::history::record_open(path.clone(), n as usize); } + self.set_image_document_path(Some(&path)); self.filepath = Some(path); if first_load && self.watch_from_config { self.watch = true; @@ -128,7 +132,7 @@ impl App { self.reset_search_state(); self.clear_active_goto_line(); self.invalidate_theme_preview_cache(); - self.store_current_theme_preview_from(&parsed.lines, &parsed.toc); + self.store_current_theme_preview_from(&parsed.lines, &parsed.toc, &parsed.image_blocks); self.replace_content(parsed); self.clear_toc_scroll_state(); true @@ -138,7 +142,7 @@ impl App { let theme = current_syntect_theme(themes); let at = app_theme(); let old_total = self.total(); - let parsed = parse_markdown_with_width( + let parsed = parse_markdown_with_width_and_path( &self.source, ss, theme, @@ -146,6 +150,7 @@ impl App { &at.markdown, self.file_mode, self.code_line_numbers, + self.filepath.as_deref(), ); let new_total = parsed.lines.len(); @@ -156,7 +161,7 @@ impl App { } self.invalidate_theme_preview_cache(); - self.store_current_theme_preview_from(&parsed.lines, &parsed.toc); + self.store_current_theme_preview_from(&parsed.lines, &parsed.toc, &parsed.image_blocks); self.replace_content(parsed); self.goto_line.target = None; self.goto_line.error = false; diff --git a/src/app/mod.rs b/src/app/mod.rs index 3637e72..a354eb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,9 @@ use crate::{ + image_runtime::ImageRuntime, markdown::{ build_searchable_lines, toc::{toc_levels, TocEntry}, - CodeBlockInfo, LinkSpan, + CodeBlockInfo, ImageBlockInfo, LinkSpan, }, render::{build_status_bar, build_toc_line_with_index, toc_header_line}, theme::{app_theme, current_theme_selection, theme_preset_index}, @@ -173,6 +174,8 @@ pub(crate) struct App { pub(crate) link_spans_by_line: HashMap>, pub(crate) hovered_link: Option<(usize, usize)>, pub(crate) code_blocks: Vec, + pub(crate) image_blocks: Vec, + pub(crate) image_runtime: ImageRuntime, pub(crate) code_select: Option, code_block_flash: Option<(CodeBlockFlash, Instant)>, link_flash: Option<(LinkFlash, Instant)>, @@ -242,6 +245,7 @@ impl App { filepath, last_file_state, } = config; + let image_runtime = ImageRuntime::new(filepath.as_deref()); let plain_lines = build_searchable_lines(&lines) .into_iter() .map(|line| line.to_lowercase()) @@ -340,6 +344,8 @@ impl App { link_spans_by_line: HashMap::new(), hovered_link: None, code_blocks: Vec::new(), + image_blocks: Vec::new(), + image_runtime, code_select: None, code_block_flash: None, link_flash: None, diff --git a/src/app/theme_picker.rs b/src/app/theme_picker.rs index e93669d..6f0ef2f 100644 --- a/src/app/theme_picker.rs +++ b/src/app/theme_picker.rs @@ -1,5 +1,5 @@ use crate::{ - markdown::{parse_markdown_with_width, toc::TocEntry}, + markdown::{parse_markdown_with_width_and_path, toc::TocEntry, ImageBlockInfo}, theme::{ app_theme, current_syntect_theme, current_theme_selection, set_theme_preset, set_theme_selection, theme_preset_index, ThemePreset, ThemeSelection, THEME_PRESETS, @@ -14,6 +14,7 @@ use super::App; pub(crate) struct ThemePreviewCacheEntry { pub(super) lines: Vec>, pub(super) toc: Vec, + pub(super) image_blocks: Vec, } pub(crate) struct ThemePickerState { @@ -33,6 +34,7 @@ impl App { self.theme_picker.original_preview = Some(ThemePreviewCacheEntry { lines: self.lines.clone(), toc: self.toc.clone(), + image_blocks: self.image_blocks.clone(), }); self.store_current_theme_preview(); } @@ -117,13 +119,14 @@ impl App { self.replace_content(crate::markdown::ParseResult::preview( entry.lines, entry.toc, + entry.image_blocks, )); return; } let theme = current_syntect_theme(themes); let at = app_theme(); - let parsed = parse_markdown_with_width( + let parsed = parse_markdown_with_width_and_path( &self.source, ss, theme, @@ -131,8 +134,9 @@ impl App { &at.markdown, self.file_mode, self.code_line_numbers, + self.filepath.as_deref(), ); - self.store_theme_preview(preset, &parsed.lines, &parsed.toc); + self.store_theme_preview(preset, &parsed.lines, &parsed.toc, &parsed.image_blocks); self.replace_content(parsed); } @@ -143,11 +147,12 @@ impl App { self.replace_content(crate::markdown::ParseResult::preview( entry.lines, entry.toc, + entry.image_blocks, )); } else { let theme = current_syntect_theme(themes); let at = app_theme(); - let parsed = parse_markdown_with_width( + let parsed = parse_markdown_with_width_and_path( &self.source, ss, theme, @@ -155,6 +160,7 @@ impl App { &at.markdown, self.file_mode, self.code_line_numbers, + self.filepath.as_deref(), ); self.replace_content(parsed); } @@ -167,12 +173,14 @@ impl App { preset: ThemePreset, lines: &[Line<'static>], toc: &[TocEntry], + image_blocks: &[ImageBlockInfo], ) { let idx = theme_preset_index(preset); if let Some(slot) = self.theme_picker.preview_cache.get_mut(idx) { *slot = Some(ThemePreviewCacheEntry { lines: lines.to_vec(), toc: toc.to_vec(), + image_blocks: image_blocks.to_vec(), }); } } @@ -186,6 +194,7 @@ impl App { *slot = Some(ThemePreviewCacheEntry { lines: self.lines.clone(), toc: self.toc.clone(), + image_blocks: self.image_blocks.clone(), }); } } @@ -194,11 +203,12 @@ impl App { &mut self, lines: &[Line<'static>], toc: &[TocEntry], + image_blocks: &[ImageBlockInfo], ) { let Some(preset) = current_theme_selection().as_preset() else { return; }; - self.store_theme_preview(preset, lines, toc); + self.store_theme_preview(preset, lines, toc, image_blocks); } pub(crate) fn invalidate_theme_preview_cache(&mut self) { diff --git a/src/image_runtime.rs b/src/image_runtime.rs new file mode 100644 index 0000000..49a856b --- /dev/null +++ b/src/image_runtime.rs @@ -0,0 +1,540 @@ +use std::{ + collections::{HashMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + sync::mpsc::{self, Receiver, Sender}, + thread, + time::{Duration, Instant, SystemTime}, +}; + +use anyhow::Result as AnyResult; +use image::{ImageReader, Limits}; +use ratatui::{layout::Rect, Frame}; +use ratatui_image::{ + picker::{Picker, ProtocolType}, + sliced::{SignedPosition, SlicedImage, SlicedProtocol}, + Resize, +}; + +use crate::{app::App, markdown::ImageBlockInfo}; + +const MAX_IMAGE_FILE_BYTES: u64 = 20 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_IMAGE_ALLOC_BYTES: u64 = 128 * 1024 * 1024; +const IMAGE_WORKER_POLL: Duration = Duration::from_millis(50); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ImageSize { + width: u16, + height: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ImageKey { + path: PathBuf, + modified: Option, + len: u64, + size: ImageSize, +} + +struct ImageJob { + block_id: usize, + key: ImageKey, + size: ratatui::layout::Size, + picker: Picker, + path: PathBuf, + attempts: u8, +} + +struct ImageResult { + block_id: usize, + key: ImageKey, + attempts: u8, + result: Result, +} + +enum ImageState { + Loading, + Ready(SlicedProtocol), + Failed { attempts: u8, retry_at: Instant }, +} + +struct ImageEntry { + key: ImageKey, + state: ImageState, +} + +pub(crate) struct ImageRuntime { + picker: Option, + job_sender: Option>, + result_receiver: Option>, + entries: HashMap, + document_dir: PathBuf, + terminal_ready: bool, +} + +impl ImageRuntime { + pub(crate) fn new(document_path: Option<&Path>) -> Self { + Self { + picker: None, + job_sender: None, + result_receiver: None, + entries: HashMap::new(), + document_dir: document_dir(document_path), + terminal_ready: false, + } + } + + pub(crate) fn set_document_path(&mut self, document_path: Option<&Path>) { + let next = document_dir(document_path); + if self.document_dir != next { + self.document_dir = next; + self.entries.clear(); + } + } + + pub(crate) fn enable_kitty(&mut self) { + if self.picker.is_some() { + return; + } + let mut picker = match Picker::from_query_stdio() { + Ok(picker) => picker, + Err(_) => return, + }; + picker.set_protocol_type(ProtocolType::Kitty); + let (job_sender, job_receiver) = mpsc::channel(); + let (result_sender, result_receiver) = mpsc::channel(); + let worker = thread::Builder::new() + .name("leaf-kitty-images".to_string()) + .spawn(move || { + while let Ok(job) = job_receiver.recv() { + let result = decode_and_prepare(&job).map_err(|err| err.to_string()); + if result_sender + .send(ImageResult { + block_id: job.block_id, + key: job.key, + attempts: job.attempts, + result, + }) + .is_err() + { + break; + } + } + }); + if worker.is_err() { + return; + } + self.picker = Some(picker); + self.job_sender = Some(job_sender); + self.result_receiver = Some(result_receiver); + } + + pub(crate) fn sync_blocks(&mut self, blocks: &[ImageBlockInfo]) { + let block_ids: HashSet = blocks.iter().map(|block| block.id).collect(); + self.entries.retain(|id, _| block_ids.contains(id)); + } + + pub(crate) fn poll_results(&mut self) -> bool { + let Some(receiver) = &self.result_receiver else { + return false; + }; + let mut changed = false; + while let Ok(result) = receiver.try_recv() { + let Some(entry) = self.entries.get_mut(&result.block_id) else { + continue; + }; + if entry.key != result.key { + continue; + } + entry.state = match result.result { + Ok(protocol) => ImageState::Ready(protocol), + Err(_) => ImageState::Failed { + attempts: result.attempts, + retry_at: Instant::now() + retry_delay(result.attempts), + }, + }; + changed = true; + } + let now = Instant::now(); + changed + || self.entries.values().any(|entry| { + matches!( + &entry.state, + ImageState::Failed { retry_at, .. } if *retry_at <= now + ) + }) + } + + pub(crate) fn poll_delay(&self) -> Option { + let now = Instant::now(); + self.entries + .values() + .filter_map(|entry| match &entry.state { + ImageState::Loading => Some(IMAGE_WORKER_POLL), + ImageState::Failed { retry_at, .. } => { + Some(retry_at.saturating_duration_since(now)) + } + ImageState::Ready(_) => None, + }) + .min() + } + + pub(crate) fn render( + &mut self, + frame: &mut Frame<'_>, + blocks: &[ImageBlockInfo], + content_area: Rect, + scroll: usize, + x_offset: usize, + ) { + if self.picker.is_none() || content_area.width == 0 || content_area.height == 0 { + return; + } + let visible_end = scroll.saturating_add(content_area.height as usize); + for block in blocks { + if !block.renderable { + continue; + } + if block.rendered_end < scroll || block.rendered_start >= visible_end { + continue; + } + let Some((key, path, size)) = self.resolve_key(block) else { + continue; + }; + self.ensure_loaded(block.id, key.clone(), path, size); + let Some(ImageState::Ready(protocol)) = self + .entries + .get_mut(&block.id) + .map(|entry| &mut entry.state) + else { + continue; + }; + let y = block.rendered_start as isize + 1 - scroll as isize; + let x = x_offset + .saturating_add(block.prefix_width) + .saturating_add(1) + .min(i16::MAX as usize) as i16; + if y < i16::MIN as isize || y > i16::MAX as isize { + continue; + } + frame.render_widget( + SlicedImage::new(protocol, SignedPosition { x, y: y as i16 }), + content_area, + ); + } + } + + fn resolve_key( + &self, + block: &ImageBlockInfo, + ) -> Option<(ImageKey, PathBuf, ratatui::layout::Size)> { + let source = block.source.trim(); + if source.is_empty() || is_remote_source(source) || !has_supported_extension(source) { + return None; + } + let source_path = Path::new(source); + let joined = if source_path.is_absolute() { + source_path.to_path_buf() + } else { + self.document_dir.join(source_path) + }; + let path = fs::canonicalize(joined).ok()?; + let metadata = fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_IMAGE_FILE_BYTES { + return None; + } + let width = block + .rendered_width + .saturating_sub(2) + .min(u16::MAX as usize) as u16; + let block_height = block + .rendered_end + .saturating_sub(block.rendered_start) + .saturating_add(1); + let height = block_height.saturating_sub(2).min(u16::MAX as usize) as u16; + if width == 0 || height == 0 { + return None; + } + let size = ratatui::layout::Size { width, height }; + let key = ImageKey { + path: path.clone(), + modified: metadata.modified().ok(), + len: metadata.len(), + size: ImageSize { width, height }, + }; + Some((key, path, size)) + } + + fn ensure_loaded( + &mut self, + block_id: usize, + key: ImageKey, + path: PathBuf, + size: ratatui::layout::Size, + ) { + let mut attempts = 0; + if let Some(entry) = self.entries.get(&block_id) { + if entry.key == key { + match &entry.state { + ImageState::Loading | ImageState::Ready(_) => return, + ImageState::Failed { + attempts: previous_attempts, + retry_at, + } => { + if *retry_at > Instant::now() { + return; + } + attempts = previous_attempts.saturating_add(1); + } + } + } + } + let Some(picker) = self.picker.clone() else { + return; + }; + let Some(sender) = &self.job_sender else { + return; + }; + self.entries.insert( + block_id, + ImageEntry { + key: key.clone(), + state: ImageState::Loading, + }, + ); + let _ = sender.send(ImageJob { + block_id, + key, + size, + picker, + path, + attempts, + }); + } +} + +impl App { + pub(crate) fn initialize_kitty_images(&mut self) -> bool { + self.image_runtime.terminal_ready = true; + self.try_enable_kitty_images() + } + + pub(crate) fn try_enable_kitty_images(&mut self) -> bool { + if !self.image_runtime.terminal_ready + || self.image_blocks.is_empty() + || !is_kitty_terminal() + { + return self.image_runtime.picker.is_some(); + } + self.image_runtime.enable_kitty(); + self.image_runtime.picker.is_some() + } + + pub(crate) fn poll_image_results(&mut self) -> bool { + self.image_runtime.poll_results() + } + + pub(crate) fn image_poll_delay(&self) -> Option { + self.image_runtime.poll_delay() + } + + pub(crate) fn set_image_document_path(&mut self, path: Option<&Path>) { + self.image_runtime.set_document_path(path); + } + + pub(crate) fn render_images(&mut self, frame: &mut Frame<'_>, content_area: Rect) { + let scroll = self.scroll(); + let x_offset = self.line_number_gutter_width(); + let blocks = self.image_blocks.clone(); + self.image_runtime + .render(frame, &blocks, content_area, scroll, x_offset); + } +} + +fn retry_delay(attempts: u8) -> Duration { + let exponent = u32::from(attempts.min(5)); + Duration::from_secs(1u64 << exponent) +} + +fn decode_and_prepare(job: &ImageJob) -> AnyResult { + if job.key.len > MAX_IMAGE_FILE_BYTES { + anyhow::bail!("image file exceeds {} bytes", MAX_IMAGE_FILE_BYTES); + } + let mut reader = ImageReader::open(&job.path)?.with_guessed_format()?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_IMAGE_ALLOC_BYTES); + reader.limits(limits); + let image = reader.decode()?; + Ok(SlicedProtocol::new_with_resize( + &job.picker, + image, + job.size, + Resize::Fit(None), + )?) +} + +fn document_dir(document_path: Option<&Path>) -> PathBuf { + document_path + .and_then(Path::parent) + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn has_supported_extension(source: &str) -> bool { + Path::new(source) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" + ) + }) +} + +fn is_remote_source(source: &str) -> bool { + let lower = source.to_ascii_lowercase(); + lower.starts_with("data:") || lower.contains("://") +} + +fn is_kitty_terminal() -> bool { + is_kitty_environment(|name| env::var(name).ok()) +} + +fn is_kitty_environment(mut lookup: impl FnMut(&str) -> Option) -> bool { + ["KITTY_PID", "KITTY_WINDOW_ID"] + .iter() + .any(|name| lookup(name).is_some_and(|value| !value.is_empty())) + || lookup("TERM").is_some_and(|term| term.eq_ignore_ascii_case("xterm-kitty")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn kitty_environment_detection_accepts_supported_markers() { + for marker in ["KITTY_PID", "KITTY_WINDOW_ID"] { + let values = HashMap::from([(marker.to_string(), "1".to_string())]); + assert!(is_kitty_environment(|name| values.get(name).cloned())); + } + let values = HashMap::from([("TERM".to_string(), "xterm-kitty".to_string())]); + assert!(is_kitty_environment(|name| values.get(name).cloned())); + } + + #[test] + fn kitty_environment_detection_rejects_empty_or_other_terminals() { + let values = HashMap::from([ + ("KITTY_PID".to_string(), String::new()), + ("TERM".to_string(), "xterm-256color".to_string()), + ]); + assert!(!is_kitty_environment(|name| values.get(name).cloned())); + } + + #[test] + fn remote_and_svg_sources_are_rejected() { + assert!(is_remote_source("https://example.com/image.png")); + assert!(is_remote_source("data:image/png;base64,AAAA")); + assert!(!has_supported_extension("image.svg")); + assert!(has_supported_extension("image.PNG")); + } + + #[test] + fn failed_image_is_retried_after_backoff() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut runtime = ImageRuntime::new(Some(&manifest_dir.join("README.md"))); + let block = ImageBlockInfo { + id: 9, + source: "src/tests/fixtures/kitty-image.png".to_string(), + alt: "fixture".to_string(), + title: String::new(), + source_line: 1, + rendered_start: 0, + rendered_end: 11, + rendered_width: 22, + prefix_width: 0, + renderable: true, + }; + let (key, path, size) = runtime.resolve_key(&block).expect("fixture image"); + let (sender, receiver) = mpsc::channel(); + runtime.picker = Some(Picker::halfblocks()); + runtime.job_sender = Some(sender); + runtime.entries.insert( + block.id, + ImageEntry { + key: key.clone(), + state: ImageState::Failed { + attempts: 0, + retry_at: Instant::now() + .checked_sub(Duration::from_secs(1)) + .expect("past retry time"), + }, + }, + ); + + runtime.ensure_loaded(block.id, key, path, size); + assert!(matches!( + runtime.entries.get(&block.id).map(|entry| &entry.state), + Some(ImageState::Loading) + )); + let job = receiver.try_recv().expect("retry job"); + assert_eq!(job.attempts, 1); + } + + #[test] + fn kitty_protocol_renders_placeholder_into_test_buffer() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut runtime = ImageRuntime::new(Some(&manifest_dir.join("README.md"))); + let block = ImageBlockInfo { + id: 7, + source: "src/tests/fixtures/kitty-image.png".to_string(), + alt: "fixture".to_string(), + title: String::new(), + source_line: 1, + rendered_start: 0, + rendered_end: 11, + rendered_width: 22, + prefix_width: 0, + renderable: true, + }; + let (key, path, size) = runtime.resolve_key(&block).expect("fixture image"); + let mut picker = Picker::halfblocks(); + picker.set_protocol_type(ProtocolType::Kitty); + let protocol = decode_and_prepare(&ImageJob { + block_id: block.id, + key: key.clone(), + size, + picker: picker.clone(), + path, + attempts: 0, + }) + .expect("decode fixture"); + assert!(matches!(protocol, SlicedProtocol::Kitty(_))); + runtime.picker = Some(picker); + runtime.entries.insert( + block.id, + ImageEntry { + key, + state: ImageState::Ready(protocol), + }, + ); + + let backend = ratatui::backend::TestBackend::new(30, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| runtime.render(frame, &[block], frame.area(), 0, 4)) + .expect("draw image"); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .cell((5, 1)) + .is_some_and(|cell| cell.symbol().contains('\u{10EEEE}')), + "Kitty placeholder should honor the line-number gutter offset" + ); + } +} diff --git a/src/main.rs b/src/main.rs index c5aff61..15b6d87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod clipboard; mod completions; mod config; mod editor; +mod image_runtime; mod inline; mod markdown; mod picker_width; @@ -29,7 +30,9 @@ mod update; use app::{App, AppConfig}; use cli::{parse_cli, print_usage, print_version, CliOptions}; -use markdown::{hash_str, parse_markdown, parse_markdown_with_width, read_file_state}; +use markdown::{ + hash_str, parse_markdown_with_path, parse_markdown_with_width_and_path, read_file_state, +}; use runtime::run; use terminal::{finish_with_restore, TerminalSession}; use theme::{ @@ -381,25 +384,23 @@ fn main() -> Result<()> { } else { anyhow::bail!("Not a valid file, directory or keyword: {}", f); } - } else { - if io::stdin().is_terminal() { - let cwd = std::env::current_dir().context("Cannot read current directory")?; - let label = app::path_label(&cwd); - if picker { - open_browser_picker_dir = Some(cwd); - } else { - open_fuzzy_picker_dir = Some(cwd); - } - (String::new(), label, None) + } else if io::stdin().is_terminal() { + let cwd = std::env::current_dir().context("Cannot read current directory")?; + let label = app::path_label(&cwd); + if picker { + open_browser_picker_dir = Some(cwd); } else { - if watch_from_cli { - eprintln!("Error: --watch requires a file path (stdin cannot be watched)"); - std::process::exit(1); - } - let mut stdin = io::stdin().lock(); - let buf = read_stdin_limited(&mut stdin, MAX_STDIN_BYTES)?; - (buf, "stdin".to_string(), None) + open_fuzzy_picker_dir = Some(cwd); + } + (String::new(), label, None) + } else { + if watch_from_cli { + eprintln!("Error: --watch requires a file path (stdin cannot be watched)"); + std::process::exit(1); } + let mut stdin = io::stdin().lock(); + let buf = read_stdin_limited(&mut stdin, MAX_STDIN_BYTES)?; + (buf, "stdin".to_string(), None) }; let is_file_input = filepath.is_some(); @@ -446,7 +447,7 @@ fn main() -> Result<()> { let format = inline::resolve_format(spec, is_tty); let at = app_theme(); - let mut parsed = parse_markdown_with_width( + let mut parsed = parse_markdown_with_width_and_path( &src, &ss, &theme, @@ -454,6 +455,7 @@ fn main() -> Result<()> { &at.markdown, file_mode, code_line_numbers, + filepath.as_deref(), ); while parsed.lines.last().is_some_and(|l| { @@ -470,13 +472,14 @@ fn main() -> Result<()> { } let at = app_theme(); - let parsed = parse_markdown( + let parsed = parse_markdown_with_path( &src, &ss, &theme, &at.markdown, file_mode, code_line_numbers, + filepath.as_deref(), ); let crate::markdown::ParseResult { lines, @@ -485,6 +488,7 @@ fn main() -> Result<()> { line_number_map, source_line_map, code_blocks, + image_blocks, } = parsed; let mut app = App::new_with_source( lines, @@ -500,6 +504,7 @@ fn main() -> Result<()> { ); app.set_link_spans(link_spans); app.set_code_blocks(code_blocks); + app.set_image_blocks(image_blocks); app.set_line_maps(line_number_map, source_line_map); app.set_last_content_hash(last_content_hash); app.set_watch_from_config(watch_from_config); @@ -554,6 +559,8 @@ fn main() -> Result<()> { runtime::debug_log(debug_input, "terminal enter start"); let mut session = TerminalSession::enter(&mut stdout)?; runtime::debug_log(debug_input, "terminal enter done"); + let kitty_images = app.initialize_kitty_images(); + runtime::debug_log(debug_input, &format!("kitty images enabled={kitty_images}")); let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; runtime::debug_log(debug_input, "terminal new done"); terminal.backend_mut().clear_region(ClearType::All)?; diff --git a/src/markdown/footnotes.rs b/src/markdown/footnotes.rs index 9ad0e19..48867c0 100644 --- a/src/markdown/footnotes.rs +++ b/src/markdown/footnotes.rs @@ -8,6 +8,7 @@ use ratatui::{ use crate::theme::MarkdownTheme; use super::blocks::push_rule_line; +use super::images::ImageBlockInfo; use super::latex; use super::lists::{ItemState, ListKind}; use super::spans::InlineStyleState; @@ -15,7 +16,7 @@ use super::tables::TableBuf; use super::toc::TocEntry; use super::width::display_width; use super::wrapping::push_wrapped_prefixed_lines; -use super::{LastBlock, LineMapState}; +use super::{ImageParseState, LastBlock, LineMapState}; use ratatui::style::Color; pub(super) fn to_superscript(n: usize) -> String { @@ -48,6 +49,8 @@ pub(super) struct DefinitionSnapshot { pub(super) table: Option, pub(super) last_block: LastBlock, pub(super) state: LineMapState, + pub(super) image: Vec, + pub(super) next_image_id: usize, pub(super) toc: Vec, pub(super) link_urls: Vec, } @@ -69,6 +72,8 @@ impl DefinitionSnapshot { table: &mut Option, last_block: &mut LastBlock, state: &mut LineMapState, + image: &mut Vec, + next_image_id: &mut usize, toc: &mut Vec, link_urls: &mut Vec, ) -> Self { @@ -87,6 +92,8 @@ impl DefinitionSnapshot { table: std::mem::take(table), last_block: std::mem::replace(last_block, LastBlock::Other), state: std::mem::replace(state, LineMapState::new()), + image: std::mem::take(image), + next_image_id: *next_image_id, toc: std::mem::take(toc), link_urls: std::mem::take(link_urls), } @@ -109,6 +116,8 @@ impl DefinitionSnapshot { table: &mut Option, last_block: &mut LastBlock, state: &mut LineMapState, + image: &mut Vec, + next_image_id: &mut usize, toc: &mut Vec, link_urls: &mut Vec, ) { @@ -126,6 +135,8 @@ impl DefinitionSnapshot { *table = self.table; *last_block = self.last_block; *state = self.state; + *image = self.image; + *next_image_id = (*next_image_id).max(self.next_image_id); *toc = self.toc; *link_urls = self.link_urls; } @@ -134,6 +145,7 @@ impl DefinitionSnapshot { pub(super) struct ActiveDefinition { pub(super) label: String, pub(super) snapshot: DefinitionSnapshot, + pub(super) image_blocks: Vec, } #[derive(Default)] @@ -144,6 +156,7 @@ pub(super) struct FootnotesBuf { definitions: HashMap>>, def_source_line: HashMap, def_link_urls: HashMap>, + def_image_blocks: HashMap>, active: Option, } @@ -168,7 +181,11 @@ impl FootnotesBuf { self.defs_order.push(label.clone()); } self.def_source_line.insert(label.clone(), src_line); - self.active = Some(ActiveDefinition { label, snapshot }); + self.active = Some(ActiveDefinition { + label, + snapshot, + image_blocks: Vec::new(), + }); } pub(super) fn finish_definition( @@ -176,12 +193,17 @@ impl FootnotesBuf { captured_lines: Vec>, captured_link_urls: Vec, ) -> DefinitionSnapshot { - let ActiveDefinition { label, snapshot } = self + let ActiveDefinition { + label, + snapshot, + image_blocks, + } = self .active .take() .expect("finish_definition without active definition"); self.definitions.insert(label.clone(), captured_lines); - self.def_link_urls.insert(label, captured_link_urls); + self.def_link_urls.insert(label.clone(), captured_link_urls); + self.def_image_blocks.insert(label, image_blocks); snapshot } @@ -189,11 +211,18 @@ impl FootnotesBuf { self.active.is_some() } + pub(super) fn record_image_block(&mut self, block: ImageBlockInfo) { + if let Some(active) = self.active.as_mut() { + active.image_blocks.push(block); + } + } + pub(super) fn flush( &mut self, lines: &mut Vec>, state: &mut LineMapState, link_urls: &mut Vec, + image_blocks: &mut Vec, theme: &MarkdownTheme, render_width: usize, ) { @@ -245,7 +274,13 @@ impl FootnotesBuf { let prefix_width = display_width(&prefix_text); let indent = " ".repeat(prefix_width); let mut first = true; - for def_line in def_lines { + let mut def_image_blocks = self.def_image_blocks.remove(&label).unwrap_or_default(); + let image_ranges: Vec<(usize, usize)> = def_image_blocks + .iter() + .map(|block| (block.rendered_start, block.rendered_end)) + .collect(); + for (def_line_idx, def_line) in def_lines.into_iter().enumerate() { + let output_start = lines.len(); let mut body: Vec> = def_line.spans.into_iter().collect(); recolor_default_text(&mut body, theme.text, theme.footnote_text); if first { @@ -275,7 +310,19 @@ impl FootnotesBuf { render_width, ); } + let output_end = lines.len(); + for (block, (source_start, source_end)) in + def_image_blocks.iter_mut().zip(&image_ranges) + { + if *source_start == def_line_idx { + block.rendered_start = output_start; + } + if *source_end == def_line_idx { + block.rendered_end = output_end.saturating_sub(1); + } + } } + image_blocks.append(&mut def_image_blocks); state.mark_all_new(lines.len()); if let Some(mut urls) = self.def_link_urls.remove(&label) { link_urls.append(&mut urls); diff --git a/src/markdown/images.rs b/src/markdown/images.rs new file mode 100644 index 0000000..6c2e4e0 --- /dev/null +++ b/src/markdown/images.rs @@ -0,0 +1,206 @@ +use std::path::Path; + +use image::ImageReader; +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, +}; + +use crate::theme::MarkdownTheme; + +use super::blocks::{block_prefix, BlockLayout}; +use super::lists::{list_item_prefix, ItemState, ListKind}; +use super::width::{display_width, truncate_display_width}; + +const MIN_IMAGE_BLOCK_HEIGHT: usize = 12; +const MAX_IMAGE_BLOCK_HEIGHT: usize = 40; +const DEFAULT_CELL_HEIGHT_RATIO: u64 = 2; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ImageBlockInfo { + pub(crate) id: usize, + pub(crate) source: String, + pub(crate) alt: String, + pub(crate) title: String, + pub(crate) source_line: usize, + pub(crate) rendered_start: usize, + pub(crate) rendered_end: usize, + pub(crate) rendered_width: usize, + pub(crate) prefix_width: usize, + pub(crate) renderable: bool, +} + +pub(super) struct ImageRenderContext<'a> { + pub(super) render_width: usize, + pub(super) blockquote_depth: usize, + pub(super) list_stack: &'a [ListKind], + pub(super) blockquote_color: Option, + pub(super) theme: &'a MarkdownTheme, + pub(super) image_base_dir: Option<&'a Path>, +} + +pub(super) fn push_image_placeholder( + lines: &mut Vec>, + item_stack: &mut [ItemState], + alt: &str, + source: &str, + context: ImageRenderContext<'_>, +) -> BlockLayout { + let ImageRenderContext { + render_width, + blockquote_depth, + list_stack, + blockquote_color, + theme, + image_base_dir, + } = context; + let prefix = if !item_stack.is_empty() { + list_item_prefix( + blockquote_depth, + list_stack, + item_stack, + theme, + blockquote_color, + ) + } else if blockquote_depth > 0 { + block_prefix(blockquote_depth, theme, blockquote_color) + } else { + Vec::new() + }; + let prefix_width: usize = prefix + .iter() + .map(|span| display_width(span.content.as_ref())) + .sum(); + let frame_width = render_width.saturating_sub(prefix_width).max(1); + let inner_width = frame_width.saturating_sub(2).max(1); + let block_height = image_block_height(source, image_base_dir, inner_width); + let frame_style = Style::default().fg(theme.code_frame); + let label_style = Style::default().fg(theme.code_label); + + let header_prefix = "┌─ image "; + let header = format!( + "{header_prefix}{}┐", + "─".repeat(inner_width.saturating_sub(display_width(header_prefix))) + ); + let header = if display_width(&header) <= frame_width { + header + } else { + format!("┌{}┐", "─".repeat(inner_width)) + }; + let alt_line = if alt.trim().is_empty() { + "alt".to_string() + } else { + format!("alt: {}", alt.trim()) + }; + let source_line = if source.is_empty() { + "source: (empty)".to_string() + } else { + format!("source: {source}") + }; + let row_text = [alt_line, source_line]; + + for row in 0..block_height { + let text = if row == 0 { + header.clone() + } else if row == block_height - 1 { + format!("└{}┘", "─".repeat(inner_width)) + } else if row <= row_text.len() { + let content_width = inner_width.saturating_sub(2); + let content = truncate_display_width(&row_text[row - 1], content_width); + let padding = content_width.saturating_sub(display_width(&content)); + format!("│ {content}{}│", " ".repeat(padding)) + } else { + let padding = inner_width.saturating_sub(2); + format!("│{}│", " ".repeat(padding)) + }; + let mut spans = prefix.clone(); + spans.push(Span::styled(text, frame_style)); + if row == 1 { + if let Some(label) = spans.last_mut() { + label.style = label_style; + } + } + lines.push(Line::from(spans)); + } + + BlockLayout { + prefix_width, + rendered_width: frame_width, + } +} + +pub(super) fn image_block_height( + source: &str, + base_dir: Option<&Path>, + inner_width: usize, +) -> usize { + image_dimensions(source, base_dir) + .and_then(|(pixel_width, pixel_height)| { + let inner_width = u64::try_from(inner_width).ok()?; + let pixel_width = u64::from(pixel_width); + let pixel_height = u64::from(pixel_height); + if pixel_width == 0 || pixel_height == 0 { + return None; + } + let image_rows = inner_width + .saturating_mul(pixel_height) + .div_ceil(pixel_width.saturating_mul(DEFAULT_CELL_HEIGHT_RATIO)); + usize::try_from(image_rows.saturating_add(2)).ok() + }) + .unwrap_or(MIN_IMAGE_BLOCK_HEIGHT) + .clamp(MIN_IMAGE_BLOCK_HEIGHT, MAX_IMAGE_BLOCK_HEIGHT) +} + +fn image_dimensions(source: &str, base_dir: Option<&Path>) -> Option<(u32, u32)> { + let source = source.trim(); + if source.is_empty() || source.starts_with("data:") || source.contains("://") { + return None; + } + let source_path = Path::new(source); + let path = if source_path.is_absolute() { + source_path.to_path_buf() + } else { + base_dir.unwrap_or_else(|| Path::new(".")).join(source_path) + }; + let metadata = std::fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 20 * 1024 * 1024 { + return None; + } + ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()? + .into_dimensions() + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_image_height_uses_aspect_ratio_and_terminal_cell_ratio() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + assert_eq!( + image_block_height("images/preview.png", Some(manifest_dir), 78), + 23 + ); + assert_eq!( + image_block_height("src/tests/fixtures/kitty-image.png", Some(manifest_dir), 78), + 40 + ); + } + + #[test] + fn unavailable_or_narrow_images_use_minimum_height() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + assert_eq!( + image_block_height("images/missing.png", Some(manifest_dir), 78), + 12 + ); + assert_eq!( + image_block_height("images/preview.png", Some(manifest_dir), 20), + 12 + ); + } +} diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index 333ced7..b3cb74b 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -3,6 +3,7 @@ mod fences; mod footnotes; mod frontmatter; mod highlight; +mod images; mod latex; mod links; mod lists; @@ -37,7 +38,7 @@ use ratatui::{ use std::{ hash::{Hash, Hasher}, io, - path::PathBuf, + path::{Path, PathBuf}, }; use toc::{normalize_toc, TocEntry}; @@ -48,6 +49,8 @@ use blocks::{ CODE_BLOCK_GUTTER, }; use fences::normalize_code_fences; +pub(crate) use images::ImageBlockInfo; +use images::{push_image_placeholder, ImageRenderContext}; use links::build_link_spans; use lists::{ end_item, end_list, flush_list_item_spans, list_item_prefix, start_item, start_list, ItemState, @@ -57,8 +60,9 @@ use lists::{ pub(crate) use lists::{TASK_CHECKED, TASK_CHECKED_ALT, TASK_UNCHECKED}; use markers::push_custom_marker_spans; use spans::{ - handle_html_tag_event, handle_inline_style_event, inline_text_style, push_inline_code_span, - push_inline_latex_span, push_mark_span, HtmlBufferKind, HtmlTagOutcome, InlineStyleState, + handle_html_tag_event, handle_inline_style_event, image_alt_html_text, inline_text_style, + push_inline_code_span, push_inline_latex_span, push_mark_span, HtmlBufferKind, HtmlTagOutcome, + InlineStyleState, }; pub(crate) const DEFAULT_LINK_MARKER: &str = "#"; @@ -85,6 +89,14 @@ pub(super) enum LastBlock { Blockquote, } +pub(super) struct ImageParseState { + pub(super) id: usize, + pub(super) source: String, + pub(super) title: String, + pub(super) alt: String, + pub(super) source_line: usize, +} + pub(crate) fn hash_str(text: &str) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut hasher); @@ -359,10 +371,15 @@ pub(crate) struct ParseResult { pub(crate) line_number_map: Vec, pub(crate) source_line_map: Vec, pub(crate) code_blocks: Vec, + pub(crate) image_blocks: Vec, } impl ParseResult { - pub(crate) fn preview(lines: Vec>, toc: Vec) -> Self { + pub(crate) fn preview( + lines: Vec>, + toc: Vec, + image_blocks: Vec, + ) -> Self { Self { lines, toc, @@ -370,6 +387,7 @@ impl ParseResult { line_number_map: Vec::new(), source_line_map: Vec::new(), code_blocks: Vec::new(), + image_blocks, } } } @@ -381,6 +399,7 @@ impl From for (Vec>, Vec, Vec, Ve } } +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn parse_markdown( src: &str, ss: &syntect::parsing::SyntaxSet, @@ -389,7 +408,28 @@ pub(crate) fn parse_markdown( file_mode: bool, code_line_numbers: bool, ) -> ParseResult { - parse_markdown_with_width( + parse_markdown_with_width_and_path( + src, + ss, + theme, + DEFAULT_RENDER_WIDTH, + md_theme, + file_mode, + code_line_numbers, + None, + ) +} + +pub(crate) fn parse_markdown_with_path( + src: &str, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, + md_theme: &MarkdownTheme, + file_mode: bool, + code_line_numbers: bool, + document_path: Option<&Path>, +) -> ParseResult { + parse_markdown_with_width_and_path( src, ss, theme, @@ -397,9 +437,11 @@ pub(crate) fn parse_markdown( md_theme, file_mode, code_line_numbers, + document_path, ) } +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn parse_markdown_with_width( src: &str, ss: &syntect::parsing::SyntaxSet, @@ -409,6 +451,30 @@ pub(crate) fn parse_markdown_with_width( file_mode: bool, code_line_numbers: bool, ) -> ParseResult { + parse_markdown_with_width_and_path( + src, + ss, + theme, + render_width, + theme_colors, + file_mode, + code_line_numbers, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn parse_markdown_with_width_and_path( + src: &str, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, + render_width: usize, + theme_colors: &MarkdownTheme, + file_mode: bool, + code_line_numbers: bool, + document_path: Option<&Path>, +) -> ParseResult { + let image_base_dir = document_path.and_then(Path::parent); let original_src = src; let (src, fm_pairs) = frontmatter::extract_frontmatter(src); let fm_byte_count = original_src.len() - src.len(); @@ -432,6 +498,9 @@ pub(crate) fn parse_markdown_with_width( let mut code_lang = String::new(); let mut code_buf = String::new(); let mut code_blocks: Vec = Vec::new(); + let mut image_blocks: Vec = Vec::new(); + let mut next_image_id = 0usize; + let mut image: Vec = Vec::new(); let mut blockquote_depth = 0usize; let mut inline = InlineStyleState::default(); let mut html_style_buffer: Option<(HtmlBufferKind, String)> = None; @@ -467,14 +536,16 @@ pub(crate) fn parse_markdown_with_width( prev_event_end = range.end; continue; } - if handle_inline_style_event( - &ev, - &mut inline, - &mut spans, - theme_colors, - blockquote_depth, - &mut link_urls, - ) { + if image.is_empty() + && handle_inline_style_event( + &ev, + &mut inline, + &mut spans, + theme_colors, + blockquote_depth, + &mut link_urls, + ) + { prev_event_end = range.end; continue; } @@ -504,6 +575,77 @@ pub(crate) fn parse_markdown_with_width( ); last_block = LastBlock::Other; } + MdEvent::Start(Tag::Image { + dest_url, title, .. + }) => { + image.push(ImageParseState { + id: next_image_id, + source: dest_url.to_string(), + title: title.to_string(), + alt: String::new(), + source_line: state.current_src_line, + }); + next_image_id += 1; + } + MdEvent::End(TagEnd::Image) => { + if let Some(image_state) = image.pop() { + if let Some(parent) = image.last_mut() { + if !parent.alt.is_empty() && !image_state.alt.is_empty() { + parent.alt.push(' '); + } + parent.alt.push_str(&image_state.alt); + } else { + flush_pending_inline_if_any( + &mut lines, + &mut spans, + blockquote_depth, + &list_stack, + &mut item_stack, + render_width, + theme_colors, + blockquote_color, + ); + let rendered_start = lines.len(); + state.mark_wrapped(before, rendered_start, &lines); + let layout = push_image_placeholder( + &mut lines, + &mut item_stack, + &image_state.alt, + &image_state.source, + ImageRenderContext { + render_width, + blockquote_depth, + list_stack: &list_stack, + blockquote_color, + theme: theme_colors, + image_base_dir, + }, + ); + let block = ImageBlockInfo { + id: image_state.id, + source: image_state.source, + alt: image_state.alt, + title: image_state.title, + source_line: image_state.source_line, + rendered_start, + rendered_end: lines.len().saturating_sub(1), + rendered_width: layout.rendered_width, + prefix_width: layout.prefix_width, + renderable: !footnotes.is_active(), + }; + if footnotes.is_active() { + footnotes.record_image_block(block); + } else { + image_blocks.push(block); + } + state.mark_image_block( + rendered_start, + lines.len(), + image_state.source_line, + ); + } + } + } MdEvent::Start(Tag::Paragraph) => {} MdEvent::End(TagEnd::Paragraph) => { close_inline_block( @@ -618,7 +760,9 @@ pub(crate) fn parse_markdown_with_width( last_block = LastBlock::Other; } MdEvent::Code(text) => { - if let Some((_, buf)) = html_style_buffer.as_mut() { + if let Some(image_state) = image.last_mut() { + image_state.alt.push_str(text.as_ref()); + } else if let Some((_, buf)) = html_style_buffer.as_mut() { buf.push_str(text.as_ref()); } else { push_inline_code_span(&mut spans, text.as_ref(), theme_colors); @@ -737,16 +881,25 @@ pub(crate) fn parse_markdown_with_width( last_block = LastBlock::Other; } MdEvent::Text(text) => { - push_text_event( - &mut spans, - &mut code_buf, - text.as_ref(), - in_code, - theme_colors, - blockquote_depth, - inline, - &mut html_style_buffer, - ); + if let Some(image_state) = image.last_mut() { + image_state.alt.push_str(text.as_ref()); + } else { + push_text_event( + &mut spans, + &mut code_buf, + text.as_ref(), + in_code, + theme_colors, + blockquote_depth, + inline, + &mut html_style_buffer, + ); + } + } + MdEvent::SoftBreak | MdEvent::HardBreak if !image.is_empty() => { + if let Some(image_state) = image.last_mut() { + image_state.alt.push(' '); + } } MdEvent::SoftBreak | MdEvent::HardBreak if !in_code => { flush_wrapped_spans( @@ -762,6 +915,14 @@ pub(crate) fn parse_markdown_with_width( wraps = true; } MdEvent::SoftBreak | MdEvent::HardBreak => {} + MdEvent::InlineHtml(raw) if !image.is_empty() => { + if let Some(image_state) = image.last_mut() { + match image_alt_html_text(raw.as_ref()) { + Some(text) => image_state.alt.push_str(text), + None => image_state.alt.push_str(raw.as_ref()), + } + } + } MdEvent::InlineHtml(raw) if !in_code => { match handle_html_tag_event(raw.as_ref(), &mut inline, &mut spans) { HtmlTagOutcome::Consumed => {} @@ -808,7 +969,16 @@ pub(crate) fn parse_markdown_with_width( } } MdEvent::InlineMath(text) => { - push_inline_latex_span(&mut spans, text.as_ref(), theme_colors); + if let Some(image_state) = image.last_mut() { + image_state.alt.push_str(text.as_ref()); + } else { + push_inline_latex_span(&mut spans, text.as_ref(), theme_colors); + } + } + MdEvent::DisplayMath(text) if !image.is_empty() => { + if let Some(image_state) = image.last_mut() { + image_state.alt.push_str(text.as_ref()); + } } MdEvent::DisplayMath(text) => { if !spans.is_empty() { @@ -880,6 +1050,8 @@ pub(crate) fn parse_markdown_with_width( &mut table, &mut last_block, &mut state, + &mut image, + &mut next_image_id, &mut toc, &mut link_urls, ); @@ -916,6 +1088,8 @@ pub(crate) fn parse_markdown_with_width( &mut table, &mut last_block, &mut state, + &mut image, + &mut next_image_id, &mut toc, &mut link_urls, ); @@ -938,6 +1112,7 @@ pub(crate) fn parse_markdown_with_width( &mut lines, &mut state, &mut link_urls, + &mut image_blocks, theme_colors, render_width, ); @@ -953,6 +1128,7 @@ pub(crate) fn parse_markdown_with_width( line_number_map: state.line_number_map, source_line_map: state.source_line_map, code_blocks, + image_blocks, } } @@ -1011,6 +1187,15 @@ impl LineMapState { } } + fn mark_image_block(&mut self, from: usize, to: usize, source_line: usize) { + let previous_source_line = self.current_src_line; + self.current_src_line = source_line; + for i in self.line_number_map.len()..to { + self.push(i == from); + } + self.current_src_line = previous_source_line; + } + fn mark_table_lines(&mut self, to: usize, lines: &[Line<'_>]) { let mut prev_border = true; let start = self.line_number_map.len(); diff --git a/src/markdown/spans.rs b/src/markdown/spans.rs index 2d2a212..919c621 100644 --- a/src/markdown/spans.rs +++ b/src/markdown/spans.rs @@ -110,6 +110,14 @@ pub(super) fn normalize_html_tag(raw: &str) -> Option<(HtmlTagName, bool)> { Some((classify_tag_name(name)?, is_open)) } +pub(super) fn image_alt_html_text(raw: &str) -> Option<&'static str> { + match normalize_html_tag(raw) { + Some((HtmlTagName::Br, _)) => Some(" "), + Some(_) => Some(""), + None => None, + } +} + pub(super) fn handle_html_tag_event( raw: &str, inline: &mut InlineStyleState, diff --git a/src/markdown/table_layout.rs b/src/markdown/table_layout.rs index 52132a0..b1a8a1c 100644 --- a/src/markdown/table_layout.rs +++ b/src/markdown/table_layout.rs @@ -115,6 +115,13 @@ fn rebuild_fragment(frag: &CellFragment, text: String) -> CellFragment { match frag { CellFragment::InlineMath(_, _) => CellFragment::InlineMath(text, false), CellFragment::Mark(_, _) => CellFragment::Mark(text, false), + CellFragment::Image { source, title, .. } => CellFragment::Image { + alt: text, + source: source.clone(), + title: title.clone(), + style: CellInlineStyle::default(), + adjacent: false, + }, _ => CellFragment::Code(text, false), } } @@ -152,7 +159,13 @@ pub(super) fn wrap_table_cell(frags: &[CellFragment], width: usize) -> Vec { + CellFragment::Text(t, style, adj) + | CellFragment::Image { + alt: t, + style, + adjacent: adj, + .. + } => { let expanded = expand_tabs(t, 0); let style = *style; let adj = *adj; @@ -296,7 +309,12 @@ pub(super) fn align_cell( for frag in frags { match frag { - CellFragment::Text(t, inline, _) => { + CellFragment::Text(t, inline, _) + | CellFragment::Image { + alt: t, + style: inline, + .. + } => { let expanded = expand_tabs(t, 0); content_width += display_width(&expanded); let mut style = base_style; diff --git a/src/markdown/tables.rs b/src/markdown/tables.rs index 5d41532..ba7213f 100644 --- a/src/markdown/tables.rs +++ b/src/markdown/tables.rs @@ -6,7 +6,9 @@ use ratatui::{ }; use super::latex; -use super::spans::{normalize_html_tag, HtmlBufferKind, HtmlTagName, HtmlTagOutcome}; +use super::spans::{ + image_alt_html_text, normalize_html_tag, HtmlBufferKind, HtmlTagName, HtmlTagOutcome, +}; use super::table_layout::{ align_cell, cap_table_widths, fit_table_widths, fragments_display_width, min_table_cell_width, wrap_table_cell, @@ -48,6 +50,13 @@ pub(super) enum CellFragment { InlineMath(String, bool), LinkMarker(CellInlineStyle), Mark(String, bool), + Image { + alt: String, + source: String, + title: String, + style: CellInlineStyle, + adjacent: bool, + }, HardBreak, } @@ -58,6 +67,7 @@ impl CellFragment { t.clone() } CellFragment::InlineMath(t, _) => latex::to_unicode(t), + CellFragment::Image { alt, .. } => alt.clone(), CellFragment::LinkMarker(_) => super::with_link_marker(|m| m.to_string()), CellFragment::HardBreak => String::new(), } @@ -66,14 +76,19 @@ impl CellFragment { pub(super) fn display_width(&self) -> usize { let w = display_width(&self.rendered_text()); match self { - CellFragment::Text(_, _, _) | CellFragment::LinkMarker(_) => w, + CellFragment::Text(_, _, _) + | CellFragment::LinkMarker(_) + | CellFragment::Image { .. } => w, CellFragment::HardBreak => 0, _ => w + 2, } } pub(super) fn is_text(&self) -> bool { - matches!(self, CellFragment::Text(_, _, _)) + matches!( + self, + CellFragment::Text(_, _, _) | CellFragment::Image { .. } + ) } } @@ -88,6 +103,13 @@ pub(super) struct TableBuf { html_style_buffer: Option<(HtmlBufferKind, String)>, key_column: Option, fill_width: bool, + image: Vec, +} + +struct TableImageState { + alt: String, + source: String, + title: String, } struct TableBorder<'a> { @@ -107,18 +129,75 @@ pub(super) fn handle_table_event( let Some(tb) = table.as_mut() else { return false; }; + if !tb.image.is_empty() + && !matches!( + ev, + MdEvent::Start(Tag::Image { .. }) + | MdEvent::End(TagEnd::Image) + | MdEvent::Text(_) + | MdEvent::Code(_) + | MdEvent::InlineMath(_) + | MdEvent::InlineHtml(_) + | MdEvent::SoftBreak + | MdEvent::HardBreak + ) + { + return true; + } match ev { MdEvent::Text(t) => { - tb.push_text(t.as_ref()); + if let Some(image) = tb.image.last_mut() { + image.alt.push_str(t.as_ref()); + } else { + tb.push_text(t.as_ref()); + } + true + } + MdEvent::Start(Tag::Image { + dest_url, title, .. + }) => { + tb.image.push(TableImageState { + alt: String::new(), + source: dest_url.to_string(), + title: title.to_string(), + }); + true + } + MdEvent::End(TagEnd::Image) => { + if let Some(image) = tb.image.pop() { + if let Some(parent) = tb.image.last_mut() { + if !parent.alt.is_empty() && !image.alt.is_empty() { + parent.alt.push(' '); + } + parent.alt.push_str(&image.alt); + } else { + let adjacent = tb.prev_ends_without_ws(); + tb.current_cell.push(CellFragment::Image { + alt: image.alt, + source: image.source, + title: image.title, + style: tb.inline_style, + adjacent, + }); + } + } true } MdEvent::Code(t) => { - tb.push_code(t.as_ref()); + if let Some(image) = tb.image.last_mut() { + image.alt.push_str(t.as_ref()); + } else { + tb.push_code(t.as_ref()); + } true } MdEvent::InlineMath(t) => { - tb.push_inline_math(t.as_ref()); + if let Some(image) = tb.image.last_mut() { + image.alt.push_str(t.as_ref()); + } else { + tb.push_inline_math(t.as_ref()); + } true } MdEvent::Start(Tag::TableCell) => true, @@ -185,6 +264,13 @@ pub(super) fn handle_table_event( true } MdEvent::InlineHtml(raw) => { + if let Some(image) = tb.image.last_mut() { + match image_alt_html_text(raw.as_ref()) { + Some(text) => image.alt.push_str(text), + None => image.alt.push_str(raw.as_ref()), + } + return true; + } match handle_html_tag_event_cell(raw.as_ref(), tb) { HtmlTagOutcome::Consumed => {} HtmlTagOutcome::OpenStyleBuffer(kind) => { @@ -207,6 +293,12 @@ pub(super) fn handle_table_event( } true } + MdEvent::SoftBreak | MdEvent::HardBreak => { + if let Some(image) = tb.image.last_mut() { + image.alt.push(' '); + } + true + } MdEvent::End(TagEnd::Table) => { let rendered = tb.render(render_width); lines.extend(rendered); @@ -290,6 +382,7 @@ impl TableBuf { html_style_buffer: None, key_column: None, fill_width: false, + image: Vec::new(), } } @@ -317,6 +410,7 @@ impl TableBuf { html_style_buffer: None, key_column: Some(0), fill_width: true, + image: Vec::new(), } } else { let alignments = vec![Alignment::None; pairs.len()]; @@ -339,6 +433,7 @@ impl TableBuf { html_style_buffer: None, key_column: None, fill_width: true, + image: Vec::new(), } } } diff --git a/src/render/content.rs b/src/render/content.rs index f588895..4be4c54 100644 --- a/src/render/content.rs +++ b/src/render/content.rs @@ -101,6 +101,7 @@ pub(super) fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect) { .wrap(Wrap { trim: false }), content_area, ); + app.render_images(f, content_area); let (mouse_col, mouse_row) = app.mouse_position; let sb_x = area.x + area.width - SCROLLBAR_WIDTH; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 08f0e35..97cde64 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -102,6 +102,9 @@ pub(crate) fn run( if app.poll_history_errors() { needs_redraw = true; } + if app.poll_image_results() { + needs_redraw = true; + } let current_title_filename = app.title_filename(); if current_title_filename != last_title_filename.as_deref() { @@ -148,6 +151,7 @@ pub(crate) fn run( } else { None }, + app.image_poll_delay(), flash_timeout, editor_flash_timeout, watch_flash_timeout, diff --git a/src/tests/fixtures/kitty-image.png b/src/tests/fixtures/kitty-image.png new file mode 100644 index 0000000000000000000000000000000000000000..8e18d7950ce92e44d467e91cf8e9409746e5f3f3 GIT binary patch literal 73 zcmeAS@N?(olHy`uVBq!ia0vp^Od!m`1|*BN@u~nR0Z$jlkcv6UKh7WcAkU>J!r*#_ V{b$= 3); + + let rendered: String = parsed.lines.iter().map(line_plain_text).collect(); + assert_eq!(rendered.matches("diagram alt").count(), 1); + assert!(rendered.contains("source: images/diagram.svg")); + assert!(rendered.contains("After")); +} + +#[test] +fn image_placeholder_preserves_list_and_blockquote_prefixes() { + let (ss, theme) = test_assets(); + let src = "- ![list image](list.png)\n\n> ![quote image](quote.png)\n"; + let parsed = parse_markdown_with_width(src, &ss, &theme, 50, &test_md_theme(), false, true); + assert_eq!(parsed.image_blocks.len(), 2); + assert!(parsed + .image_blocks + .iter() + .all(|block| block.prefix_width > 0)); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert!(rendered.iter().any(|line| line.contains("list image"))); + assert!(rendered + .iter() + .any(|line| line.starts_with("▏ ") && line.contains("quote image"))); +} + +#[test] +fn image_in_heading_is_rendered_as_placeholder() { + let (ss, theme) = test_assets(); + let parsed = parse_markdown( + "## ![heading image](heading.png)\n", + &ss, + &theme, + &test_md_theme(), + false, + true, + ); + assert_eq!(parsed.image_blocks.len(), 1); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert!(rendered.iter().any(|line| line.contains("heading image"))); + assert!(parsed.toc.iter().any(|entry| entry.title.is_empty())); +} + +#[test] +fn table_image_falls_back_to_alt_text() { + let (ss, theme) = test_assets(); + let src = "| Image |\n| --- |\n| ![table alt](table.png) |\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert!(parsed.image_blocks.is_empty()); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert!(rendered.iter().any(|line| line.contains("table alt"))); + assert!(!rendered.iter().any(|line| line.contains("source:"))); +} + +#[test] +fn footnote_image_state_does_not_shift_following_source_lines() { + let (ss, theme) = test_assets(); + let src = "Body[^n]\n\n[^n]: ![note image](note.png)\n\nAfter\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.image_blocks.len(), 1); + assert_eq!(parsed.image_blocks[0].source, "note.png"); + let block = &parsed.image_blocks[0]; + assert!(!block.renderable); + assert!(block.rendered_start < parsed.lines.len()); + assert!(block.rendered_end < parsed.lines.len()); + assert!(block.rendered_end - block.rendered_start + 1 >= 12); + let rendered = rendered_non_empty_lines(&parsed.lines); + let after_idx = rendered + .iter() + .position(|line| line.contains("After")) + .expect("After line"); + assert!(parsed.source_line_map.contains(&5)); + assert!(after_idx > 0); +} + +#[test] +fn image_alt_collects_nested_images_and_inline_formatting() { + let (ss, theme) = test_assets(); + let src = "![outer *em* `code` $x$ [link](https://example.com) bold
raw ![inner](inner.png)](outer.png)\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.image_blocks.len(), 1); + let block = &parsed.image_blocks[0]; + assert_eq!(block.source, "outer.png"); + assert!(block.alt.contains("outer")); + assert!(block.alt.contains("em")); + assert!(block.alt.contains("code")); + assert!(block.alt.contains("x")); + assert!(block.alt.contains("link")); + assert!(block.alt.contains("bold")); + assert!(block.alt.contains("raw")); + assert!(!block.alt.contains("")); + assert!(!block.alt.contains("
")); + assert!(block.alt.contains("inner")); + let rendered: String = parsed.lines.iter().map(line_plain_text).collect(); + assert!(!rendered.contains("inner.png")); + assert!(!rendered.contains('#')); +} + +#[test] +fn footnote_and_body_images_have_unique_ids() { + let (ss, theme) = test_assets(); + let src = "Body[^n]\n\n[^n]: ![note](note.png)\n\n![body](body.png)\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert_eq!(parsed.image_blocks.len(), 2); + let ids: std::collections::HashSet<_> = + parsed.image_blocks.iter().map(|block| block.id).collect(); + assert_eq!(ids.len(), 2); +} + +#[test] +fn multiline_image_maps_all_placeholder_rows_to_start_line() { + let (ss, theme) = test_assets(); + let src = "Before\n\n![multi\nline](image.png)\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let block = &parsed.image_blocks[0]; + assert_eq!(block.source_line, 3); + let line_numbers: std::collections::HashSet<_> = parsed.line_number_map + [block.rendered_start..=block.rendered_end] + .iter() + .copied() + .collect(); + assert_eq!(line_numbers.len(), 1); + assert!( + parsed.source_line_map[block.rendered_start..=block.rendered_end] + .iter() + .all(|line| *line == 3) + ); +} + +#[test] +fn table_image_alt_collects_inline_and_nested_content() { + let (ss, theme) = test_assets(); + let src = + "| Image |\n| --- |\n| ![outer `code` bold ![inner](inner.png)](outer.png) |\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert!(rendered + .iter() + .any(|line| line.contains("outer code bold inner"))); + assert!(!rendered.iter().any(|line| line.contains(""))); + assert!(!rendered.iter().any(|line| line.contains("inner.png"))); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 3da19ed..e596a69 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -23,6 +23,7 @@ mod markdown_blocks; mod markdown_embedded; mod markdown_footnotes; mod markdown_html_tag; +mod markdown_images; mod markdown_links; mod markdown_list_blocks; mod markdown_lists; diff --git a/src/tests/theme.rs b/src/tests/theme.rs index 33b123d..6650722 100644 --- a/src/tests/theme.rs +++ b/src/tests/theme.rs @@ -156,6 +156,49 @@ fn theme_picker_restores_original_preset_on_escape() { set_theme_selection(original); } +#[test] +fn theme_preview_and_restore_preserve_image_blocks() { + let _guard = lock_theme_test_state(); + let (ss, theme) = test_assets(); + let ts = ThemeSet::load_defaults(); + let source = "![diagram](diagram.png)\n"; + let parsed = parse_markdown(source, &ss, &theme, &test_md_theme(), false, true); + let image_blocks = parsed.image_blocks.clone(); + let crate::markdown::ParseResult { + lines, + toc, + image_blocks: parsed_image_blocks, + .. + } = parsed; + assert_eq!(parsed_image_blocks, image_blocks); + let mut app = App::new_with_source( + lines, + toc, + AppConfig { + filename: "stdin".to_string(), + source: source.to_string(), + debug_input: false, + watch: false, + filepath: None, + last_file_state: None, + }, + ); + app.set_image_blocks(image_blocks); + + let original = current_theme_selection(); + set_theme_preset(ThemePreset::OceanDark); + app.open_theme_picker(); + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); + assert_eq!(app.image_blocks[0].source, "diagram.png"); + + app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); + assert_eq!(app.image_blocks[0].source, "diagram.png"); + + app.restore_theme_picker_preview(&ss, &ts); + assert_eq!(app.image_blocks[0].source, "diagram.png"); + set_theme_selection(original); +} + #[test] fn theme_picker_caches_previewed_themes_for_reuse() { let _guard = lock_theme_test_state(); From b89c0d4ceb6085d94e215a4e4d0acf626fdc9ee3 Mon Sep 17 00:00:00 2001 From: Xiaofei Zhang Date: Mon, 28 Sep 2026 16:51:34 +0800 Subject: [PATCH 2/2] feat(images): add toggleable kitty image rendering Add a default-disabled kitty-images setting and i/I session toggle. Disabled mode follows upstream text layout, while enabled mode reparses image blocks and cancels background workers safely. --- ARCHITECTURE.md | 11 +- CHANGELOG.md | 2 + README.md | 12 +- TESTING.md | 5 + config.toml | 6 + src/app/code_blocks.rs | 2 +- src/app/content.rs | 2 + src/app/flash.rs | 23 ++ src/app/mod.rs | 15 +- src/app/theme_picker.rs | 2 + src/config.rs | 2 + src/image_runtime.rs | 497 +++++++++++++++++++++++++++++++++-- src/main.rs | 19 +- src/markdown/images.rs | 16 ++ src/markdown/mod.rs | 48 +++- src/markdown/tables.rs | 5 +- src/render/popup.rs | 13 +- src/render/status.rs | 26 +- src/runtime/keyboard.rs | 8 + src/runtime/mod.rs | 17 ++ src/tests/app.rs | 22 +- src/tests/config.rs | 34 +++ src/tests/markdown_images.rs | 95 +++++-- src/tests/render.rs | 55 +++- src/tests/theme.rs | 6 +- 25 files changed, 865 insertions(+), 78 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4cf9910..6f588a1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -35,7 +35,7 @@ - `syntax.rs` : syntect code highlighting and language resolution - `fences.rs` : code fence normalization (nested fences, tilde fences) - `links.rs` : link span detection and construction - - `images.rs` : Markdown image block metadata, aspect-aware height calculation, and text placeholder layout + - `images.rs` : Markdown image block metadata, image aspect ratio height calculation, and text placeholder layout - `tables.rs` : table construction, event handling, and rendering - `table_layout.rs` : table cell sizing, wrapping, and alignment algorithms - `latex.rs` : LaTeX-to-Unicode conversion: `unicodeit` + postprocessing for `\frac`, `\sqrt`, `^{}`, `_{}` @@ -91,8 +91,8 @@ - `src/image_runtime.rs` - Kitty-only capability detection and terminal protocol setup - local image path resolution, safety limits, and decode cache keys - - background image decode/resize/encode worker and result polling - - scroll-aware Kitty `SlicedImage` placement inside the TUI content area + - background image decode/resize/encode worker, cancellation, and result polling + - scroll-aware Kitty `SlicedImage` placement and `i/I` runtime enable/disable lifecycle - `src/terminal.rs` - raw mode / alternate screen lifecycle @@ -130,10 +130,10 @@ - a file argument, or - `stdin`, or - the file picker if no input is provided interactively. -3. `markdown/` parses the source into rendered lines, TOC, link spans, code blocks, and image block metadata. Local image headers are probed for dimensions so each image reserves an aspect-aware text placeholder area before full decoding begins. +3. `markdown/` parses the source into rendered lines, TOC, link spans, code blocks, and image block metadata. Image layout is a parse-time decision: when image rendering is off, image events take the upstream text path and no image blocks or reserved rows are created. When enabled, local image headers are probed for dimensions so each image reserves a placeholder area sized from its aspect ratio before full decoding begins. 4. If `--inline` is active, `inline.rs` writes the text representation to stdout and exits; Kitty image sequences are intentionally excluded from this path. 5. `App` stores the state, image blocks, and the Kitty image runtime. -6. After terminal initialization, `image_runtime.rs` detects Kitty only when the document contains renderable local images. The runtime queues visible images to its background worker and polls completed protocol states from the main event loop. +6. After terminal initialization, `image_runtime.rs` detects Kitty only when `kitty-images` is enabled (config default: false) and the document contains renderable local images. The runtime queues visible images to its background worker and polls completed protocol states from the main event loop. `i/I` reparses the document for the new layout, then queues a deferred enable request for the next loop iteration; disabling cancels the current worker generation and the reparse removes image blocks and reserved rows. 7. `runtime.rs` runs the event loop: - processes the pending picker queue and spawns the loading thread - polls picker loading and image worker results, installing them when ready @@ -161,6 +161,7 @@ - caches are refreshed and image base paths are updated - image load: + - `kitty-images` defaults to false; the `i/I` hotkey changes only the current session - visible local images are fingerprinted by path, file metadata, and target cell size - changed or newly visible images are decoded and Kitty-encoded in the background - failed, remote, SVG, oversized, and non-Kitty images retain the text placeholder diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d57a3a..d5e78ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - render local PNG, JPEG, GIF, and WebP images in the interactive Kitty TUI with background decoding and scroll-aware placement +- add `kitty-images` configuration (disabled by default) and an `i/I` session toggle that cancels image workers when disabled +- make disabled image mode match upstream Markdown output by showing alt text without reserved image geometry - retain readable image placeholders for unsupported terminals, invalid images, tables, footnotes, and `--inline` output ## [[1.28.2](https://github.com/RivoLink/leaf/releases/tag/1.28.2)] - 2026-09-14 diff --git a/README.md b/README.md index e4b31ef..511f084 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ cat TESTING.md | leaf ## Inline Images (Kitty TUI) -When running the interactive TUI in [Kitty](https://sw.kovidgoyal.net/kitty/), Leaf renders local Markdown images with Kitty's graphics protocol: +When running the interactive TUI in [Kitty](https://sw.kovidgoyal.net/kitty/), Leaf can render local Markdown images with Kitty's graphics protocol. Image rendering is **disabled by default**. Set `kitty-images = true` in `config.toml`, set `LEAF_KITTY_IMAGES=1`, or press `i` / `I` in the TUI to enable it for the current session: ```markdown ![Architecture diagram](images/architecture.png) @@ -179,7 +179,9 @@ When running the interactive TUI in [Kitty](https://sw.kovidgoyal.net/kitty/), L Supported formats are PNG, JPEG, GIF, and WebP. Animated images display their first frame. Relative paths are resolved from the Markdown file's directory; files read from stdin are resolved from the current working directory. -Images are decoded and encoded in a background worker, then rendered inside an aspect-aware document block so scrolling, search, line numbers, TOC resizing, reloads, and terminal resizing keep working. Local image dimensions determine the reserved height, clamped to 12–40 rows; unavailable dimensions use the 12-row minimum. Missing, oversized, remote, SVG, and unsupported images keep a readable image placeholder with their alt text and source. +Images are decoded and encoded in a background worker, then rendered inside a document block whose height is based on the image aspect ratio, so scrolling, search, line numbers, TOC resizing, reloads, and terminal resizing keep working. Local image dimensions determine the reserved height, clamped to 12–40 rows; unavailable dimensions use the 12-row minimum. Missing, oversized, remote, SVG, and unsupported images keep a readable image placeholder with their alt text and source. + +When image rendering is off, Leaf uses its normal upstream Markdown layout: only the alt text is shown, with no image border and no extra reserved rows. Pressing `i/I` reparses the document to create image blocks; turning it off reparses again to remove them. This first stage intentionally supports only Kitty in the interactive TUI. Other terminals and `--inline` output show the same text placeholder and never receive Kitty control sequences. @@ -243,6 +245,7 @@ This opens the configuration file in your editor. If the file does not exist yet theme = "ocean" # arctic, forest, ocean, solarized-dark, or a custom theme file editor = "nano" # any editor in PATH watch = false # auto-reload when opening a file +kitty-images = false # enable local images in the Kitty TUI (toggle with i/I) width = 80 # maximum content width (min: 20, default: terminal width) extras = ["txt", "rs"] # extra file types shown in the picker file-picker-width = "75%" # width of the file picker (fuzzy + file browser) @@ -353,7 +356,7 @@ See [`gruvbox.toml`](gruvbox.toml) for a complete example with all available col | `w` | Toggle watch mode | `Double-Click` (code) | Copy code block | | `r` | Force reload (watch mode) | `Shift+Drag` | Select text | | `t` | Toggle TOC sidebar | `Option+Drag` | Select text (iTerm2) | -| `q` | Quit | | | +| `i` / `I` | Toggle Kitty images | `q` | Quit | ## Features @@ -363,7 +366,7 @@ See [`gruvbox.toml`](gruvbox.toml) for a complete example with all available col - **Editor integration** : *Open the current file in your preferred editor*. - **Frontmatter support** : *YAML frontmatter rendered as a table (horizontal or vertical based on key count)*. - **Rich Markdown rendering** : *Tables, lists, blockquotes, rules, bold, italic, and strikethrough*. -- **Kitty images** : *Local PNG, JPEG, GIF, and WebP images render in the interactive Kitty TUI with scroll-aware placeholders*. +- **Kitty images** : *Optional local PNG, JPEG, GIF, and WebP rendering in the interactive Kitty TUI, disabled by default and toggled with `i/I`*. - **GitHub extras** : *Alert callouts, task list checkboxes, and `==mark==` text highlighting*. - **Inline HTML tags** : *Common inline formatting tags (``, ``, ``, ...) rendered with proper styling*. - **Extra file types** : *Open any file; code files get syntax highlighting, text files render as Markdown*. @@ -395,6 +398,7 @@ leaf --watch notes.md ### Images remain placeholders +- Image rendering is disabled by default: set `kitty-images = true`, use `LEAF_KITTY_IMAGES=1`, or press `i/I` in the TUI. - Confirm the interactive TUI is running in Kitty, not `--inline` mode. - Use a local PNG, JPEG, GIF, or WebP path; HTTP(S), data URLs, and SVG are not downloaded or rendered. - Relative image paths are resolved from the Markdown file's directory. diff --git a/TESTING.md b/TESTING.md index 5024a01..1b06cc8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -55,6 +55,11 @@ Confirm these render correctly: Run these checks in Kitty with `leaf TESTING.md`: +- confirm the local preview image starts as upstream alt text because `kitty-images` defaults to false, with no image border or reserved rows +- press `i` and confirm the image renders after the `Images enabled` status flash +- press `I` and confirm the document collapses back to upstream alt text with no blank image-sized area or stale Kitty pixels +- press `i` again, then confirm scrolling, resize, TOC, and reload behavior +- start with `LEAF_KITTY_IMAGES=1` and confirm the image renders without a hotkey press - confirm the local preview image renders inside its bordered block - scroll down until the image is partially visible, then scroll through and past it - toggle line numbers and confirm the image shifts with the text gutter diff --git a/config.toml b/config.toml index 90e809e..2934eed 100644 --- a/config.toml +++ b/config.toml @@ -59,6 +59,12 @@ theme = "ocean" # Default: false watch = false +# Render local Markdown images in the interactive Kitty TUI +# Press i or I in the TUI to toggle images for the current session. +# Priority: LEAF_KITTY_IMAGES > this setting > default (false) +# Accepted env values: 1 = true, 0 = false +kitty-images = false + # Extra file types shown in the file picker # Add other extensions to also show them (without the dot). # Code files (py, sh, css, html...) get syntax highlighting. diff --git a/src/app/code_blocks.rs b/src/app/code_blocks.rs index a0e9d6a..c97a38f 100644 --- a/src/app/code_blocks.rs +++ b/src/app/code_blocks.rs @@ -13,7 +13,7 @@ impl App { self.image_runtime.sync_blocks(&image_blocks); self.image_blocks = image_blocks; self.store_current_theme_preview(); - self.try_enable_kitty_images(); + self.reconcile_kitty_images_after_content_change(); } pub(crate) fn is_code_select_mode(&self) -> bool { diff --git a/src/app/content.rs b/src/app/content.rs index 8148aaa..8934e33 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -106,6 +106,7 @@ impl App { self.file_mode, self.code_line_numbers, Some(&path), + self.kitty_images_enabled, ); let first_load = self.filepath.is_none(); @@ -151,6 +152,7 @@ impl App { self.file_mode, self.code_line_numbers, self.filepath.as_deref(), + self.kitty_images_enabled, ); let new_total = parsed.lines.len(); diff --git a/src/app/flash.rs b/src/app/flash.rs index afbe344..74b5673 100644 --- a/src/app/flash.rs +++ b/src/app/flash.rs @@ -42,6 +42,14 @@ pub(crate) enum CodeBlockFlash { NoneVisible, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ImageFlash { + Enabled, + Disabled, + Unavailable, + NoImages, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum HistoryFlash { WriteFailed, @@ -187,6 +195,21 @@ impl App { self.code_block_flash.as_ref().map(|(f, t)| (f, t)) } + pub(crate) fn set_image_flash(&mut self, flash: ImageFlash) { + self.image_flash = Some((flash, Instant::now())); + self.status_cache_key = None; + } + + pub(crate) fn image_flash(&self) -> Option<(&ImageFlash, &Instant)> { + self.image_flash + .as_ref() + .map(|(flash, started)| (flash, started)) + } + + pub(crate) fn clear_image_flash(&mut self) { + self.image_flash = None; + } + pub(crate) fn set_history_flash(&mut self, flash: HistoryFlash) { self.history_flash = Some((flash, Instant::now())); } diff --git a/src/app/mod.rs b/src/app/mod.rs index a354eb7..6f4878c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -40,7 +40,8 @@ pub(crate) use content::{FileChange, FileState}; mod flash; pub(crate) use flash::{ - CodeBlockFlash, EditorFlash, HistoryFlash, LinkFlash, PathFlash, WatchFlash, FLASH_DURATION_MS, + CodeBlockFlash, EditorFlash, HistoryFlash, ImageFlash, LinkFlash, PathFlash, WatchFlash, + FLASH_DURATION_MS, }; pub(crate) mod history; @@ -92,6 +93,7 @@ pub(crate) struct StatusCacheKey { watch: bool, flash_active: bool, editor_flash_active: bool, + image_flash_active: bool, file_picker_open: bool, picker_loading: bool, watch_flash_active: bool, @@ -176,6 +178,9 @@ pub(crate) struct App { pub(crate) code_blocks: Vec, pub(crate) image_blocks: Vec, pub(crate) image_runtime: ImageRuntime, + pub(crate) kitty_images_enabled: bool, + pub(crate) kitty_images_enable_requested: bool, + image_flash: Option<(ImageFlash, Instant)>, pub(crate) code_select: Option, code_block_flash: Option<(CodeBlockFlash, Instant)>, link_flash: Option<(LinkFlash, Instant)>, @@ -346,6 +351,9 @@ impl App { code_blocks: Vec::new(), image_blocks: Vec::new(), image_runtime, + kitty_images_enabled: false, + kitty_images_enable_requested: false, + image_flash: None, code_select: None, code_block_flash: None, link_flash: None, @@ -710,6 +718,11 @@ impl App { .as_ref() .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) .unwrap_or(false), + image_flash_active: self + .image_flash + .as_ref() + .map(|(_, t)| t.elapsed() < Duration::from_millis(FLASH_DURATION_MS)) + .unwrap_or(false), file_picker_open: self.is_file_picker_open(), picker_loading: self.is_picker_loading(), watch_flash_active: self diff --git a/src/app/theme_picker.rs b/src/app/theme_picker.rs index 6f0ef2f..0cb4b5a 100644 --- a/src/app/theme_picker.rs +++ b/src/app/theme_picker.rs @@ -135,6 +135,7 @@ impl App { self.file_mode, self.code_line_numbers, self.filepath.as_deref(), + self.kitty_images_enabled, ); self.store_theme_preview(preset, &parsed.lines, &parsed.toc, &parsed.image_blocks); self.replace_content(parsed); @@ -161,6 +162,7 @@ impl App { self.file_mode, self.code_line_numbers, self.filepath.as_deref(), + self.kitty_images_enabled, ); self.replace_content(parsed); } diff --git a/src/config.rs b/src/config.rs index 161f59f..a9790b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,8 @@ pub(crate) struct LeafConfig { pub(crate) theme: Option, pub(crate) editor: Option, pub(crate) watch: Option, + #[serde(rename = "kitty-images")] + pub(crate) kitty_images: Option, pub(crate) width: Option, pub(crate) extras: Vec, #[serde(rename = "main-line-numbers")] diff --git a/src/image_runtime.rs b/src/image_runtime.rs index 49a856b..8436fb8 100644 --- a/src/image_runtime.rs +++ b/src/image_runtime.rs @@ -2,7 +2,11 @@ use std::{ collections::{HashMap, HashSet}, env, fs, path::{Path, PathBuf}, - sync::mpsc::{self, Receiver, Sender}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, Sender}, + Arc, Mutex, + }, thread, time::{Duration, Instant, SystemTime}, }; @@ -15,13 +19,20 @@ use ratatui_image::{ sliced::{SignedPosition, SlicedImage, SlicedProtocol}, Resize, }; +use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; -use crate::{app::App, markdown::ImageBlockInfo}; +#[cfg(test)] +use crate::app::AppConfig; +use crate::{ + app::{App, ImageFlash}, + markdown::ImageBlockInfo, +}; const MAX_IMAGE_FILE_BYTES: u64 = 20 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 16_384; const MAX_IMAGE_ALLOC_BYTES: u64 = 128 * 1024 * 1024; const IMAGE_WORKER_POLL: Duration = Duration::from_millis(50); +static IMAGE_DECODE_LOCK: Mutex<()> = Mutex::new(()); #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct ImageSize { @@ -44,6 +55,7 @@ struct ImageJob { picker: Picker, path: PathBuf, attempts: u8, + cancel: Arc, } struct ImageResult { @@ -71,6 +83,8 @@ pub(crate) struct ImageRuntime { entries: HashMap, document_dir: PathBuf, terminal_ready: bool, + active: bool, + cancel_worker: Option>, } impl ImageRuntime { @@ -82,6 +96,8 @@ impl ImageRuntime { entries: HashMap::new(), document_dir: document_dir(document_path), terminal_ready: false, + active: false, + cancel_worker: None, } } @@ -93,22 +109,36 @@ impl ImageRuntime { } } - pub(crate) fn enable_kitty(&mut self) { - if self.picker.is_some() { - return; + pub(crate) fn enable_kitty(&mut self) -> bool { + if self.active { + return true; + } + if self.picker.is_none() { + let mut picker = match Picker::from_query_stdio() { + Ok(picker) => picker, + Err(_) => return false, + }; + picker.set_protocol_type(ProtocolType::Kitty); + self.picker = Some(picker); + } + if self.picker.is_none() { + return false; } - let mut picker = match Picker::from_query_stdio() { - Ok(picker) => picker, - Err(_) => return, - }; - picker.set_protocol_type(ProtocolType::Kitty); let (job_sender, job_receiver) = mpsc::channel(); let (result_sender, result_receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); let worker = thread::Builder::new() .name("leaf-kitty-images".to_string()) .spawn(move || { while let Ok(job) = job_receiver.recv() { - let result = decode_and_prepare(&job).map_err(|err| err.to_string()); + if worker_cancel.load(Ordering::Relaxed) { + break; + } + let result = decode_job_guarded(&job).map_err(|err| err.to_string()); + if worker_cancel.load(Ordering::Relaxed) { + break; + } if result_sender .send(ImageResult { block_id: job.block_id, @@ -123,11 +153,27 @@ impl ImageRuntime { } }); if worker.is_err() { - return; + return false; } - self.picker = Some(picker); + self.active = true; + self.cancel_worker = Some(cancel); self.job_sender = Some(job_sender); self.result_receiver = Some(result_receiver); + true + } + + pub(crate) fn disable(&mut self) { + self.active = false; + if let Some(cancel) = self.cancel_worker.take() { + cancel.store(true, Ordering::Relaxed); + } + self.job_sender = None; + self.result_receiver = None; + self.entries.clear(); + } + + pub(crate) fn is_active(&self) -> bool { + self.active } pub(crate) fn sync_blocks(&mut self, blocks: &[ImageBlockInfo]) { @@ -136,6 +182,9 @@ impl ImageRuntime { } pub(crate) fn poll_results(&mut self) -> bool { + if !self.active { + return false; + } let Some(receiver) = &self.result_receiver else { return false; }; @@ -167,6 +216,9 @@ impl ImageRuntime { } pub(crate) fn poll_delay(&self) -> Option { + if !self.active { + return None; + } let now = Instant::now(); self.entries .values() @@ -188,7 +240,11 @@ impl ImageRuntime { scroll: usize, x_offset: usize, ) { - if self.picker.is_none() || content_area.width == 0 || content_area.height == 0 { + if !self.active + || self.picker.is_none() + || content_area.width == 0 + || content_area.height == 0 + { return; } let visible_end = scroll.saturating_add(content_area.height as usize); @@ -273,6 +329,9 @@ impl ImageRuntime { path: PathBuf, size: ratatui::layout::Size, ) { + if !self.active { + return; + } let mut attempts = 0; if let Some(entry) = self.entries.get(&block_id) { if entry.key == key { @@ -296,6 +355,9 @@ impl ImageRuntime { let Some(sender) = &self.job_sender else { return; }; + let Some(cancel) = self.cancel_worker.as_ref().map(Arc::clone) else { + return; + }; self.entries.insert( block_id, ImageEntry { @@ -310,25 +372,115 @@ impl ImageRuntime { picker, path, attempts, + cancel, }); } } impl App { + pub(crate) fn set_kitty_images_enabled(&mut self, enabled: bool) { + self.kitty_images_enabled = enabled; + self.kitty_images_enable_requested = false; + if !enabled { + self.image_runtime.disable(); + } + } + + #[cfg(test)] + pub(crate) fn is_kitty_images_enabled(&self) -> bool { + self.kitty_images_enabled + } + + pub(crate) fn is_kitty_images_rendering(&self) -> bool { + self.kitty_images_enabled && self.image_runtime.is_active() + } + + #[cfg(test)] + pub(crate) fn set_kitty_images_rendering_for_test(&mut self) { + self.image_runtime.terminal_ready = true; + self.image_runtime.picker = Some(ratatui_image::picker::Picker::halfblocks()); + self.image_runtime.active = true; + } + + #[cfg(test)] + pub(crate) fn kitty_images_enable_requested(&self) -> bool { + self.kitty_images_enable_requested + } + + pub(crate) fn toggle_kitty_images(&mut self, ss: &SyntaxSet, themes: &ThemeSet) { + if self.kitty_images_enabled { + self.kitty_images_enabled = false; + self.kitty_images_enable_requested = false; + self.image_runtime.disable(); + self.set_image_flash(ImageFlash::Disabled); + self.reparse_source(ss, themes); + return; + } + if !is_kitty_terminal() { + self.set_image_flash(ImageFlash::Unavailable); + return; + } + self.kitty_images_enabled = true; + self.kitty_images_enable_requested = true; + self.reparse_source(ss, themes); + if !self.image_blocks.iter().any(|block| block.renderable) { + self.kitty_images_enabled = false; + self.kitty_images_enable_requested = false; + self.image_runtime.disable(); + self.reparse_source(ss, themes); + self.set_image_flash(ImageFlash::NoImages); + } + } + + pub(crate) fn reconcile_kitty_images_after_content_change(&mut self) -> bool { + if self.kitty_images_enable_requested + || !self.kitty_images_enabled + || !self.image_runtime.terminal_ready + || self.image_runtime.is_active() + || !self.image_blocks.iter().any(|block| block.renderable) + { + return false; + } + self.kitty_images_enable_requested = true; + true + } + + pub(crate) fn process_pending_kitty_images(&mut self) -> bool { + if !std::mem::take(&mut self.kitty_images_enable_requested) { + return false; + } + if self.try_enable_kitty_images() { + self.set_image_flash(ImageFlash::Enabled); + } else { + self.kitty_images_enabled = false; + self.image_runtime.disable(); + self.set_image_flash(ImageFlash::Unavailable); + } + true + } + pub(crate) fn initialize_kitty_images(&mut self) -> bool { self.image_runtime.terminal_ready = true; - self.try_enable_kitty_images() + if !self.kitty_images_enabled || !self.image_blocks.iter().any(|block| block.renderable) { + return self.image_runtime.is_active(); + } + if self.image_runtime.enable_kitty() { + return true; + } + self.kitty_images_enabled = false; + self.image_runtime.disable(); + false } pub(crate) fn try_enable_kitty_images(&mut self) -> bool { - if !self.image_runtime.terminal_ready - || self.image_blocks.is_empty() + if !self.kitty_images_enabled + || !self.image_runtime.terminal_ready + || !self.image_blocks.iter().any(|block| block.renderable) || !is_kitty_terminal() { - return self.image_runtime.picker.is_some(); + return self.image_runtime.is_active(); } - self.image_runtime.enable_kitty(); - self.image_runtime.picker.is_some() + self.image_runtime.enable_kitty() } pub(crate) fn poll_image_results(&mut self) -> bool { @@ -358,6 +510,12 @@ fn retry_delay(attempts: u8) -> Duration { } fn decode_and_prepare(job: &ImageJob) -> AnyResult { + let _decode_guard = IMAGE_DECODE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if job.cancel.load(Ordering::Relaxed) { + anyhow::bail!("image decode cancelled"); + } if job.key.len > MAX_IMAGE_FILE_BYTES { anyhow::bail!("image file exceeds {} bytes", MAX_IMAGE_FILE_BYTES); } @@ -376,6 +534,11 @@ fn decode_and_prepare(job: &ImageJob) -> AnyResult { )?) } +fn decode_job_guarded(job: &ImageJob) -> AnyResult { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| decode_and_prepare(job))) + .unwrap_or_else(|_| Err(anyhow::anyhow!("image decoder panicked"))) +} + fn document_dir(document_path: Option<&Path>) -> PathBuf { document_path .and_then(Path::parent) @@ -415,7 +578,56 @@ fn is_kitty_environment(mut lookup: impl FnMut(&str) -> Option) -> bool #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; + use std::{collections::HashMap, sync::Mutex}; + + static IMAGE_ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard(Vec<(&'static str, Option)>); + + impl EnvGuard { + fn save(names: &[&'static str]) -> Self { + Self( + names + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(), + ) + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (name, value) in &self.0 { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + } + } + + fn test_parse_assets() -> (SyntaxSet, ThemeSet) { + ( + SyntaxSet::load_defaults_newlines(), + ThemeSet::load_defaults(), + ) + } + + fn test_image_block(id: usize) -> ImageBlockInfo { + ImageBlockInfo { + id, + source: "fixture.png".to_string(), + alt: "fixture".to_string(), + title: String::new(), + source_line: 1, + rendered_start: 0, + rendered_end: 11, + rendered_width: 22, + prefix_width: 0, + renderable: true, + } + } #[test] fn kitty_environment_detection_accepts_supported_markers() { @@ -444,6 +656,243 @@ mod tests { assert!(has_supported_extension("image.PNG")); } + #[test] + fn disable_stops_runtime_and_cancels_worker() { + let mut runtime = ImageRuntime::new(None); + let (job_sender, _job_receiver) = mpsc::channel(); + let (_result_sender, result_receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + runtime.picker = Some(Picker::halfblocks()); + runtime.job_sender = Some(job_sender); + runtime.result_receiver = Some(result_receiver); + runtime.cancel_worker = Some(Arc::clone(&cancel)); + runtime.entries.insert( + 1, + ImageEntry { + key: ImageKey { + path: PathBuf::from("fixture.png"), + modified: None, + len: 1, + size: ImageSize { + width: 1, + height: 1, + }, + }, + state: ImageState::Loading, + }, + ); + runtime.active = true; + + runtime.disable(); + + assert!(!runtime.is_active()); + assert!(runtime.job_sender.is_none()); + assert!(runtime.result_receiver.is_none()); + assert!(runtime.entries.is_empty()); + assert!(runtime.picker.is_some()); + assert!(cancel.load(Ordering::Relaxed)); + assert!(!runtime.poll_results()); + assert!(runtime.poll_delay().is_none()); + } + + #[test] + fn app_starts_with_images_disabled_and_reports_no_images() { + let _guard = IMAGE_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::save(&["KITTY_PID", "KITTY_WINDOW_ID", "TERM"]); + std::env::remove_var("KITTY_WINDOW_ID"); + std::env::set_var("KITTY_PID", "1"); + std::env::set_var("TERM", "xterm-256color"); + let (ss, themes) = test_parse_assets(); + let mut app = App::new( + Vec::new(), + Vec::new(), + "stdin".to_string(), + false, + false, + None, + None, + ); + assert!(!app.is_kitty_images_enabled()); + app.toggle_kitty_images(&ss, &themes); + assert!(!app.is_kitty_images_enabled()); + assert!(app.image_blocks.is_empty()); + assert!(matches!( + app.image_flash().map(|(flash, _)| flash), + Some(ImageFlash::NoImages) + )); + } + + #[test] + fn toggle_without_kitty_reports_unavailable_without_enabling() { + let _guard = IMAGE_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::save(&["KITTY_PID", "KITTY_WINDOW_ID", "TERM"]); + std::env::remove_var("KITTY_PID"); + std::env::remove_var("KITTY_WINDOW_ID"); + std::env::set_var("TERM", "xterm-256color"); + let (ss, themes) = test_parse_assets(); + let source = "![fixture](fixture.png)".to_string(); + let mut app = App::new_with_source( + Vec::new(), + Vec::new(), + AppConfig { + filename: "stdin".to_string(), + source: source.clone(), + debug_input: false, + watch: false, + filepath: None, + last_file_state: None, + }, + ); + app.set_image_blocks(vec![test_image_block(1)]); + + app.toggle_kitty_images(&ss, &themes); + + assert!(!app.is_kitty_images_enabled()); + assert!(!app.kitty_images_enable_requested()); + assert!(!app.image_runtime.is_active()); + assert!(matches!( + app.image_flash().map(|(flash, _)| flash), + Some(ImageFlash::Unavailable) + )); + } + + #[test] + fn configured_enabled_state_survives_initial_empty_picker_document() { + let mut app = App::new( + Vec::new(), + Vec::new(), + "stdin".to_string(), + false, + false, + None, + None, + ); + app.set_kitty_images_enabled(true); + assert!(!app.initialize_kitty_images()); + assert!(app.is_kitty_images_enabled()); + assert!(!app.image_runtime.is_active()); + } + + #[test] + fn configured_enable_failure_after_picker_load_returns_to_off() { + let _guard = IMAGE_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::save(&["KITTY_PID", "KITTY_WINDOW_ID", "TERM"]); + std::env::remove_var("KITTY_PID"); + std::env::remove_var("KITTY_WINDOW_ID"); + std::env::set_var("TERM", "xterm-256color"); + + let mut app = App::new( + Vec::new(), + Vec::new(), + "stdin".to_string(), + false, + false, + None, + None, + ); + app.set_kitty_images_enabled(true); + assert!(!app.initialize_kitty_images()); + assert!(app.is_kitty_images_enabled()); + app.set_image_blocks(vec![test_image_block(1)]); + assert!(app.kitty_images_enable_requested()); + assert!(!app.image_runtime.is_active()); + app.process_pending_kitty_images(); + + assert!(!app.is_kitty_images_enabled()); + assert!(matches!( + app.image_flash().map(|(flash, _)| flash), + Some(ImageFlash::Unavailable) + )); + } + + #[test] + fn app_toggle_disables_active_images_immediately() { + let (ss, themes) = test_parse_assets(); + let mut app = App::new( + Vec::new(), + Vec::new(), + "stdin".to_string(), + false, + false, + None, + None, + ); + app.set_image_blocks(vec![test_image_block(1)]); + app.set_kitty_images_enabled(true); + app.image_runtime.active = true; + app.toggle_kitty_images(&ss, &themes); + assert!(!app.is_kitty_images_enabled()); + assert!(!app.image_runtime.is_active()); + assert!(app.image_blocks.is_empty()); + assert!(matches!( + app.image_flash().map(|(flash, _)| flash), + Some(ImageFlash::Disabled) + )); + } + + #[test] + fn app_toggle_defers_kitty_query_until_runtime_processing() { + let _guard = IMAGE_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::save(&["KITTY_PID", "KITTY_WINDOW_ID", "TERM"]); + std::env::remove_var("KITTY_WINDOW_ID"); + std::env::set_var("KITTY_PID", "1"); + std::env::set_var("TERM", "xterm-256color"); + let (ss, themes) = test_parse_assets(); + let source = "![fixture](fixture.png)".to_string(); + let mut app = App::new_with_source( + Vec::new(), + Vec::new(), + AppConfig { + filename: "stdin".to_string(), + source: source.clone(), + debug_input: false, + watch: false, + filepath: None, + last_file_state: None, + }, + ); + app.set_image_blocks(vec![test_image_block(1)]); + app.toggle_kitty_images(&ss, &themes); + assert!(app.is_kitty_images_enabled()); + assert!(app.kitty_images_enable_requested()); + assert!(!app.image_runtime.is_active()); + assert!(app.image_blocks.iter().any(|block| block.renderable)); + } + + #[test] + fn deferred_request_enables_cached_picker_without_stdio_query() { + let _guard = IMAGE_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::save(&["KITTY_PID", "KITTY_WINDOW_ID", "TERM"]); + std::env::set_var("KITTY_PID", "1"); + let (ss, themes) = test_parse_assets(); + let source = "![fixture](fixture.png)".to_string(); + let mut app = App::new_with_source( + Vec::new(), + Vec::new(), + AppConfig { + filename: "stdin".to_string(), + source: source.clone(), + debug_input: false, + watch: false, + filepath: None, + last_file_state: None, + }, + ); + app.set_image_blocks(vec![test_image_block(1)]); + app.toggle_kitty_images(&ss, &themes); + app.image_runtime.terminal_ready = true; + app.image_runtime.picker = Some(Picker::halfblocks()); + + assert!(app.process_pending_kitty_images()); + assert!(app.is_kitty_images_rendering()); + assert!(matches!( + app.image_flash().map(|(flash, _)| flash), + Some(ImageFlash::Enabled) + )); + + app.image_runtime.disable(); + } + #[test] fn failed_image_is_retried_after_backoff() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -463,7 +912,9 @@ mod tests { let (key, path, size) = runtime.resolve_key(&block).expect("fixture image"); let (sender, receiver) = mpsc::channel(); runtime.picker = Some(Picker::halfblocks()); + runtime.active = true; runtime.job_sender = Some(sender); + runtime.cancel_worker = Some(Arc::new(AtomicBool::new(false))); runtime.entries.insert( block.id, ImageEntry { @@ -505,17 +956,19 @@ mod tests { let (key, path, size) = runtime.resolve_key(&block).expect("fixture image"); let mut picker = Picker::halfblocks(); picker.set_protocol_type(ProtocolType::Kitty); - let protocol = decode_and_prepare(&ImageJob { + let protocol = decode_job_guarded(&ImageJob { block_id: block.id, key: key.clone(), size, picker: picker.clone(), path, attempts: 0, + cancel: Arc::new(AtomicBool::new(false)), }) .expect("decode fixture"); assert!(matches!(protocol, SlicedProtocol::Kitty(_))); runtime.picker = Some(picker); + runtime.active = true; runtime.entries.insert( block.id, ImageEntry { diff --git a/src/main.rs b/src/main.rs index 15b6d87..4032ab5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,11 +60,13 @@ pub(crate) use read_stdin_limited as read_stdin_with_limit; #[cfg(test)] pub(crate) use render::wrap_path_lines; #[cfg(test)] +pub(crate) use resolve_kitty_images as test_resolve_kitty_images; +#[cfg(test)] pub(crate) use resolve_main_line_numbers as test_resolve_main_line_numbers; #[cfg(test)] pub(crate) use resolve_tab_title_length_n as test_resolve_tab_title_length_n; #[cfg(test)] -pub(crate) use runtime::should_handle_key; +pub(crate) use runtime::{is_kitty_images_key, should_handle_key}; #[cfg(test)] pub(crate) use theme::{ parse_theme_color, parse_theme_preset, theme_preset_label, CustomThemeConfig, ThemePreset, @@ -135,6 +137,17 @@ fn resolve_code_line_numbers(config_value: Option) -> bool { config_value.unwrap_or(true) } +fn resolve_kitty_images(config_value: Option) -> bool { + if let Ok(val) = std::env::var("LEAF_KITTY_IMAGES") { + match val.as_str() { + "1" => return true, + "0" => return false, + _ => {} + } + } + config_value.unwrap_or(false) +} + const LEAF_TAB_PREFIX_LEN: usize = 6; const MIN_TAB_TITLE_LENGTH: i32 = 20; @@ -296,6 +309,7 @@ fn main() -> Result<()> { let max_width = resolve_configured_width(cli_width, user_config.width); let main_line_numbers = resolve_main_line_numbers(user_config.main_line_numbers); let code_line_numbers = resolve_code_line_numbers(user_config.code_line_numbers); + let kitty_images = resolve_kitty_images(user_config.kitty_images); let tab_title_length = resolve_tab_title_length_n(user_config.tab_title_length); let tab_title_max_filename_len = tab_title_length.and_then(tab_title_n_to_max_filename_len); let file_picker_width = resolve_file_picker_width(user_config.file_picker_width); @@ -456,6 +470,7 @@ fn main() -> Result<()> { file_mode, code_line_numbers, filepath.as_deref(), + false, ); while parsed.lines.last().is_some_and(|l| { @@ -480,6 +495,7 @@ fn main() -> Result<()> { file_mode, code_line_numbers, filepath.as_deref(), + kitty_images, ); let crate::markdown::ParseResult { lines, @@ -502,6 +518,7 @@ fn main() -> Result<()> { last_file_state, }, ); + app.set_kitty_images_enabled(kitty_images); app.set_link_spans(link_spans); app.set_code_blocks(code_blocks); app.set_image_blocks(image_blocks); diff --git a/src/markdown/images.rs b/src/markdown/images.rs index 6c2e4e0..9654941 100644 --- a/src/markdown/images.rs +++ b/src/markdown/images.rs @@ -39,6 +39,22 @@ pub(super) struct ImageRenderContext<'a> { pub(super) image_base_dir: Option<&'a Path>, } +pub(super) fn is_renderable_image_source(source: &str) -> bool { + let source = source.trim(); + if source.is_empty() || source.starts_with("data:") || source.contains("://") { + return false; + } + Path::new(source) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" + ) + }) +} + pub(super) fn push_image_placeholder( lines: &mut Vec>, item_stack: &mut [ItemState], diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index b3cb74b..032d518 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -50,7 +50,7 @@ use blocks::{ }; use fences::normalize_code_fences; pub(crate) use images::ImageBlockInfo; -use images::{push_image_placeholder, ImageRenderContext}; +use images::{is_renderable_image_source, push_image_placeholder, ImageRenderContext}; use links::build_link_spans; use lists::{ end_item, end_list, flush_list_item_spans, list_item_prefix, start_item, start_list, ItemState, @@ -417,9 +417,34 @@ pub(crate) fn parse_markdown( file_mode, code_line_numbers, None, + false, ) } +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn parse_markdown_with_images( + src: &str, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, + md_theme: &MarkdownTheme, + file_mode: bool, + code_line_numbers: bool, + document_path: Option<&Path>, +) -> ParseResult { + parse_markdown_with_width_and_path( + src, + ss, + theme, + DEFAULT_RENDER_WIDTH, + md_theme, + file_mode, + code_line_numbers, + document_path, + true, + ) +} + +#[allow(clippy::too_many_arguments)] pub(crate) fn parse_markdown_with_path( src: &str, ss: &syntect::parsing::SyntaxSet, @@ -428,6 +453,7 @@ pub(crate) fn parse_markdown_with_path( file_mode: bool, code_line_numbers: bool, document_path: Option<&Path>, + render_images: bool, ) -> ParseResult { parse_markdown_with_width_and_path( src, @@ -438,6 +464,7 @@ pub(crate) fn parse_markdown_with_path( file_mode, code_line_numbers, document_path, + render_images, ) } @@ -460,6 +487,7 @@ pub(crate) fn parse_markdown_with_width( file_mode, code_line_numbers, None, + false, ) } @@ -473,6 +501,7 @@ pub(crate) fn parse_markdown_with_width_and_path( file_mode: bool, code_line_numbers: bool, document_path: Option<&Path>, + render_images: bool, ) -> ParseResult { let image_base_dir = document_path.and_then(Path::parent); let original_src = src; @@ -526,7 +555,14 @@ pub(crate) fn parse_markdown_with_width_and_path( .saturating_sub(file_mode_offset) .max(1); if table.is_some() - && handle_table_event(&mut table, &ev, &mut lines, render_width, &mut link_urls) + && handle_table_event( + &mut table, + &ev, + &mut lines, + render_width, + &mut link_urls, + render_images, + ) { if lines.len() > before { state.mark_table_lines(lines.len(), &lines); @@ -577,7 +613,7 @@ pub(crate) fn parse_markdown_with_width_and_path( } MdEvent::Start(Tag::Image { dest_url, title, .. - }) => { + }) if render_images => { image.push(ImageParseState { id: next_image_id, source: dest_url.to_string(), @@ -587,7 +623,7 @@ pub(crate) fn parse_markdown_with_width_and_path( }); next_image_id += 1; } - MdEvent::End(TagEnd::Image) => { + MdEvent::End(TagEnd::Image) if render_images => { if let Some(image_state) = image.pop() { if let Some(parent) = image.last_mut() { if !parent.alt.is_empty() && !image_state.alt.is_empty() { @@ -621,6 +657,8 @@ pub(crate) fn parse_markdown_with_width_and_path( image_base_dir, }, ); + let renderable = !footnotes.is_active() + && is_renderable_image_source(&image_state.source); let block = ImageBlockInfo { id: image_state.id, source: image_state.source, @@ -631,7 +669,7 @@ pub(crate) fn parse_markdown_with_width_and_path( rendered_end: lines.len().saturating_sub(1), rendered_width: layout.rendered_width, prefix_width: layout.prefix_width, - renderable: !footnotes.is_active(), + renderable, }; if footnotes.is_active() { footnotes.record_image_block(block); diff --git a/src/markdown/tables.rs b/src/markdown/tables.rs index ba7213f..8109fcd 100644 --- a/src/markdown/tables.rs +++ b/src/markdown/tables.rs @@ -125,6 +125,7 @@ pub(super) fn handle_table_event( lines: &mut Vec>, render_width: usize, link_urls: &mut Vec, + render_images: bool, ) -> bool { let Some(tb) = table.as_mut() else { return false; @@ -156,7 +157,7 @@ pub(super) fn handle_table_event( } MdEvent::Start(Tag::Image { dest_url, title, .. - }) => { + }) if render_images => { tb.image.push(TableImageState { alt: String::new(), source: dest_url.to_string(), @@ -164,7 +165,7 @@ pub(super) fn handle_table_event( }); true } - MdEvent::End(TagEnd::Image) => { + MdEvent::End(TagEnd::Image) if render_images => { if let Some(image) = tb.image.pop() { if let Some(parent) = tb.image.last_mut() { if !parent.alt.is_empty() && !image.alt.is_empty() { diff --git a/src/render/popup.rs b/src/render/popup.rs index 97f72df..c38fece 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -75,9 +75,9 @@ pub(super) fn popup_footer_line(segments: &[&'static str], bg: Color) -> Line<'s Line::from(spans) } -pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { +pub(super) fn render_help_popup(f: &mut Frame, app: &App) { let theme = app_theme(); - let area = centered_rect(53, 26, f.area()); + let area = centered_rect(53, 27, f.area()); let select_hint = crate::editor::selection_modifier_label(&crate::editor::detect_terminal_emulator()); @@ -88,6 +88,11 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { .fg(theme.ui.toc_accent) .add_modifier(Modifier::BOLD); let text_style = Style::default().fg(theme.ui.toc_primary_inactive); + let image_state = if app.is_kitty_images_rendering() { + "images: on" + } else { + "images: off" + }; let title_style = Style::default() .fg(theme.markdown.heading_2) @@ -191,6 +196,10 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { Span::styled("ctrl+h ", key_style), Span::styled("hist", text_style), ]), + Line::from(vec![ + Span::styled("i/I ", key_style), + Span::styled(image_state, text_style), + ]), Line::from(vec![ Span::styled("p ", key_style), Span::styled("path viewer", text_style), diff --git a/src/render/status.rs b/src/render/status.rs index ab16693..0b24ef7 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -1,8 +1,8 @@ use crate::{ app::{ history::{msg_history_capped, MSG_HISTORY_WRITE_FAILED}, - App, CodeBlockFlash, EditorFlash, HistoryFlash, LinkFlash, PathFlash, WatchFlash, - FLASH_DURATION_MS, + App, CodeBlockFlash, EditorFlash, HistoryFlash, ImageFlash, LinkFlash, PathFlash, + WatchFlash, FLASH_DURATION_MS, }, theme::app_theme, }; @@ -74,6 +74,22 @@ fn watch_flash_section(app: &App) -> Option>> { Some(vec![Span::styled(text, Style::default().fg(fg).bg(bar_bg))]) } +fn image_flash_section(app: &App) -> Option>> { + let (flash, started) = app.image_flash()?; + if started.elapsed() >= std::time::Duration::from_millis(FLASH_DURATION_MS) { + return None; + } + let theme = app_theme(); + let bar_bg = status_bar_bg(); + let (text, fg) = match flash { + ImageFlash::Enabled => (" Images enabled ", theme.ui.status_success_fg), + ImageFlash::Disabled => (" Images disabled ", theme.ui.status_warning_fg), + ImageFlash::Unavailable => (" Kitty unavailable ", theme.ui.status_error_fg), + ImageFlash::NoImages => (" No images ", theme.ui.status_error_fg), + }; + Some(vec![Span::styled(text, Style::default().fg(fg).bg(bar_bg))]) +} + pub(crate) fn status_watch_section(app: &App) -> Option>> { let theme = app_theme(); if !app.is_watch_enabled() { @@ -346,6 +362,12 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { return join_span_sections(vec![left], outer_separator); } + if let Some(flash_section) = image_flash_section(app) { + let mut left = status_brand_section(); + left.extend(flash_section); + return join_span_sections(vec![left], outer_separator); + } + if let Some(flash_section) = watch_flash_section(app) { let mut left = status_brand_section(); left.extend(flash_section); diff --git a/src/runtime/keyboard.rs b/src/runtime/keyboard.rs index dc60f5b..2e7437f 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -7,6 +7,11 @@ use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; use super::mouse::handle_open_in_editor; +pub(crate) fn is_kitty_images_key(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Char('i') | KeyCode::Char('I')) + && !key.modifiers.contains(KeyModifiers::CONTROL) +} + pub(super) enum HandleResult { Continue { redraw: bool }, Break, @@ -418,6 +423,9 @@ pub(super) fn handle_key_event( app.begin_goto_line() } KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_line_numbers(), + _ if is_kitty_images_key(&key) => { + app.toggle_kitty_images(ss, themes); + } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { handle_open_in_editor(terminal, app, ss, themes)?; } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 97cde64..043df7b 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,6 +1,9 @@ mod keyboard; mod mouse; +#[cfg(test)] +pub(crate) use keyboard::is_kitty_images_key; + use crate::{ app::{App, FileChange, FLASH_DURATION_MS}, render::{ui, CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}, @@ -96,6 +99,9 @@ pub(crate) fn run( let _ = app.start_pending_picker_loading(); needs_redraw = true; } + if app.process_pending_kitty_images() { + needs_redraw = true; + } if app.poll_picker_loading() { needs_redraw = true; } @@ -126,6 +132,9 @@ pub(crate) fn run( let editor_flash_timeout = app .editor_flash() .and_then(|(_, started)| EDITOR_FLASH_DURATION.checked_sub(started.elapsed())); + let image_flash_timeout = app + .image_flash() + .and_then(|(_, started)| FLASH_DURATION.checked_sub(started.elapsed())); let watch_flash_timeout = app .watch_flash() .and_then(|(_, started)| WATCH_FLASH_DURATION.checked_sub(started.elapsed())); @@ -154,6 +163,7 @@ pub(crate) fn run( app.image_poll_delay(), flash_timeout, editor_flash_timeout, + image_flash_timeout, watch_flash_timeout, config_flash_timeout, link_flash_timeout, @@ -278,6 +288,13 @@ pub(crate) fn run( } } + if let Some((_, started)) = app.image_flash() { + if started.elapsed() >= FLASH_DURATION { + app.clear_image_flash(); + needs_redraw = true; + } + } + if let Some((_, started)) = app.watch_flash() { if started.elapsed() >= WATCH_FLASH_DURATION { app.clear_watch_flash(); diff --git a/src/tests/app.rs b/src/tests/app.rs index d9e8cae..5e419fa 100644 --- a/src/tests/app.rs +++ b/src/tests/app.rs @@ -3,7 +3,7 @@ use crate::app::{App, AppConfig, FileChange}; use crate::cli::parse_cli; use crate::markdown::{hash_str, parse_markdown, parse_markdown_with_width, read_file_state}; use crate::*; -use crossterm::event::KeyEventKind; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::{ fs, time::{SystemTime, UNIX_EPOCH}, @@ -920,3 +920,23 @@ fn main_line_numbers_config_initializes_and_preserves_across_load_path() { let _ = fs::remove_file(path1); let _ = fs::remove_file(path2); } + +#[test] +fn kitty_images_hotkey_accepts_i_and_shift_i_but_not_ctrl_i() { + assert!(is_kitty_images_key(&KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))); + assert!(is_kitty_images_key(&KeyEvent::new( + KeyCode::Char('I'), + KeyModifiers::SHIFT, + ))); + assert!(!is_kitty_images_key(&KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::CONTROL, + ))); + assert!(!is_kitty_images_key(&KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))); +} diff --git a/src/tests/config.rs b/src/tests/config.rs index e123a97..0d38beb 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -132,6 +132,7 @@ text = [220, 221, 222] fn repository_config_keeps_ocean_default() { let config: LeafConfig = toml::from_str(include_str!("../../config.toml")).unwrap(); assert_eq!(config.theme.as_deref(), Some("ocean")); + assert_eq!(config.kitty_images, Some(false)); assert!(config.themes.is_empty()); } @@ -343,3 +344,36 @@ fn validate_hyper_link_prefix_graphemes() { assert!(!is_valid_hyper_link_prefix(" ")); assert!(!is_valid_hyper_link_prefix("→→")); } + +#[test] +fn parse_kitty_images_config() { + let enabled: LeafConfig = toml::from_str("kitty-images = true").unwrap(); + let disabled: LeafConfig = toml::from_str("kitty-images = false").unwrap(); + let missing: LeafConfig = toml::from_str("theme = \"ocean\"").unwrap(); + assert_eq!(enabled.kitty_images, Some(true)); + assert_eq!(disabled.kitty_images, Some(false)); + assert_eq!(missing.kitty_images, None); +} + +#[test] +fn resolve_kitty_images_defaults_to_false() { + let _guard = super::lock_theme_test_state(); + std::env::remove_var("LEAF_KITTY_IMAGES"); + assert!(!test_resolve_kitty_images(None)); +} + +#[test] +fn resolve_kitty_images_uses_config_then_environment() { + let _guard = super::lock_theme_test_state(); + std::env::remove_var("LEAF_KITTY_IMAGES"); + assert!(test_resolve_kitty_images(Some(true))); + assert!(!test_resolve_kitty_images(Some(false))); + + std::env::set_var("LEAF_KITTY_IMAGES", "1"); + assert!(test_resolve_kitty_images(Some(false))); + std::env::set_var("LEAF_KITTY_IMAGES", "0"); + assert!(!test_resolve_kitty_images(Some(true))); + std::env::set_var("LEAF_KITTY_IMAGES", "invalid"); + assert!(test_resolve_kitty_images(Some(true))); + std::env::remove_var("LEAF_KITTY_IMAGES"); +} diff --git a/src/tests/markdown_images.rs b/src/tests/markdown_images.rs index 60640c7..d9fd00d 100644 --- a/src/tests/markdown_images.rs +++ b/src/tests/markdown_images.rs @@ -1,14 +1,35 @@ use super::{rendered_non_empty_lines, test_assets, test_md_theme}; -use crate::markdown::{line_plain_text, parse_markdown, parse_markdown_with_width}; +use crate::markdown::{ + line_plain_text, parse_markdown, parse_markdown_with_images, parse_markdown_with_width_and_path, +}; + +fn parse_images(src: &str) -> crate::markdown::ParseResult { + let (ss, theme) = test_assets(); + parse_markdown_with_images(src, &ss, &theme, &test_md_theme(), false, true, None) +} + +fn parse_images_with_width(src: &str, width: usize) -> crate::markdown::ParseResult { + let (ss, theme) = test_assets(); + parse_markdown_with_width_and_path( + src, + &ss, + &theme, + width, + &test_md_theme(), + false, + true, + None, + true, + ) +} #[test] fn image_becomes_fixed_height_placeholder_without_duplicate_alt() { - let (ss, theme) = test_assets(); - let src = "Before\n\n![diagram alt](images/diagram.svg \"diagram title\")\n\nAfter\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let src = "Before\n\n![diagram alt](images/diagram.png \"diagram title\")\n\nAfter\n"; + let parsed = parse_images(src); let block = parsed.image_blocks.first().expect("image block"); assert_eq!(block.id, 0); - assert_eq!(block.source, "images/diagram.svg"); + assert_eq!(block.source, "images/diagram.png"); assert_eq!(block.alt, "diagram alt"); assert_eq!(block.title, "diagram title"); assert_eq!(block.source_line, 3); @@ -20,15 +41,22 @@ fn image_becomes_fixed_height_placeholder_without_duplicate_alt() { let rendered: String = parsed.lines.iter().map(line_plain_text).collect(); assert_eq!(rendered.matches("diagram alt").count(), 1); - assert!(rendered.contains("source: images/diagram.svg")); + assert!(rendered.contains("source: images/diagram.png")); assert!(rendered.contains("After")); } +#[test] +fn remote_and_unsupported_images_are_not_renderable() { + let parsed = + parse_images("![remote](https://example.com/image.png)\n\n![vector](images/diagram.svg)\n"); + assert_eq!(parsed.image_blocks.len(), 2); + assert!(parsed.image_blocks.iter().all(|block| !block.renderable)); +} + #[test] fn image_placeholder_preserves_list_and_blockquote_prefixes() { - let (ss, theme) = test_assets(); let src = "- ![list image](list.png)\n\n> ![quote image](quote.png)\n"; - let parsed = parse_markdown_with_width(src, &ss, &theme, 50, &test_md_theme(), false, true); + let parsed = parse_images_with_width(src, 50); assert_eq!(parsed.image_blocks.len(), 2); assert!(parsed .image_blocks @@ -43,15 +71,7 @@ fn image_placeholder_preserves_list_and_blockquote_prefixes() { #[test] fn image_in_heading_is_rendered_as_placeholder() { - let (ss, theme) = test_assets(); - let parsed = parse_markdown( - "## ![heading image](heading.png)\n", - &ss, - &theme, - &test_md_theme(), - false, - true, - ); + let parsed = parse_images("## ![heading image](heading.png)\n"); assert_eq!(parsed.image_blocks.len(), 1); let rendered = rendered_non_empty_lines(&parsed.lines); assert!(rendered.iter().any(|line| line.contains("heading image"))); @@ -60,9 +80,8 @@ fn image_in_heading_is_rendered_as_placeholder() { #[test] fn table_image_falls_back_to_alt_text() { - let (ss, theme) = test_assets(); let src = "| Image |\n| --- |\n| ![table alt](table.png) |\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); assert!(parsed.image_blocks.is_empty()); let rendered = rendered_non_empty_lines(&parsed.lines); assert!(rendered.iter().any(|line| line.contains("table alt"))); @@ -71,9 +90,8 @@ fn table_image_falls_back_to_alt_text() { #[test] fn footnote_image_state_does_not_shift_following_source_lines() { - let (ss, theme) = test_assets(); let src = "Body[^n]\n\n[^n]: ![note image](note.png)\n\nAfter\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); assert_eq!(parsed.image_blocks.len(), 1); assert_eq!(parsed.image_blocks[0].source, "note.png"); let block = &parsed.image_blocks[0]; @@ -92,9 +110,8 @@ fn footnote_image_state_does_not_shift_following_source_lines() { #[test] fn image_alt_collects_nested_images_and_inline_formatting() { - let (ss, theme) = test_assets(); let src = "![outer *em* `code` $x$ [link](https://example.com) bold
raw ![inner](inner.png)](outer.png)\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); assert_eq!(parsed.image_blocks.len(), 1); let block = &parsed.image_blocks[0]; assert_eq!(block.source, "outer.png"); @@ -115,9 +132,8 @@ fn image_alt_collects_nested_images_and_inline_formatting() { #[test] fn footnote_and_body_images_have_unique_ids() { - let (ss, theme) = test_assets(); let src = "Body[^n]\n\n[^n]: ![note](note.png)\n\n![body](body.png)\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); assert_eq!(parsed.image_blocks.len(), 2); let ids: std::collections::HashSet<_> = parsed.image_blocks.iter().map(|block| block.id).collect(); @@ -126,9 +142,8 @@ fn footnote_and_body_images_have_unique_ids() { #[test] fn multiline_image_maps_all_placeholder_rows_to_start_line() { - let (ss, theme) = test_assets(); let src = "Before\n\n![multi\nline](image.png)\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); let block = &parsed.image_blocks[0]; assert_eq!(block.source_line, 3); let line_numbers: std::collections::HashSet<_> = parsed.line_number_map @@ -146,10 +161,9 @@ fn multiline_image_maps_all_placeholder_rows_to_start_line() { #[test] fn table_image_alt_collects_inline_and_nested_content() { - let (ss, theme) = test_assets(); let src = "| Image |\n| --- |\n| ![outer `code` bold ![inner](inner.png)](outer.png) |\n"; - let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + let parsed = parse_images(src); let rendered = rendered_non_empty_lines(&parsed.lines); assert!(rendered .iter() @@ -157,3 +171,26 @@ fn table_image_alt_collects_inline_and_nested_content() { assert!(!rendered.iter().any(|line| line.contains(""))); assert!(!rendered.iter().any(|line| line.contains("inner.png"))); } + +#[test] +fn disabled_images_match_upstream_text_layout() { + let (ss, theme) = test_assets(); + let src = "Before\n\n![diagram alt](images/diagram.png)\n\nAfter\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert!(parsed.image_blocks.is_empty()); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert_eq!(rendered, vec!["Before", "diagram alt", "After"]); + assert!(!rendered.iter().any(|line| line.contains("┌─ image"))); + assert!(!rendered.iter().any(|line| line.contains("source:"))); +} + +#[test] +fn disabled_table_images_match_upstream_alt_text() { + let (ss, theme) = test_assets(); + let src = "| Image |\n| --- |\n| ![table alt](table.png) |\n"; + let parsed = parse_markdown(src, &ss, &theme, &test_md_theme(), false, true); + assert!(parsed.image_blocks.is_empty()); + let rendered = rendered_non_empty_lines(&parsed.lines); + assert!(rendered.iter().any(|line| line.contains("table alt"))); + assert!(!rendered.iter().any(|line| line.contains("source:"))); +} diff --git a/src/tests/render.rs b/src/tests/render.rs index ac565ef..daa4bf6 100644 --- a/src/tests/render.rs +++ b/src/tests/render.rs @@ -1,5 +1,5 @@ use super::{find_symbol, render_buffer, test_assets, test_md_theme}; -use crate::app::App; +use crate::app::{App, ImageFlash}; use crate::markdown::{parse_markdown, parse_markdown_with_width}; use crate::wrap_path_lines; use ratatui::style::Style; @@ -239,3 +239,56 @@ fn path_popup_copy_status_uses_reserved_row_without_moving_paths() { } } } + +#[test] +fn help_and_status_show_kitty_images_state() { + use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; + + fn draw(app: &mut App) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(80, 30)).unwrap(); + terminal + .draw(|frame| crate::render::ui(frame, app)) + .unwrap(); + terminal.backend().buffer().clone() + } + + fn buffer_text(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + let mut app = App::new( + Vec::new(), + Vec::new(), + "test".to_string(), + false, + false, + None, + None, + ); + app.open_help(); + assert!(buffer_text(&draw(&mut app)).contains("images: off")); + app.set_kitty_images_enabled(true); + assert!(buffer_text(&draw(&mut app)).contains("images: off")); + app.set_kitty_images_rendering_for_test(); + assert!(buffer_text(&draw(&mut app)).contains("images: on")); + + app.close_help(); + app.set_image_flash(ImageFlash::Enabled); + assert!(buffer_text(&draw(&mut app)).contains("Images enabled")); + app.clear_image_flash(); + app.set_image_flash(ImageFlash::Disabled); + assert!(buffer_text(&draw(&mut app)).contains("Images disabled")); + app.clear_image_flash(); + app.set_image_flash(ImageFlash::NoImages); + assert!(buffer_text(&draw(&mut app)).contains("No images")); + app.clear_image_flash(); + app.set_image_flash(ImageFlash::Unavailable); + assert!(buffer_text(&draw(&mut app)).contains("Kitty unavailable")); +} diff --git a/src/tests/theme.rs b/src/tests/theme.rs index 6650722..86ebd58 100644 --- a/src/tests/theme.rs +++ b/src/tests/theme.rs @@ -1,6 +1,6 @@ use super::{lock_theme_test_state, test_assets, test_md_theme}; use crate::app::{App, AppConfig}; -use crate::markdown::parse_markdown; +use crate::markdown::{parse_markdown, parse_markdown_with_images}; use crate::theme::{ current_syntect_theme, current_theme_preset, current_theme_selection, resolve_theme_selection, set_theme_preset, set_theme_selection, theme_preset_index, validate_theme_syntax, @@ -162,7 +162,8 @@ fn theme_preview_and_restore_preserve_image_blocks() { let (ss, theme) = test_assets(); let ts = ThemeSet::load_defaults(); let source = "![diagram](diagram.png)\n"; - let parsed = parse_markdown(source, &ss, &theme, &test_md_theme(), false, true); + let parsed = + parse_markdown_with_images(source, &ss, &theme, &test_md_theme(), false, true, None); let image_blocks = parsed.image_blocks.clone(); let crate::markdown::ParseResult { lines, @@ -184,6 +185,7 @@ fn theme_preview_and_restore_preserve_image_blocks() { }, ); app.set_image_blocks(image_blocks); + app.set_kitty_images_enabled(true); let original = current_theme_selection(); set_theme_preset(ThemePreset::OceanDark);