diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e35006b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +fixtures/expected/*.ansi binary +fixtures/*.md text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 3e37f15..f56224c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,21 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "autocfg" version = "1.5.0" @@ -66,6 +81,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -82,6 +112,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -143,6 +187,90 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs" version = "6.0.0" @@ -161,7 +289,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -185,6 +313,12 @@ dependencies = [ "wio", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -198,7 +332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -232,6 +366,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-kit" version = "0.14.3" @@ -301,7 +441,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -315,12 +455,35 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "image" version = "0.25.10" @@ -341,9 +504,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lazy_static" version = "1.5.0" @@ -375,18 +575,42 @@ dependencies = [ "libc", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.0" @@ -403,6 +627,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -443,6 +679,35 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -524,6 +789,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -535,6 +850,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustc_version" version = "0.4.1" @@ -544,6 +888,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.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -553,10 +910,22 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -566,6 +935,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -617,12 +992,83 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -636,18 +1082,23 @@ dependencies = [ [[package]] name = "termdown" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ab_glyph", "base64", + "crossterm", "font-kit", "image", "libc", "pulldown-cmark", + "ratatui", + "rayon", + "regex", "serde", "terminal_size", "toml", - "unicode-width", + "tui-textarea", + "unicode-width 0.2.0", ] [[package]] @@ -656,8 +1107,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -727,6 +1178,17 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "unicase" version = "2.9.0" @@ -739,11 +1201,34 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "walkdir" @@ -783,7 +1268,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -798,6 +1283,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -807,6 +1301,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 77529af..4e33cbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "termdown" -version = "0.3.0" +version = "0.4.0" edition = "2021" description = "Render Markdown with large-font headings in the terminal via Kitty graphics protocol" license = "Apache-2.0" @@ -12,12 +12,17 @@ categories = ["command-line-utilities", "text-processing"] [dependencies] ab_glyph = "0.2" base64 = "0.22" +crossterm = "0.28" font-kit = "0.14" image = { version = "0.25", default-features = false, features = ["png"] } pulldown-cmark = "0.13" +ratatui = "0.29" +rayon = "1" +regex = "1" serde = { version = "1", features = ["derive"] } terminal_size = "0.4" toml = "0.8" +tui-textarea = "0.7" unicode-width = "0.2" [target.'cfg(unix)'.dependencies] diff --git a/Makefile b/Makefile index 3f08ace..991f5b3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help fmt fmt-check lint test build check all +.PHONY: help fmt fmt-check lint test build build-release check all CARGO ?= cargo @@ -9,6 +9,7 @@ help: @echo " lint - clippy on all targets, warnings as errors (CI gate)" @echo " test - cargo test" @echo " build - cargo build --all-targets" + @echo " build-release - cargo build --release" @echo " check - fmt-check + lint + test (run before pushing)" @echo " all - fmt + check + build" @@ -27,6 +28,9 @@ test: build: $(CARGO) build --all-targets +build-release: + $(CARGO) build --release + check: fmt-check lint test all: fmt check build diff --git a/README.md b/README.md index 3faa265..13e4b1d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,35 @@ termdown --help termdown --version ``` +### TUI mode + +For long files, use `--tui` for a vim-style interactive browser: + +```sh +termdown --tui README.md +``` + +Key bindings: + +| Key | Action | +|---|---| +| `j` / `↓` | Scroll down one line | +| `k` / `↑` | Scroll up one line | +| `d` / `u` | Half page down / up | +| `f` / `Space` / `PgDn` | Full page down | +| `b` / `PgUp` | Full page up | +| `gg` / `G` | Jump to start / end | +| `]` / `[` | Next / previous heading | +| `t` | Toggle Table of Contents panel | +| `/` | Search forward | +| `n` / `N` | Next / previous match | +| `?` | Toggle keyboard-shortcut help overlay | +| `Enter` | Follow link (overlay picker if multiple visible) | +| `o` / `i` | Back / forward across followed `.md` links | +| `q` / `Ctrl-C` | Quit | + +TUI mode requires a file path; stdin input is not supported. + ## Configuration termdown reads configuration from `~/.termdown/config.toml`. @@ -118,6 +147,7 @@ rm -rf ~/.termdown - **Font selection & fallback** -- weight matching relies on platform font APIs (Core Text / fontconfig) which may not always resolve to the expected variant - **Theme detection** -- auto-detection relies on OSC 11 terminal responses; if your terminal does not support this, use `--theme` or the config file to set the theme manually - **Complex emoji sequences** -- ZWJ-heavy emoji sequences (family/grouping variants, some skin-tone combinations) may still render as separate glyphs because heading layout does not perform full text shaping +- **TUI help popup vs heading images** -- in TUI mode, the `?` help overlay is drawn on the text layer, while heading images live on Kitty's graphics layer (always on top of text). Any heading image overlapping the popup area is temporarily removed while the popup is open and restored when it closes -- this is a Kitty graphics protocol limitation, not a bug ## License diff --git a/README_CN.md b/README_CN.md index 1c85ceb..bf13095 100644 --- a/README_CN.md +++ b/README_CN.md @@ -53,6 +53,35 @@ termdown --help termdown --version ``` +### TUI 模式 + +阅读较长的文档时,可以用 `--tui` 进入类似 vim 的交互浏览器: + +```sh +termdown --tui README.md +``` + +按键绑定: + +| 按键 | 动作 | +|---|---| +| `j` / `↓` | 向下滚动一行 | +| `k` / `↑` | 向上滚动一行 | +| `d` / `u` | 半屏向下 / 向上 | +| `f` / `Space` / `PgDn` | 整屏向下 | +| `b` / `PgUp` | 整屏向上 | +| `gg` / `G` | 跳到文档开头 / 末尾 | +| `]` / `[` | 下一个 / 上一个标题 | +| `t` | 切换目录面板 | +| `/` | 正向搜索 | +| `n` / `N` | 下一个 / 上一个匹配 | +| `?` | 切换快捷键帮助弹窗 | +| `Enter` | 打开链接(屏幕中有多个链接时显示序号选择器) | +| `o` / `i` | 在已跳转的 `.md` 文档之间前进 / 后退 | +| `q` / `Ctrl-C` | 退出 | + +TUI 模式需要指定文件路径,不支持从 stdin 读取。 + ## 配置 配置文件位于 `~/.termdown/config.toml`。 @@ -116,6 +145,7 @@ rm -rf ~/.termdown - **字体选择与降级** -- 字体粗细匹配依赖平台 API(Core Text / fontconfig),不一定能解析到预期的字重变体 - **主题检测** -- 自动检测依赖终端对 OSC 11 的响应;如终端不支持,请通过 `--theme` 或配置文件手动指定主题 - **复杂 emoji 序列** -- 依赖 ZWJ 的复杂 emoji(例如家庭/群组类组合、部分肤色组合)目前仍可能拆成多个字形,因为标题渲染还没有完整文本 shaping +- **TUI 帮助弹窗与标题图片** -- TUI 模式下 `?` 帮助弹窗绘制在文字层,而标题图片位于 Kitty graphics 层(始终覆盖在文字之上)。与弹窗区域重叠的标题图片会在弹窗打开时被临时移除,关闭后自动恢复 —— 这是 Kitty graphics 协议的限制,不是 bug ## 许可证 diff --git a/docs/LINK_PICKER_DESIGN.md b/docs/LINK_PICKER_DESIGN.md new file mode 100644 index 0000000..58bb43f --- /dev/null +++ b/docs/LINK_PICKER_DESIGN.md @@ -0,0 +1,81 @@ +# Link Picker — Design Options + +## Problem + +Current `LinkSelect` mode (`src/tui/mod.rs:451-462`, `:924-932`): + +- Collects every link in the viewport via `visible_links`. +- Status-bar overlay shows up to **9** labels (`take(9)` + `…`), keybinding only accepts digits `1`–`9`. +- When the viewport holds many links, or labels are long, the single status-bar row truncates and the 10th+ links are unselectable. + +Two alternative designs are recorded here. Neither has been implemented yet. + +--- + +## Option C — Vimium-style inline hints + +Paint a short label directly next to each visible link, in the body itself. + +### UX + +1. User presses `Enter` on a viewport with ≥2 links. +2. Each link is prefixed with a styled label: `[1]`, `[2]`, … `[9]`, `[a]`, `[b]`, … For >26 links, use two-char labels like `[aa]`, `[ab]`. +3. User types the label. Single-char labels fire immediately; multi-char labels commit after the second keystroke. Partial matches stay pending and filter the remaining hints (like Vimium "linkHints filter"). +4. `Esc` cancels. + +### Pros + +- Scales to arbitrarily many links. +- Positional: the user sees *which* link each label points at without scanning a status bar. +- Works even when the body is dense with links. + +### Cons / open questions + +- Rendering: labels must be injected as styled prefix spans inside `clipped_spans`. Must not shift the body layout's column alignment for heading images (`MARGIN_WIDTH` gutter) or break search-match byte offsets. +- If labels are injected as real text, the wrap cache needs to reflow; probably easier to inject labels *only* for the active frame via a render-time overlay, not as layout-level spans. +- Label alphabet and length policy: prefer "home-row first" (`a s d f j k l`) like Vimium, or keep `1–9` plus `a–z`? Pick before implementing. +- Interaction with kitty heading images — labels sit in text rows, so no conflict, but the column math for image placement must ignore the injected label width. + +--- + +## Option D — Links side panel (parallel to ToC) + +Reuse the existing ToC sidebar pattern. Add a `Mode::Links` (or reuse the TOC panel slot with a tab switch) toggled by `l`. + +### UX + +1. User presses `l` → a left panel opens (same 30-col width as ToC today, see `src/tui/mod.rs:805-842`). +2. Panel lists every link in the **whole document** (not just viewport), grouped visually by heading section, each entry formatted as `` or similar. External vs local `.md` gets a type badge (`↗` external, `↪` local). +3. `j`/`k` (or arrows) moves the selection; `Enter` opens the selected link (follows existing `open_link_target` path). +4. `l` again, or `Esc`, closes the panel. +5. Selection state is per-doc (lives on `DocEntry`, like `toc_open`), so back/forward preserves where the user was in the link list. + +### Pros + +- No new overlay paradigm — mirrors the existing `t`/ToC interaction, low learning cost. +- Covers *all* links in the doc, not just the ones in the current viewport. Good for documents with many cross-references. +- Easy to show extra metadata inline (URL, type badge, maybe "visited" marker via history stack). +- Scrollable list; no label-alphabet cap. + +### Cons / open questions + +- Loses positional context — user sees a list, not "this specific link I'm looking at on the page." +- Body width shrinks while the panel is open; kitty image placements must re-register (already handled for ToC via `needs_full_redraw`). +- If user wants to pick a link that's currently under the cursor, going through a list feels heavier than pressing Enter once. +- Can `l` coexist with ToC open? Cleanest: only one left panel at a time; opening Links auto-closes ToC and vice-versa. + +--- + +## Recommendation (not final) + +Options C and D address different use cases: + +- **C** optimizes for the *"I'm reading and want to follow a specific link I can see"* flow. +- **D** optimizes for the *"I want to survey every link in this doc"* flow. + +They are complementary. A realistic plan could be: + +1. Ship **D** first — it's a straightforward extension of existing ToC infrastructure and immediately unblocks the >9-link case. +2. Revisit **C** later if the inline-hint flow feels worth the rendering complexity. + +Either way, the status-bar `take(9)` overlay should be retired once a replacement lands. diff --git a/docs/TUI_MODE_DEBUG_LOG.md b/docs/TUI_MODE_DEBUG_LOG.md new file mode 100644 index 0000000..78a58c4 --- /dev/null +++ b/docs/TUI_MODE_DEBUG_LOG.md @@ -0,0 +1,197 @@ +# TUI Mode — Debug Log + +Running log of bugs surfaced during manual QA of `--tui` and the fixes +applied. This document exists so that a fresh session can resume +debugging without re-deriving the investigation. + +Paired with: +- `docs/TUI_MODE_DESIGN.md` — the authoritative design. +- `docs/TUI_MODE_RESEARCH.md` — library / protocol background. +- `docs/TUI_MODE_PLAN.md` — task-by-task implementation plan. + +--- + +## Branch & Status + +- Working branch: `feature/tui-mode` (37 implementation commits on top + of `master`). +- `make check` is green — 77 tests, 0 clippy warnings. +- Phase 8.1 (manual QA in Ghostty/iTerm2) is the current step. All + code-authored phases are complete but real-terminal testing has + surfaced the bugs below. + +--- + +## Round 1 — First Manual Run + +**Symptom.** All heading images rendered stacked on row 0 across the +top of the terminal, side-by-side, overlapping each other. Right edge +showed fragments of heading text as single stacked letters. + +**Root cause.** `src/render.rs::place` was encoding cell coordinates +into the Kitty APC `x=`/`y=` parameters. Those keys are +**source-image pixel offsets (for cropping)**, not terminal cells. +Kitty `a=p` places at the **current cursor position**; to place at a +specific cell you must first emit a CUP (`\x1b[R;CH`) and *then* send +`a=p`. + +Also: `transmit` used `a=T` (transmit **and display**), polluting the +startup screen. The right form for the TUI lifecycle is `a=t` +(transmit only, place later via `a=p`). + +**Fix (`30fc57e`).** + +```rust +// src/render.rs +pub fn place(w: &mut W, id: u32, col: u16, row: u16) -> io::Result<()> { + // Move cursor first (1-indexed), then place the image there. + write!(w, "\x1b[{};{}H\x1b_Ga=p,i={id},q=2;\x1b\\", row + 1, col + 1) +} +``` + +And changed both `a=T` occurrences in `transmit` to `a=t`. + +Tests were updated (`transmit_produces_a_eq_t_with_id`, +`place_produces_cursor_move_then_a_eq_p`) and the +`sync_enters_new_moves_and_leaves` test in `tui/kitty.rs` was updated +to expect the new CUP prefix. + +--- + +## Round 2 — Second Manual Run + +**Symptoms reported.** + +1. First frame looked mostly correct, but: + - Body text started at column 0 — no left margin (cat mode uses 4 + columns). + - A red vertical pixel line appeared on the far-left edge — likely + a Kitty image artifact bleeding past its row budget. + - The bottom-most H2 heading ("TUI 模式" in the Chinese README) + visibly overlapped the status bar. + +2. After pressing `j`/`k` to scroll, the screen **degraded + progressively** with each keypress: + - Old text from previous frames persisted on screen while new text + layered on top. + - Right ~30–40% of the terminal went blank. + - Status bar drifted upward out of the bottom row. + - Chinese and English snippets stacked visibly. + +**Hypotheses investigated** (opus pass). + +| ID | Hypothesis | Verdict | +|----|------------|---------| +| H1 | Ratatui's incremental diff leaves stale cells when RLines change shape | Confirmed | +| H2 | `Viewport::width` cached once at startup, never resyncs on resize | Confirmed | +| H3 | No `MARGIN_WIDTH` prefix on TUI body RLines | Confirmed | +| H4 | Hardcoded `rows` estimates (H1=6, H2=4, H3=3) under-count real PNG cell height | Confirmed | +| H5 | Cat mode's output changed | Not changed — snapshots still pass | +| H6 | Kitty `a=p` advances cursor past image; can scroll the visible region when placing near bottom | **This was the biggest offender.** Needed `C=1` on every place. | + +**Fix (`bec090a`).** + +Multi-file change in `src/render.rs`, `src/layout.rs`, +`src/tui/kitty.rs`, `src/tui/mod.rs`. Highlights: + +- `render_heading` now returns `Option<(Vec, u32, u32)>` (png + + pixel width + pixel height). `HeadingImage` gained `px_width` / + `px_height` fields. Layout threads these through. +- `place` now emits `C=1` (don't advance cursor after placement) in + addition to the CUP prefix: + `\x1b[{row+1};{col+1}H\x1b_Ga=p,i={id},q=2,C=1;\x1b\\`. +- New `ImageLifecycle::reset_placements` method — deletes every + current placement without discarding the cached PNG data. +- Event loop now: + - Re-queries `terminal.size()` each iteration, syncs + `viewport.width`/`height`, invalidating the wrap cache on change. + - Poll timeout raised from 16ms to 50ms (less CPU spin). + - Every processed event sets `needs_full_redraw = true`; the next + iteration calls `terminal.clear()` + `reset_placements` before + `terminal.draw`. Belt-and-braces guard against the stale-cell + class of bugs. +- `draw` prepends a 4-space `RSpan` to every body RLine so the text + column aligns with image column (`MARGIN_WIDTH = 4`). +- Heading image `rows` are now computed from the queried terminal + cell pixel height when available (via + `crossterm::terminal::window_size()`): + `rows = ceil(png_height_px / cell_pixel_height)`. Fall back to the + hardcoded per-level estimates if the terminal reports pixel size 0. +- `ToggleToc` no longer manually adjusts viewport width — the + event-loop resync handles it. + +**Status.** Committed; `make check` green. **Not yet verified by the +user in a real terminal.** The user hit a usage limit before re-testing. + +--- + +## Round 3 — Flicker when holding `j` + +**Symptom.** Scrolling by tapping `j` looks correct, but holding `j` +for ~1-2 seconds (until macOS key-autorepeat kicks in at ~30 Hz) +degrades into violent whole-screen flicker. + +**Root cause.** Round 2 had set `app.needs_full_redraw = true` after +*every* processed event as a belt-and-braces safety net. At autorepeat +rate the event loop fires `terminal.clear()` (emits `\x1b[2J`) plus a +full PNG re-transmission ~30 times per second, which is exactly what a +flicker looks like. The `C=1` placement flag landed in Round 2 already +prevents the cursor-advance cascade the blanket was guarding against, +so the blanket is now pure harm. + +**Fix (`46d7503`).** + +- Removed the blanket `needs_full_redraw = true` at the end of + `event_loop`. +- Set `needs_full_redraw = true` only where body geometry or content + actually changes: `ToggleToc` (width shift), `Back` / `Forward` / + `open_link_target` (doc swap). Resize already had an explicit path. +- Scroll / search / mode-change events now rely purely on ratatui's + cell diff + `images.sync()` — which is what `TUI_MODE_DESIGN.md` + originally specified. + +Held-`j` should now match the design acceptance bar ("no flicker, no +lag, no image residue"). + +--- + +## Open Risks / What To Verify Next + +Residual items from Rounds 1-3. Test on both Ghostty and iTerm2: + +1. **Red vertical line on the left edge.** Most plausibly a cascading + side-effect of the cursor-advance bug, addressed by `C=1` in + `bec090a`. Likely resolved; reopen if it reappears. + +2. **Bottom-heading overlap with the status bar.** Resolved by + `32455c9` (spacer `VisualLine`s + placement clipping against + `body_height`). If still present on a terminal that reports zero + pixel size: the per-level fallback undercount may leak through — + see #3 below. + +3. **Terminals that don't report pixel size.** `window_size()` can + return 0 for pixel fields. Our fallback keeps the old per-level + estimates, which under-count. Consider bumping those fallbacks to + H1=8, H2=6, H3=4, or add an APC-based `\x1b[16t` probe. + +4. **Performance on long docs.** When a full redraw *does* fire + (ToC toggle, doc swap, resize), `reset_placements` + re-transmit + runs over every cached image. For a 30-heading doc that's a + measurable stall on the ToC-toggle keystroke. Only worth + optimizing if it becomes user-visible — normal scroll no longer + takes this path. + +--- + +## If Everything Is Fine After Round 2 + +- Close out Phase 8.1 and merge `feature/tui-mode` to `master`. +- Delete this file or archive it under `docs/archive/`. + +## If Further Rounds Are Needed + +- Add a new `### Round N — ...` section above this one. +- Always name the exact commit SHAs applied and the specific + hypotheses tested. +- Keep `make check` green at each round; manual-only behavior fixes + still need snapshot validation that cat mode is unaffected. diff --git a/docs/TUI_MODE_DESIGN.md b/docs/TUI_MODE_DESIGN.md new file mode 100644 index 0000000..a2c64e1 --- /dev/null +++ b/docs/TUI_MODE_DESIGN.md @@ -0,0 +1,434 @@ +# TUI Mode — Design + +Design for termdown's `--tui` mode. Research and approach comparison +that led to this design lives in `TUI_MODE_RESEARCH.md`. Read that first +if you want the "why this stack"; this doc is the "what we're building". + +## Goals + +- Browse Markdown documents larger than one screen with vim-style + navigation (paging, `gg`/`G`, heading jumps, `/` search, `n`/`N`). +- Preserve termdown's Kitty-graphics heading rendering without the + per-frame re-transmission cost that makes similar tools feel sluggish. +- Share the Markdown → rendered output pipeline between the cat path + (default) and the TUI path; don't fork rendering logic. + +## Non-Goals (v1) + +- Regex search (literal substring only). +- Mouse support (mdfried's mouse-vs-text-selection tradeoff is worse + than no mouse). +- Syntax highlighting inside code blocks. +- `--tui` piped from stdin (TUI needs the terminal for both key input + and document data; requiring a file path avoids the conflict). +- Configurable key bindings. + +## Activation + +- Explicit `--tui` flag required. Default remains cat-style output. +- `termdown --tui FILE.md` — enters TUI on `FILE.md`. +- `termdown --tui` with no file or with `-` → error, exit non-zero. +- Future evolution (documented but not implemented in v1): + - Automatic mode when output is a TTY and the rendered document + exceeds terminal height (git-log-style). + - `[tui]` section in `~/.termdown/config.toml` to opt into automatic + mode or override defaults. + +Single binary, no cargo feature flag. TUI code is always compiled in; +strip + LTO keep the binary growth acceptable (~2-3 MB expected). + +## Module Layout + +``` +src/ +├── main.rs CLI dispatch: --tui → tui::run, else → cat::print +├── config.rs (existing) +├── font.rs (existing) +├── theme.rs (existing) +├── style.rs (existing; extend Colors with match-highlight slot) +├── render.rs (existing; add transmit + place + delete_placement) +│ +├── layout.rs [new] pulldown-cmark → RenderedDoc (Vec + +│ HeadingImage[] + HeadingEntry[]). Used by both cat +│ and tui. +├── cat.rs [new] RenderedDoc → stdout (replaces the stdout +│ write logic currently in markdown.rs). +├── markdown.rs (shrinks; event-handling logic migrates into layout.rs) +│ +└── tui/ + ├── mod.rs App struct, terminal setup, main event loop. + ├── viewport.rs Wrap cache, visible-line computation, scroll. + ├── search.rs SearchState, match list, highlight injection. + ├── kitty.rs Image id allocation, placement diff, a=T/a=p/a=d + │ protocol operations, exit cleanup. + └── input.rs Key event → Action mapping. +``` + +New dependencies: `ratatui`, `crossterm`, `tui-textarea`, `regex` (used +for smart-case literal matching via escape; regex search itself is v2). + +## Data Model + +Core types live in `layout.rs` and are consumed by both cat and tui: + +```rust +pub struct RenderedDoc { + pub lines: Vec, + pub headings: Vec, + pub images: Vec, +} + +pub struct Line { + pub spans: Vec, + pub kind: LineKind, +} + +pub enum LineKind { + Body, + Heading { level: u8, id: usize }, + CodeBlock { lang: Option }, + BlockQuote { depth: u8 }, + ListItem { depth: u8 }, + Table, + HorizontalRule, + Blank, +} + +pub enum Span { + Text { content: String, style: Style }, + HeadingImage { id: u32, rows: u16 }, + Link { content: String, url: String, style: Style }, +} + +pub struct Style { + pub fg: Option, + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +pub struct HeadingEntry { + pub level: u8, + pub text: String, // plain-text form, used by search & ToC + pub line_index: usize, // index into RenderedDoc.lines +} + +pub struct HeadingImage { + pub id: u32, + pub png: Vec, + pub cols: u16, + pub rows: u16, +} +``` + +Key points: + +- **Lines are logical (unwrapped).** One Markdown paragraph = one `Line`. + Wrapping happens in `viewport.rs` against the current terminal width, + cached, and re-run only on resize. +- **Spans carry structured `Style`, not ANSI strings.** cat converts + `Style` to ANSI on output; tui converts to `ratatui::style::Style`. + Search highlight injects a background-color override without parsing + escapes. +- **`HeadingImage` is stored once and referenced by id.** The tui path + transmits each PNG to the terminal once at load time and only emits + placement commands on scroll. +- **`HeadingEntry` is the ToC data source.** No need to rescan `lines` + to build the outline panel. + +### Edge Cases + +- **Code blocks**: one `Line { kind: CodeBlock, … }` per source line. + Search can hit text inside code. +- **Tables**: the existing `markdown.rs` table renderer is lifted into + `layout.rs`. Each rendered table row becomes a `Line { kind: Table }`. + Tables do not wrap; over-wide tables truncate with `…`. +- **Image placeholders (non-heading)**: stay as `[image: alt text]` text + spans — same behavior as cat today. +- **Blank lines**: `LineKind::Blank` so search can skip them. + +## Cat Path Rewrite + +`markdown.rs` today writes to stdout as it walks the pulldown-cmark +event stream. Under the new design: + +1. `layout.rs` owns the event walk and produces a `RenderedDoc`. +2. `cat.rs` turns `RenderedDoc` into ANSI bytes on stdout. +3. The orchestration in `main.rs` stays the same for cat mode + (termios save/restore around the emit). + +This is a real refactor of cat output. Byte-level output may shift +(whitespace, ANSI reset timing). Regression protection: + +- Freeze the current cat output for every file in `fixtures/` into + `fixtures/expected/*.ansi` **before** the refactor lands. +- `make test` runs a snapshot comparison against that frozen baseline. +- Diffs are reviewed intent-first — byte-identical is not required, + but visible behavior must match. + +## Runtime State + +```rust +pub struct App { + docs: Vec, + cursor: usize, // active DocEntry index + history: Vec, // back stack of cursor values + forward: Vec, // forward stack + mode: Mode, + term_size: (u16, u16), + next_image_id: u32, +} + +enum Mode { + Normal, + Search { query: String, direction: Direction }, + Toc, + LinkSelect, // overlay shown after `f` +} + +pub struct DocEntry { + source_path: PathBuf, + doc: RenderedDoc, + viewport: Viewport, + wrap_cache: WrapCache, // keyed by terminal cols + search: Option, + placed_images: HashMap, // id → current (col, row) +} + +pub struct Viewport { + top_visual_line: usize, + height: u16, +} +``` + +**Per-doc state** (`viewport`, `search`, `wrap_cache`, `placed_images`) +is intentional. When the user follows a link from A to B and later +presses `o` (back), A reopens at its previous scroll position with its +search still highlighted, mirroring browser back behavior. Memory cost +is a few tens of KB per doc — negligible. + +## Event Loop + +```text +loop { + event = poll(16ms) | tick; + action = input::map(app.mode, event); + dirty = apply(&mut app, action); + + if dirty { + terminal.draw(|f| render_text(f, &app))?; // ratatui writes text cells + kitty::sync_images(&mut app)?; // we diff + place/delete + writer.flush()?; + } +} +``` + +Event polling with a 16ms budget coalesces bursts of held-key repeats +into a single redraw per frame. + +### Layered Rendering + +ratatui owns the text layer; we own the image layer. Coordination: + +1. In `terminal.draw`, every image row is filled with a custom + "ImageReserve" widget whose `render` is a no-op — ratatui's diff + engine will *not* touch those cells, so images underneath survive. +2. After ratatui flushes, `kitty::sync_images` walks the visible region + once, computes the desired `{id → (col, row)}` map, diffs against + `placed_images`, and emits: + - `a=d, d=i, i=ID` for ids that left the viewport, + - `a=p, i=ID, x, y` for ids that entered, + - `delete + place` for ids whose position changed (Kitty does not + treat repeated `a=p` of the same id as "move" — it stacks). +3. `placed_images` is updated. + +## Key Bindings + +| Key | Mode | Action | +|---|---|---| +| `j` / `↓` | Normal | Down 1 line | +| `k` / `↑` | Normal | Up 1 line | +| `d` / `Ctrl-d` | Normal | Down half screen | +| `u` / `Ctrl-u` | Normal | Up half screen | +| `f` / `Ctrl-f` / `Space` / `PageDown` | Normal | Down full screen | +| `b` / `Ctrl-b` / `PageUp` | Normal | Up full screen | +| `g g` | Normal | Jump to top | +| `G` | Normal | Jump to bottom | +| `]]` | Normal | Next heading | +| `[[` | Normal | Previous heading | +| `t` | Normal | Toggle ToC panel | +| `/` | Normal → SearchForward | Open search prompt | +| `?` | Normal → SearchBackward | Reverse search prompt | +| `Enter` | Search | Commit query, jump to first match | +| `Esc` | Search / Toc / LinkSelect | Back to Normal | +| `n` | Normal | Next match | +| `N` | Normal | Previous match | +| `Enter` | Normal | Open link: 0 links visible → nop; 1 → open; >1 → enter LinkSelect | +| digit | LinkSelect | Open numbered link | +| `o` | Normal | Back (previous doc) | +| `i` | Normal | Forward | +| `q` / `Ctrl-c` | Any | Quit | + +`o`/`i` repurpose vim's `Ctrl-o`/`Ctrl-i` jump semantics as bare keys — +termdown has no insert mode to conflict with. + +### Link Opening + +Links follow "open first if unambiguous, otherwise select": + +- Viewport contains 0 visible links → Enter is a no-op. +- Viewport contains 1 link → Enter opens it via `open`/`xdg-open`. +- Viewport contains multiple links → Enter enters LinkSelect mode. + Each visible link gets a bracketed digit overlay (`[1]foo`, `[2]bar`); + pressing a digit opens that link. Esc exits LinkSelect. + +## Search + +```rust +pub struct SearchState { + query: String, + direction: Direction, + matches: Vec, + current: Option, +} + +pub struct MatchPos { + line_index: usize, + byte_range: Range, +} +``` + +### Matching (v1) + +- Literal substring, smart case (case-insensitive unless the query has + at least one uppercase letter; same rule as vim with `smartcase`). +- Searches `Span::Text.content`, `Span::Link.content`, and + `HeadingEntry.text`. Skips `HeadingImage` (image-rendered heading + text is searchable via the corresponding `HeadingEntry`). +- Full scan once on commit — O(N) over the document. 10k-line docs + complete in single-digit ms. + +### Navigation + +- `n` advances `current`; wrap-around at the end shows + `search hit BOTTOM, continuing at TOP` in the status line. +- `N` reverses. +- Jumping centers the match at ~1/3 from the viewport top (vim default), + not the exact center — reads better. + +### Highlight + +Drawing each visible line, if the line's `line_index` has matches, the +corresponding byte ranges have their `Style.bg` overwritten: + +- Non-current matches: theme-provided "match" background. +- Current match: theme-provided "current match" background (more vivid). + +Colors slot into `style.rs::Colors`, getting auto-resolved for light vs +dark theme like the rest of termdown's palette. + +### Edge Cases + +- Empty query (press `/` then Enter): no-op. +- Zero matches: status line shows `Pattern not found: `, stay + in Normal mode, don't clear prior `SearchState`. +- Re-running `/` replaces the query (no nested search). +- If the user `n`s to a match and then scrolls away with `j`, + `current` is preserved — subsequent `n` continues from `current`, + not from the current viewport position (vim behavior). + +## Kitty Image Lifecycle + +Three primitive operations added to `render.rs`: + +```rust +fn transmit(id: u32, png: &[u8]); // a=T, i=ID, f=100, q=2, chunked +fn place(id: u32, col: u16, row: u16); // a=p, i=ID, x=COL, y=ROW, q=2 +fn delete_placement(id: u32); // a=d, d=i, i=ID, q=2 +``` + +### Timeline + +1. **Load doc.** `layout.rs` produces `RenderedDoc` with PNGs in memory. +2. **Register images.** For each `HeadingImage` in the new `DocEntry`, + call `transmit(id, png)` once. The terminal caches the data. +3. **Event loop.** On each dirty frame, `kitty::sync_images` diffs the + desired placement set against `placed_images` and emits + delete/place commands (no PNG data). +4. **Resize.** `sync_images` runs with new cell coordinates — terminal + scales the image to the new cell size; no re-transmission needed. +5. **Exit.** Send `a=d, d=A` to delete all placements *and free the + stored image data* this process created, then restore termios. + (`d=a` would delete placements but leave data cached in the + terminal — wasteful across repeated opens.) + +### Why delete + place instead of re-place + +Kitty does not treat a second `a=p` of the same id as "move" — it +stacks a second placement. To move an image, the old placement must be +deleted first. Cost per frame: `O(visible_images)` delete + place +command pairs, each a few dozen bytes. Negligible compared to PNG +re-transmission. + +### Exit Cleanup + +`a=d, d=A` clears all placements and frees image data this client has +made. Trade-off: if the user has two `termdown --tui` processes sharing +one terminal (e.g. tmux panes), exiting one wipes the other's images. +Not worth the id-range partitioning in v1; if it becomes a real +complaint, switch to id-scoped deletion. + +## Testing Strategy + +| Layer | Test kind | Coverage | +|---|---|---| +| `layout.rs` | Unit, text snapshot | pulldown-cmark event → `Vec` correctness per Markdown element | +| `cat.rs` | Snapshot | `RenderedDoc` → ANSI bytes. Frozen `fixtures/expected/*.ansi` baseline before refactor | +| `viewport.rs` | Unit | Wrap on CJK, long URLs (no break), scroll bounds, height changes | +| `tui/search.rs` | Unit | Substring, smart case, n/N wrap, byte-range correctness | +| `tui/kitty.rs` | Unit (mock writer) | Diff algorithm + protocol byte format (`\x1b_G...\x1b\\`) | +| `tui/mod.rs` event loop | Manual | Ghostty, iTerm2 real terminals | + +`make check` additions: + +- `cargo test` picks up the snapshot tests automatically. +- A new `fixtures/expected/` directory under version control. + +### Manual Pre-merge Checklist + +Run against both Ghostty and iTerm2: + +- Short (< 1 screen), mid (README-size), long (20+ screen) docs. +- Heading-dense docs. +- Mixed-script text, emoji, wide tables, long code blocks. +- Held-`j` for 10 s: no flicker, no lag, no image residue. +- Search hit / miss / wrap / re-center at 1/3. +- Multi-file back/forward, per-doc state preserved. +- Resize mid-session. +- Link open: 0/1/>1 visible cases. +- `q` exit: no image residue on terminal. + +## Open Questions (Deferred) + +1. Regex search (v2). +2. Code-block syntax highlighting — would add `syntect`; out of scope. +3. Mouse support — deferred to avoid mdfried's selection/scroll trade. +4. TUI reading from stdin — requires pty multiplexing; rejected for v1. +5. Configurable key bindings — hardcoded for v1. +6. Performance SLO: held-`j` on a 100-screen doc should not stutter. + Instrument only if we miss the target. +7. Font-size changes mid-session (not the same event as resize) — + v1 requires a reopen. + +## Migration Plan + +1. Freeze cat output snapshots from current `master`. +2. Introduce `layout.rs` + `cat.rs`; wire `main.rs` to use them for the + default path. Run snapshot diffs; resolve intent differences. +3. Add `tui/` modules behind the `--tui` flag. Core event loop, + viewport, input, kitty image sync. +4. Search (v1). +5. Heading nav, ToC panel, status line. +6. Back/forward across multiple docs, link following. +7. Manual pre-merge checklist on both Ghostty and iTerm2. diff --git a/docs/TUI_MODE_PLAN.md b/docs/TUI_MODE_PLAN.md new file mode 100644 index 0000000..e70f8ad --- /dev/null +++ b/docs/TUI_MODE_PLAN.md @@ -0,0 +1,3301 @@ +# TUI Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `--tui` mode to termdown providing vim-style browsing (paging, search, heading nav, back/forward, link open) for long Markdown files. + +**Architecture:** `layout.rs` produces a structured `RenderedDoc` consumed by both the existing cat path (`cat.rs`, replacing current `markdown.rs` stdout logic) and a new `tui::App` (ratatui text layer + self-managed Kitty image placement via `a=T` / `a=p` / `a=d`). Per-doc viewport/search state is preserved across back/forward navigation. + +**Tech Stack:** Rust 2021, pulldown-cmark 0.13, ratatui + crossterm (new), tui-textarea (new), regex (new), existing `ab_glyph` + `image` + Kitty graphics protocol. + +**Spec reference:** `docs/TUI_MODE_DESIGN.md` — the authoritative design. This plan implements that spec in order. + +**Conventional commits:** Every commit uses the project's Conventional Commits format (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`). Scope prefix optional. + +**Verification gate:** Every phase ends with `make check` passing (fmt-check + lint + test). Never skip — this is the project's CI gate. + +--- + +## Phase 0 — Snapshot Baseline + +**Why first:** Tasks in Phase 1 refactor cat output. Before we touch it, freeze the current stdout bytes for every fixture so we have a byte-level regression baseline. Any drift later is reviewed intent-first. + +### Task 0.1: Freeze cat output snapshots + +**Files:** +- Create: `fixtures/expected/emoji-test.ansi` +- Create: `fixtures/expected/full-syntax-zh.ansi` +- Create: `fixtures/expected/full-syntax.ansi` +- Create: `fixtures/expected/tasklist.ansi` +- Create: `fixtures/expected/unsupported-syntax.ansi` +- Create: `tests/snapshots.rs` + +- [ ] **Step 1: Build the current binary** + +Run: `make build` +Expected: exits 0. + +- [ ] **Step 2: Capture each fixture's current output** + +Run (from repo root): + +```sh +for f in fixtures/*.md; do + name=$(basename "$f" .md) + TERM_PROGRAM=ghostty target/debug/termdown "$f" > "fixtures/expected/${name}.ansi" +done +``` + +Expected: five `.ansi` files exist, each non-empty. + +- [ ] **Step 3: Write the snapshot test** + +`tests/snapshots.rs`: + +```rust +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn binary_path() -> &'static str { + env!("CARGO_BIN_EXE_termdown") +} + +fn render(path: &Path) -> String { + let out = Command::new(binary_path()) + .arg(path) + .env("TERM_PROGRAM", "ghostty") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("termdown should run"); + assert!(out.status.success(), "termdown failed on {path:?}"); + String::from_utf8(out.stdout).expect("valid utf-8") +} + +fn check_snapshot(fixture: &str) { + let md = Path::new("fixtures").join(format!("{fixture}.md")); + let expected_path = Path::new("fixtures/expected").join(format!("{fixture}.ansi")); + let expected = fs::read_to_string(&expected_path).expect("expected file"); + let actual = render(&md); + if actual != expected { + let tmp = std::env::temp_dir().join(format!("termdown-snapshot-{fixture}.ansi")); + fs::write(&tmp, &actual).ok(); + panic!( + "snapshot mismatch for {fixture}\n expected: {}\n actual written to: {}", + expected_path.display(), + tmp.display() + ); + } +} + +#[test] fn snapshot_emoji_test() { check_snapshot("emoji-test"); } +#[test] fn snapshot_full_syntax_zh() { check_snapshot("full-syntax-zh"); } +#[test] fn snapshot_full_syntax() { check_snapshot("full-syntax"); } +#[test] fn snapshot_tasklist() { check_snapshot("tasklist"); } +#[test] fn snapshot_unsupported_syntax(){ check_snapshot("unsupported-syntax"); } +``` + +- [ ] **Step 4: Verify snapshot tests pass against frozen output** + +Run: `make check` +Expected: all tests pass, including the five new `snapshot_*` tests. + +- [ ] **Step 5: Commit** + +```sh +git add fixtures/expected tests/snapshots.rs +git commit -m "test: freeze cat output snapshots as refactor baseline" +``` + +--- + +## Phase 1 — Layout Refactor (cat path) + +**Why:** `markdown.rs` today is 800 lines of tangled state machine that writes directly to stdout. We split it into (a) `layout.rs` that produces a `RenderedDoc` and (b) `cat.rs` that serializes `RenderedDoc` → ANSI stdout. The TUI path in later phases will reuse `layout.rs`. + +### Task 1.1: Introduce core data types in layout.rs + +**Files:** +- Create: `src/layout.rs` +- Modify: `src/main.rs:1-6` (add `mod layout;`) + +- [ ] **Step 1: Write a compile-only test** + +Append to `src/layout.rs`: + +```rust +use crate::render::HeadingImage; + +#[derive(Debug, Clone)] +pub struct RenderedDoc { + pub lines: Vec, + pub headings: Vec, + pub images: Vec, +} + +#[derive(Debug, Clone)] +pub struct Line { + pub spans: Vec, + pub kind: LineKind, +} + +#[derive(Debug, Clone)] +pub enum LineKind { + Body, + Heading { level: u8, id: Option }, // id = Some for H1-H3, None for H4-H6 + CodeBlock { lang: Option }, + BlockQuote { depth: u8 }, + ListItem { depth: u8 }, + Table, + HorizontalRule, + Blank, +} + +#[derive(Debug, Clone)] +pub enum Span { + Text { content: String, style: Style }, + HeadingImage { id: u32, rows: u16 }, + Link { content: String, url: String, style: Style }, +} + +#[derive(Debug, Clone, Default)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, + pub dim: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum Color { + /// 256-color index (what the existing style.rs already emits) + Indexed(u8), + /// Truecolor fallback for future use + Rgb(u8, u8, u8), +} + +#[derive(Debug, Clone)] +pub struct HeadingEntry { + pub level: u8, + pub text: String, + pub line_index: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn types_compile() { + let _ = RenderedDoc { + lines: vec![Line { + spans: vec![Span::Text { + content: "hi".into(), + style: Style::default(), + }], + kind: LineKind::Body, + }], + headings: vec![], + images: vec![], + }; + } +} +``` + +- [ ] **Step 2: Move HeadingImage into render.rs** + +Add to `src/render.rs` (after the existing `kitty_display` function): + +```rust +/// PNG data + cell dimensions for a rendered heading image. +/// Stored by id in `RenderedDoc` and transmitted to the terminal +/// once per TUI session. +#[derive(Debug, Clone)] +pub struct HeadingImage { + pub id: u32, + pub png: Vec, + pub cols: u16, + pub rows: u16, +} +``` + +- [ ] **Step 3: Register the module** + +Edit `src/main.rs:1-6`: + +```rust +mod config; +mod font; +mod layout; +mod markdown; +mod render; +mod style; +mod theme; +``` + +- [ ] **Step 4: Verify** + +Run: `cargo test --lib layout::tests::types_compile` +Expected: PASS. + +Run: `make check` +Expected: all tests pass (including the snapshot tests from Phase 0). + +- [ ] **Step 5: Commit** + +```sh +git add src/layout.rs src/render.rs src/main.rs +git commit -m "feat(layout): introduce RenderedDoc/Line/Span types" +``` + +### Task 1.2: Port markdown event walk into layout.rs + +**Files:** +- Modify: `src/layout.rs` (add the `build` function) + +This is the core port. The existing `markdown::render` function walks pulldown-cmark events and writes to stdout. We lift the walk into a pure function that returns `RenderedDoc`. No behavior change yet — cat still uses the old path until Task 1.4 wires in `cat.rs`. + +- [ ] **Step 1: Write the first failing test (plain paragraph)** + +Append to the `#[cfg(test)] mod tests` in `src/layout.rs`: + +```rust +use crate::config::Config; +use crate::theme::Theme; + +fn build_plain(md: &str) -> RenderedDoc { + let cfg = Config::default(); + super::build(md, &cfg, Theme::Dark) +} + +#[test] +fn build_single_paragraph() { + let doc = build_plain("hello world\n"); + // One body line + one blank separator — match current cat behavior. + assert!(doc + .lines + .iter() + .any(|l| matches!(l.kind, LineKind::Body) && spans_plain_text(&l.spans) == "hello world")); +} + +fn spans_plain_text(spans: &[Span]) -> String { + let mut out = String::new(); + for s in spans { + match s { + Span::Text { content, .. } => out.push_str(content), + Span::Link { content, .. } => out.push_str(content), + Span::HeadingImage { .. } => {} + } + } + out +} +``` + +- [ ] **Step 2: Run the test — it fails because `build` doesn't exist** + +Run: `cargo test --lib layout::tests::build_single_paragraph` +Expected: FAIL with "cannot find function `build`". + +- [ ] **Step 3: Stub `Config::default`** + +If `Config::default` doesn't exist, add it in `src/config.rs`: + +```rust +impl Default for Config { + fn default() -> Self { + Self { theme: None, font: Default::default() } + } +} +``` + +(Inspect the existing `Config` struct first — if it already derives `Default`, skip. Add `#[derive(Default)]` on nested structs as needed.) + +- [ ] **Step 4: Implement `build` (minimal — handles paragraph only)** + +Add to `src/layout.rs`: + +```rust +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use crate::config::Config; +use crate::theme::Theme; + +pub fn build(md: &str, _config: &Config, _theme: Theme) -> RenderedDoc { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_STRIKETHROUGH); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_TASKLISTS); + let parser = Parser::new_ext(md, opts); + + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + + for event in parser { + match event { + Event::Start(Tag::Paragraph) => {} + Event::End(TagEnd::Paragraph) => { + lines.push(Line { + spans: vec![Span::Text { + content: std::mem::take(&mut current), + style: Style::default(), + }], + kind: LineKind::Body, + }); + } + Event::Text(t) => current.push_str(&t), + _ => {} + } + } + + RenderedDoc { lines, headings: vec![], images: vec![] } +} +``` + +- [ ] **Step 5: Run — test passes** + +Run: `cargo test --lib layout::tests::build_single_paragraph` +Expected: PASS. + +- [ ] **Step 6: Commit the first slice** + +```sh +git add src/layout.rs src/config.rs +git commit -m "feat(layout): build handles plain paragraphs" +``` + +### Task 1.3: Port inline text, emphasis, strong, strikethrough + +**Files:** +- Modify: `src/layout.rs` + +The cat output for inline formatting today embeds ANSI escapes into the text buffer. Our new model emits them as separate `Span::Text` entries with `Style` flags set. + +- [ ] **Step 1: Failing test** + +Append to the `tests` module in `src/layout.rs`: + +```rust +#[test] +fn build_inline_bold_and_italic() { + let doc = build_plain("hello **bold** and *it*\n"); + let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap(); + + // Expect at least: "hello ", "bold" (bold), " and ", "it" (italic) + let bold_span = line.spans.iter().find(|s| matches!(s, Span::Text { style, .. } if style.bold)); + let italic_span = line.spans.iter().find(|s| matches!(s, Span::Text { style, .. } if style.italic)); + assert!(matches!(bold_span, Some(Span::Text { content, .. }) if content == "bold")); + assert!(matches!(italic_span, Some(Span::Text { content, .. }) if content == "it")); +} +``` + +- [ ] **Step 2: Extend `build` to track inline state** + +Replace the body of `build` in `src/layout.rs`: + +```rust +pub fn build(md: &str, _config: &Config, _theme: Theme) -> RenderedDoc { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_STRIKETHROUGH); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_TASKLISTS); + let parser = Parser::new_ext(md, opts); + + let mut lines: Vec = Vec::new(); + let mut spans: Vec = Vec::new(); + let mut text_buf = String::new(); + let mut style = Style::default(); + + // Flush pending plain-text buffer into a span with the current style. + let flush_text = |text_buf: &mut String, spans: &mut Vec, style: &Style| { + if !text_buf.is_empty() { + spans.push(Span::Text { + content: std::mem::take(text_buf), + style: style.clone(), + }); + } + }; + + for event in parser { + match event { + Event::Start(Tag::Paragraph) => {} + Event::End(TagEnd::Paragraph) => { + flush_text(&mut text_buf, &mut spans, &style); + lines.push(Line { + spans: std::mem::take(&mut spans), + kind: LineKind::Body, + }); + } + Event::Start(Tag::Strong) => { + flush_text(&mut text_buf, &mut spans, &style); + style.bold = true; + } + Event::End(TagEnd::Strong) => { + flush_text(&mut text_buf, &mut spans, &style); + style.bold = false; + } + Event::Start(Tag::Emphasis) => { + flush_text(&mut text_buf, &mut spans, &style); + style.italic = true; + } + Event::End(TagEnd::Emphasis) => { + flush_text(&mut text_buf, &mut spans, &style); + style.italic = false; + } + Event::Start(Tag::Strikethrough) => { + flush_text(&mut text_buf, &mut spans, &style); + style.strikethrough = true; + } + Event::End(TagEnd::Strikethrough) => { + flush_text(&mut text_buf, &mut spans, &style); + style.strikethrough = false; + } + Event::Text(t) => text_buf.push_str(&t), + _ => {} + } + } + + RenderedDoc { lines, headings: vec![], images: vec![] } +} +``` + +- [ ] **Step 3: Run both tests** + +Run: `cargo test --lib layout::tests` +Expected: both `build_single_paragraph` and `build_inline_bold_and_italic` PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/layout.rs +git commit -m "feat(layout): support inline strong/emphasis/strikethrough" +``` + +### Task 1.4: Port links, inline code, autolinks + +**Files:** +- Modify: `src/layout.rs` + +- [ ] **Step 1: Failing test** + +```rust +#[test] +fn build_link_becomes_link_span() { + let doc = build_plain("see [docs](https://example.com) now\n"); + let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap(); + let link = line.spans.iter().find_map(|s| match s { + Span::Link { content, url, .. } => Some((content.clone(), url.clone())), + _ => None, + }); + assert_eq!(link, Some(("docs".into(), "https://example.com".into()))); +} + +#[test] +fn build_inline_code_has_code_style_flag() { + // Inline code shows up as a Text span with a fg/bg Style; we distinguish + // by a dedicated flag or by both colors being set. Use bg.is_some() as proxy. + let doc = build_plain("run `ls` now\n"); + let line = doc.lines.iter().find(|l| matches!(l.kind, LineKind::Body)).unwrap(); + let code = line.spans.iter().find_map(|s| match s { + Span::Text { content, style } if content == "ls" && style.bg.is_some() => Some(()), + _ => None, + }); + assert!(code.is_some()); +} +``` + +- [ ] **Step 2: Add Link + Code handling to `build`** + +Inside the match in `src/layout.rs::build`, add these arms (place before `Event::Text`): + +```rust +Event::Start(Tag::Link { dest_url, .. }) => { + flush_text(&mut text_buf, &mut spans, &style); + // Stash url; the following Event::Text is the link text. + pending_link_url = Some(dest_url.to_string()); +} +Event::End(TagEnd::Link) => { + if let Some(url) = pending_link_url.take() { + let content = std::mem::take(&mut text_buf); + spans.push(Span::Link { content, url, style: style.clone() }); + } +} +Event::Code(code) => { + flush_text(&mut text_buf, &mut spans, &style); + let mut code_style = style.clone(); + code_style.bg = Some(Color::Indexed(236)); + code_style.fg = Some(Color::Indexed(213)); + spans.push(Span::Text { + content: code.to_string(), + style: code_style, + }); +} +``` + +At the top of `build` add: + +```rust +let mut pending_link_url: Option = None; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib layout::tests` +Expected: all four layout tests PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/layout.rs +git commit -m "feat(layout): support links and inline code" +``` + +### Task 1.5: Port headings (H1-H3 image, H4-H6 text) + +**Files:** +- Modify: `src/layout.rs` +- Modify: `src/render.rs` (expose an id-allocation helper) + +The key addition: H1-H3 render to PNG via the existing `render::render_heading`, get assigned a unique `image_id`, and the heading `Line` gets a `Span::HeadingImage { id, rows }`. `rows` is derived from image height divided by an estimated cell height (we use a reasonable constant per level until we wire terminal cell size in Phase 3). + +- [ ] **Step 1: Failing test** + +```rust +#[test] +fn build_h1_emits_heading_image_span_and_entry() { + let md = "# Title\n\nbody\n"; + let doc = build_plain(md); + + // Heading line has a HeadingImage span (assuming fonts resolve on this platform; + // if not, assert that kind is Heading with a text-fallback span instead). + let heading_line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Heading { level: 1, .. })) + .expect("heading line should exist"); + + // Must have either a HeadingImage span or a plain-text fallback span. + let ok = heading_line.spans.iter().any(|s| { + matches!(s, Span::HeadingImage { .. }) + || matches!(s, Span::Text { content, .. } if content.contains("Title")) + }); + assert!(ok); + + // HeadingEntry present with matching text. + let entry = doc.headings.iter().find(|h| h.level == 1); + assert!(matches!(entry, Some(e) if e.text == "Title")); +} +``` + +- [ ] **Step 2: Add heading-level state to `build`** + +At the top of `build`, add: + +```rust +let mut heading_level: u8 = 0; +let mut heading_text = String::new(); +let mut next_image_id: u32 = 1; +let mut images: Vec = Vec::new(); +let mut headings: Vec = Vec::new(); +``` + +Inside the match, add (before the catch-all `_ => {}`): + +```rust +Event::Start(Tag::Heading { level, .. }) => { + heading_level = match level { + pulldown_cmark::HeadingLevel::H1 => 1, + pulldown_cmark::HeadingLevel::H2 => 2, + pulldown_cmark::HeadingLevel::H3 => 3, + pulldown_cmark::HeadingLevel::H4 => 4, + pulldown_cmark::HeadingLevel::H5 => 5, + pulldown_cmark::HeadingLevel::H6 => 6, + }; + heading_text.clear(); +} +Event::End(TagEnd::Heading(_)) => { + let text = std::mem::take(&mut heading_text); + headings.push(HeadingEntry { + level: heading_level, + text: text.clone(), + line_index: lines.len(), + }); + + let heading_spans: Vec = if heading_level <= 3 { + match crate::render::render_heading(&text, heading_level, _config, _theme) { + Some(png) => { + let id = next_image_id; + next_image_id += 1; + // Estimated rows: H1≈6, H2≈4, H3≈3 (refined in Phase 3 with real cell height). + let rows = match heading_level { 1 => 6, 2 => 4, _ => 3 }; + images.push(HeadingImage { id, png, cols: 0, rows }); + vec![Span::HeadingImage { id, rows }] + } + None => vec![Span::Text { + content: text.clone(), + style: Style { bold: true, ..Style::default() }, + }], + } + } else { + vec![Span::Text { + content: text.clone(), + style: Style { bold: true, ..Style::default() }, + }] + }; + + let id_for_kind = if heading_level <= 3 { + images.last().map(|img| img.id) + } else { + None + }; + lines.push(Line { + spans: heading_spans, + kind: LineKind::Heading { level: heading_level, id: id_for_kind }, + }); +} +``` + +Then redirect text during heading to `heading_text`. Modify the `Event::Text` arm: + +```rust +Event::Text(t) => { + if heading_level > 0 { + heading_text.push_str(&t); + } else { + text_buf.push_str(&t); + } +} +``` + +And make sure `Event::End(TagEnd::Heading)` resets `heading_level = 0` at the end of its arm (already clears `heading_text` via `take`; add `heading_level = 0;` as the last line). + +- [ ] **Step 3: Update `build` signature to pass config/theme through** + +Change the leading underscores to real bindings: + +```rust +pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc { +``` + +And use `config`/`theme` in the heading arm above. + +- [ ] **Step 4: Fix the remaining RenderedDoc return** + +Replace `RenderedDoc { lines, headings: vec![], images: vec![] }` with `RenderedDoc { lines, headings, images }`. + +- [ ] **Step 5: Run tests** + +Run: `cargo test --lib layout::tests` +Expected: all five layout tests PASS. + +- [ ] **Step 6: Commit** + +```sh +git add src/layout.rs src/render.rs +git commit -m "feat(layout): support headings with image ids" +``` + +### Task 1.6: Port blockquotes, lists, task lists, rules, code blocks + +**Files:** +- Modify: `src/layout.rs` + +Mirror the state logic from `src/markdown.rs:334-388` and `src/markdown.rs:391-414`. Each block element becomes one or more `Line` entries with the appropriate `LineKind`. + +- [ ] **Step 1: Failing tests (one per element)** + +Append to `src/layout.rs` tests: + +```rust +#[test] +fn build_blockquote_carries_depth() { + let doc = build_plain("> quoted\n"); + assert!(doc.lines.iter().any(|l| matches!(l.kind, LineKind::BlockQuote { depth: 1 }))); +} + +#[test] +fn build_unordered_list_item_has_depth() { + let doc = build_plain("- a\n- b\n"); + let items: Vec<_> = doc.lines.iter().filter(|l| matches!(l.kind, LineKind::ListItem { depth: 1 })).collect(); + assert_eq!(items.len(), 2); +} + +#[test] +fn build_rule_emits_horizontal_rule_line() { + let doc = build_plain("---\n"); + assert!(doc.lines.iter().any(|l| matches!(l.kind, LineKind::HorizontalRule))); +} + +#[test] +fn build_code_block_emits_codeblock_lines() { + let doc = build_plain("```rust\nfn main() {}\n```\n"); + let lang_ok = doc.lines.iter().any(|l| matches!( + &l.kind, + LineKind::CodeBlock { lang: Some(s) } if s == "rust" + )); + assert!(lang_ok); +} +``` + +- [ ] **Step 2: Add state + arms for these elements** + +Add to `build`'s state: + +```rust +let mut quote_depth: u8 = 0; +struct ListState { ordered: bool, counter: u64 } +let mut list_stack: Vec = Vec::new(); +let mut in_code_block: Option> = None; // Some(lang) while active +``` + +Add arms in the event match: + +```rust +Event::Start(Tag::BlockQuote(..)) => quote_depth += 1, +Event::End(TagEnd::BlockQuote(..)) => quote_depth = quote_depth.saturating_sub(1), + +Event::Start(Tag::List(start)) => list_stack.push(ListState { + ordered: start.is_some(), + counter: start.unwrap_or(1), +}), +Event::End(TagEnd::List(..)) => { list_stack.pop(); } + +Event::Start(Tag::Item) => { + // Start collecting a new list item's inline content. +} +Event::End(TagEnd::Item) => { + flush_text(&mut text_buf, &mut spans, &style); + let depth = list_stack.len() as u8; + lines.push(Line { + spans: std::mem::take(&mut spans), + kind: LineKind::ListItem { depth }, + }); +} + +Event::Start(Tag::CodeBlock(kind)) => { + let lang = match kind { + pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()), + _ => None, + }; + in_code_block = Some(lang); +} +Event::End(TagEnd::CodeBlock) => { in_code_block = None; } + +Event::Rule => { + lines.push(Line { spans: vec![], kind: LineKind::HorizontalRule }); +} +``` + +Modify `Event::Text` to route code-block content: + +```rust +Event::Text(t) => { + if heading_level > 0 { + heading_text.push_str(&t); + } else if let Some(lang) = &in_code_block { + let lang = lang.clone(); + for line in t.lines() { + lines.push(Line { + spans: vec![Span::Text { content: line.to_string(), style: Style::default() }], + kind: LineKind::CodeBlock { lang: lang.clone() }, + }); + } + } else { + text_buf.push_str(&t); + } +} +``` + +Blockquotes: when `quote_depth > 0` and we push a paragraph line, set `kind: LineKind::BlockQuote { depth: quote_depth }` instead of `Body`. Update the `Event::End(TagEnd::Paragraph)` arm: + +```rust +Event::End(TagEnd::Paragraph) => { + flush_text(&mut text_buf, &mut spans, &style); + let kind = if quote_depth > 0 { + LineKind::BlockQuote { depth: quote_depth } + } else { + LineKind::Body + }; + lines.push(Line { spans: std::mem::take(&mut spans), kind }); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib layout::tests` +Expected: all layout tests PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/layout.rs +git commit -m "feat(layout): support quotes, lists, rules, code blocks" +``` + +### Task 1.7: Port tables, task lists, images, HTML inline & block, breaks + +**Files:** +- Modify: `src/layout.rs` + +Rather than duplicating the existing `render_table` bytes into `Span::Text`, store each rendered table row as a separate `Line { kind: Table }` with spans for cells. The table formatter stays in `layout.rs` (moved from `markdown.rs`). + +- [ ] **Step 1: Failing tests** + +```rust +#[test] +fn build_table_emits_table_lines() { + let doc = build_plain("| A | B |\n| - | - |\n| x | y |\n"); + let rows: Vec<_> = doc.lines.iter().filter(|l| matches!(l.kind, LineKind::Table)).collect(); + // Header + separator + body = 3 lines minimum. + assert!(rows.len() >= 3); +} + +#[test] +fn build_task_list_marker_replaces_bullet() { + let doc = build_plain("- [x] done\n- [ ] todo\n"); + let items: Vec<_> = doc.lines.iter().filter(|l| matches!(l.kind, LineKind::ListItem { .. })).collect(); + assert_eq!(items.len(), 2); + let rendered = spans_plain_text(&items[0].spans); + assert!(rendered.contains("[✓]") || rendered.contains("[x]")); +} + +#[test] +fn build_image_renders_placeholder_text() { + let doc = build_plain("![alt](https://example.com/x.png)\n"); + let any_placeholder = doc.lines.iter().any(|l| { + spans_plain_text(&l.spans).contains("alt") && spans_plain_text(&l.spans).contains("https://") + }); + assert!(any_placeholder); +} +``` + +- [ ] **Step 2: Port table state** + +Add state: + +```rust +let mut table_rows: Vec>> = Vec::new(); // rows × cells × spans +let mut current_row: Vec> = Vec::new(); +let mut in_table_header = false; +let mut image_url: Option = None; +``` + +Add arms (in the event match): + +```rust +Event::Start(Tag::Table(..)) => { + table_rows.clear(); + in_table_header = false; +} +Event::End(TagEnd::Table) => { + emit_table(&mut lines, &table_rows); + table_rows.clear(); +} +Event::Start(Tag::TableHead) => { + in_table_header = true; + current_row.clear(); +} +Event::End(TagEnd::TableHead) => { + table_rows.push(std::mem::take(&mut current_row)); + in_table_header = false; +} +Event::Start(Tag::TableRow) => { current_row.clear(); } +Event::End(TagEnd::TableRow) => { + table_rows.push(std::mem::take(&mut current_row)); +} +Event::Start(Tag::TableCell) => { spans.clear(); } +Event::End(TagEnd::TableCell) => { + flush_text(&mut text_buf, &mut spans, &style); + if in_table_header { + for s in spans.iter_mut() { + if let Span::Text { style, .. } = s { style.bold = true; } + } + } + current_row.push(std::mem::take(&mut spans)); +} + +Event::Start(Tag::Image { dest_url, .. }) => { image_url = Some(dest_url.to_string()); } +Event::End(TagEnd::Image) => { + flush_text(&mut text_buf, &mut spans, &style); + let alt = spans_plain_text_inline(&spans); + spans.clear(); + let url = image_url.take().unwrap_or_default(); + let content = format!("[\u{1f5bc} {alt}]({url})"); + let mut dim = Style::default(); + dim.dim = true; + lines.push(Line { + spans: vec![Span::Text { content, style: dim }], + kind: LineKind::Body, + }); +} + +Event::TaskListMarker(checked) => { + // Replace the trailing bullet placeholder the list Start arm set. + // We haven't implemented one in Start(Item); instead prepend the marker here + // to the current line's first text span. + let marker = if checked { "[\u{2713}] " } else { "[ ] " }; + if spans.is_empty() && text_buf.is_empty() { + text_buf.push_str(marker); + } else { + // Prepend to first span's content + if let Some(Span::Text { content, .. }) = spans.first_mut() { + *content = format!("{marker}{content}"); + } else { + text_buf = format!("{marker}{text_buf}"); + } + } +} + +Event::SoftBreak | Event::HardBreak => { + if heading_level > 0 { + heading_text.push(' '); + } else { + text_buf.push(' '); + } +} +``` + +Helpers outside the loop: + +```rust +fn emit_table(lines: &mut Vec, rows: &[Vec>]) { + if rows.is_empty() { return; } + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + let mut widths = vec![0usize; cols]; + for row in rows { + for (i, cell) in row.iter().enumerate() { + let w: usize = cell.iter().map(|s| plain_width(s)).sum(); + widths[i] = widths[i].max(w); + } + } + for (ri, row) in rows.iter().enumerate() { + let mut spans: Vec = Vec::new(); + for (i, cell) in row.iter().enumerate() { + for s in cell { spans.push(s.clone()); } + let w: usize = cell.iter().map(|s| plain_width(s)).sum(); + let pad = widths[i].saturating_sub(w); + if pad > 0 { spans.push(Span::Text { content: " ".repeat(pad), style: Style::default() }); } + if i < row.len() - 1 { + let mut dim = Style::default(); dim.dim = true; + spans.push(Span::Text { content: " │ ".into(), style: dim }); + } + } + lines.push(Line { spans, kind: LineKind::Table }); + if ri == 0 { + let mut sep_spans: Vec = Vec::new(); + for (i, &w) in widths.iter().enumerate() { + let mut dim = Style::default(); dim.dim = true; + sep_spans.push(Span::Text { content: "─".repeat(w), style: dim.clone() }); + if i < widths.len() - 1 { + sep_spans.push(Span::Text { content: " ┼ ".into(), style: dim }); + } + } + lines.push(Line { spans: sep_spans, kind: LineKind::Table }); + } + } +} + +fn plain_width(span: &Span) -> usize { + match span { + Span::Text { content, .. } => unicode_width::UnicodeWidthStr::width(content.as_str()), + Span::Link { content, .. } => unicode_width::UnicodeWidthStr::width(content.as_str()), + Span::HeadingImage { .. } => 0, + } +} + +fn spans_plain_text_inline(spans: &[Span]) -> String { + let mut s = String::new(); + for sp in spans { + match sp { + Span::Text { content, .. } => s.push_str(content), + Span::Link { content, .. } => s.push_str(content), + Span::HeadingImage { .. } => {} + } + } + s +} +``` + +HTML support (both inline and block) can be handled minimally by treating HTML blocks as dimmed-text `LineKind::Body` entries, mirroring `flush_html_block` behavior: + +```rust +Event::Html(s) => { + for line in s.lines() { + let mut dim = Style::default(); dim.dim = true; + lines.push(Line { + spans: vec![Span::Text { content: line.to_string(), style: dim }], + kind: LineKind::Body, + }); + } +} +Event::InlineHtml(s) => { + // v1: pass the raw tag text through as plain text; cat output will match + // existing behavior closely enough for the snapshot audit. + text_buf.push_str(&s); +} +``` + +- [ ] **Step 3: Run all layout tests** + +Run: `cargo test --lib layout::tests` +Expected: PASS (8 tests). + +- [ ] **Step 4: Commit** + +```sh +git add src/layout.rs +git commit -m "feat(layout): support tables, task markers, images, html" +``` + +### Task 1.8: Write cat.rs (RenderedDoc → stdout) + +**Files:** +- Create: `src/cat.rs` +- Modify: `src/main.rs` (add `mod cat;`) + +This module takes `RenderedDoc` and writes the ANSI stream that used to come from `markdown.rs`. It owns wrapping, margins, quote prefixes, list indentation, table margin alignment, and the Kitty image emission for heading images. + +- [ ] **Step 1: Module skeleton** + +Create `src/cat.rs`: + +```rust +use std::io::{BufWriter, Write}; + +use crate::layout::{Color, HeadingEntry, Line, LineKind, RenderedDoc, Span, Style}; +use crate::render; +use crate::style::{ + display_width, Colors, BOLD_ON, DIM_ON, ITALIC_OFF, ITALIC_ON, MARGIN, MARGIN_WIDTH, RESET, + STRIKETHROUGH_OFF, STRIKETHROUGH_ON, UNDERLINE_OFF, UNDERLINE_ON, +}; + +pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors) { + let stdout = std::io::stdout(); + let mut out = BufWriter::new(stdout.lock()); + let mut first_block = true; + for line in &doc.lines { + write_line(&mut out, line, &doc.images, term_width, colors, &mut first_block); + } + let _ = out.flush(); +} + +fn write_line( + out: &mut W, + line: &Line, + images: &[crate::render::HeadingImage], + term_width: usize, + colors: &Colors, + first_block: &mut bool, +) { + match &line.kind { + LineKind::Blank => { + let _ = writeln!(out); + } + LineKind::HorizontalRule => { + block_gap(out, first_block); + let width = term_width.min(62).saturating_sub(2); + let _ = writeln!(out, "{MARGIN}{DIM_ON}{}{RESET}", "\u{2500}".repeat(width)); + } + LineKind::Heading { level, id } => { + block_gap(out, first_block); + if let Some(image_id) = id { + if let Some(img) = images.iter().find(|i| i.id == *image_id) { + let _ = writeln!(out, "{MARGIN}{}", render::kitty_display(&img.png)); + return; + } + } + // Fallback: plain bold text derived from line spans. + let text = render_spans_plain(&line.spans); + let _ = writeln!(out, "{MARGIN}{BOLD_ON}{text}{RESET}"); + let _ = level; // unused; kept for future font-scaling hook + } + LineKind::BlockQuote { depth } => { + write_paragraph(out, &line.spans, *depth as usize, term_width, colors); + } + LineKind::Body => { + write_paragraph(out, &line.spans, 0, term_width, colors); + } + LineKind::ListItem { depth } => { + let indent = " ".repeat((*depth as usize).saturating_sub(1)); + // Bullet or number injection happens during layout; spans are cell text. + let mut buf = format!("{MARGIN}{indent}\u{2022} "); + buf.push_str(&render_spans_ansi(&line.spans, colors)); + wrap_and_write(out, &buf, term_width, ""); + } + LineKind::CodeBlock { .. } => { + let text = render_spans_plain(&line.spans); + let _ = writeln!( + out, + "{MARGIN}{}{} {text} {RESET}", + colors.code_bg, colors.code_fg + ); + } + LineKind::Table => { + // Table rows are pre-padded by layout; just add margin and emit. + let rendered = render_spans_ansi(&line.spans, colors); + let _ = writeln!(out, "{MARGIN} {rendered}"); + } + } +} + +fn block_gap(out: &mut W, first_block: &mut bool) { + if !*first_block { let _ = writeln!(out); } + *first_block = false; +} + +fn write_paragraph( + out: &mut W, + spans: &[Span], + quote_depth: usize, + term_width: usize, + colors: &Colors, +) { + let body = render_spans_ansi(spans, colors); + let prefix = if quote_depth > 0 { + let bars: String = (0..quote_depth) + .map(|_| format!("{}\u{2502} ", colors.quote_bar)) + .collect(); + format!("{MARGIN}{bars}{}", colors.quote_text) + } else { + MARGIN.to_string() + }; + let suffix = if quote_depth > 0 { RESET } else { "" }; + let prefix_visual_width = MARGIN_WIDTH + quote_depth * 3; + let max_text_width = term_width.saturating_sub(prefix_visual_width); + + if max_text_width == 0 || display_width(&body) <= max_text_width { + let _ = writeln!(out, "{prefix}{body}{suffix}"); + } else { + for wrapped in wrap_text(&body, max_text_width) { + let _ = writeln!(out, "{prefix}{wrapped}{suffix}"); + } + } +} + +fn wrap_and_write(out: &mut W, text: &str, term_width: usize, suffix: &str) { + let max = term_width.saturating_sub(MARGIN_WIDTH); + if max == 0 || display_width(text) <= max { + let _ = writeln!(out, "{text}{suffix}"); + return; + } + for wrapped in wrap_text(text, max) { + let _ = writeln!(out, "{wrapped}{suffix}"); + } +} + +fn wrap_text(text: &str, max_width: usize) -> Vec { + // Identical to markdown::wrap_text — moved here for cat.rs self-sufficiency. + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width: usize = 0; + for word in text.split_inclusive(' ') { + let w = display_width(word); + if current_width + w > max_width && !current.is_empty() { + lines.push(current.trim_end().to_string()); + current = String::new(); + current_width = 0; + } + current.push_str(word); + current_width += w; + } + if !current.is_empty() { lines.push(current); } + if lines.is_empty() { lines.push(text.to_string()); } + lines +} + +fn render_spans_plain(spans: &[Span]) -> String { + let mut s = String::new(); + for sp in spans { + match sp { + Span::Text { content, .. } | Span::Link { content, .. } => s.push_str(content), + Span::HeadingImage { .. } => {} + } + } + s +} + +fn render_spans_ansi(spans: &[Span], colors: &Colors) -> String { + let mut out = String::new(); + for sp in spans { + match sp { + Span::Text { content, style } => { + push_style_on(&mut out, style, colors); + out.push_str(content); + push_style_off(&mut out, style); + } + Span::Link { content, url, style } => { + out.push_str(colors.link); + out.push_str(UNDERLINE_ON); + push_style_on(&mut out, style, colors); + out.push_str(content); + push_style_off(&mut out, style); + out.push_str(UNDERLINE_OFF); + out.push_str(RESET); + if !url.is_empty() { + out.push_str(&format!(" {}({url}){RESET}", colors.url)); + } + } + Span::HeadingImage { .. } => {} + } + } + out +} + +fn push_style_on(out: &mut String, style: &Style, _colors: &Colors) { + if style.bold { out.push_str(BOLD_ON); } + if style.italic { out.push_str(ITALIC_ON); } + if style.underline { out.push_str(UNDERLINE_ON); } + if style.strikethrough { out.push_str(STRIKETHROUGH_ON); } + if style.dim { out.push_str(DIM_ON); } + if let Some(fg) = &style.fg { out.push_str(&color_fg(*fg)); } + if let Some(bg) = &style.bg { out.push_str(&color_bg(*bg)); } +} + +fn push_style_off(out: &mut String, style: &Style) { + // Use RESET when any heavy attribute was on, otherwise emit targeted off codes. + if style.fg.is_some() || style.bg.is_some() || style.bold || style.dim { + out.push_str(RESET); + } else { + if style.italic { out.push_str(ITALIC_OFF); } + if style.underline { out.push_str(UNDERLINE_OFF); } + if style.strikethrough { out.push_str(STRIKETHROUGH_OFF); } + } +} + +fn color_fg(c: Color) -> String { + match c { + Color::Indexed(n) => format!("\x1b[38;5;{n}m"), + Color::Rgb(r, g, b) => format!("\x1b[38;2;{r};{g};{b}m"), + } +} + +fn color_bg(c: Color) -> String { + match c { + Color::Indexed(n) => format!("\x1b[48;5;{n}m"), + Color::Rgb(r, g, b) => format!("\x1b[48;2;{r};{g};{b}m"), + } +} + +// Suppress unused-warning on future consumers +#[allow(dead_code)] +pub fn headings(doc: &RenderedDoc) -> &[HeadingEntry] { &doc.headings } +``` + +- [ ] **Step 2: Register the module** + +Add `mod cat;` to `src/main.rs` below `mod config;`. + +- [ ] **Step 3: Verify build** + +Run: `make build` +Expected: compiles with no errors (warnings allowed; `make check`'s clippy will flag unused if any — fix them). + +- [ ] **Step 4: Commit** + +```sh +git add src/cat.rs src/main.rs +git commit -m "feat(cat): render RenderedDoc to ANSI stdout" +``` + +### Task 1.9: Wire main.rs to the new cat path and audit snapshot diffs + +**Files:** +- Modify: `src/main.rs` +- Modify: `fixtures/expected/*.ansi` (only if intentional drift accepted) + +- [ ] **Step 1: Switch the default path** + +Edit `src/main.rs:96` — replace: + +```rust +markdown::render(&md, term_width, &config, theme, &colors); +``` + +with: + +```rust +let doc = layout::build(&md, &config, theme); +cat::print(&doc, term_width, &colors); +``` + +- [ ] **Step 2: Run the snapshot tests and observe failures** + +Run: `cargo test --test snapshots` +Expected: failures are likely (byte drift). The failure message points to the temp file with the new output. + +- [ ] **Step 3: Diff each drift case** + +For each failing fixture, open a diff between `fixtures/expected/.ansi` and the temp file. Evaluate: +- If visible rendering matches the old one (margins, colors, wrap points, image placement), accept the drift: `cp /tmp/termdown-snapshot-.ansi fixtures/expected/.ansi`. +- If visible rendering differs (wrong wrap, missing emphasis, wrong color), fix `layout.rs` or `cat.rs` and rerun. + +This step has no single command — it's a manual audit. Expect 2-5 rounds of fix + rerun. + +- [ ] **Step 4: Confirm clean snapshots** + +Run: `make check` +Expected: all tests pass, including `snapshot_*`. + +- [ ] **Step 5: Commit** + +```sh +git add src/main.rs fixtures/expected +git commit -m "refactor: route cat mode through layout.rs + cat.rs" +``` + +### Task 1.10: Remove obsolete logic from markdown.rs + +**Files:** +- Delete or shrink: `src/markdown.rs` + +If `markdown::render` is no longer called, remove the module or leave a deprecation shim. Preferred: remove the function and any no-longer-referenced helpers. Keep standalone tests that still apply (wrap_text test, table test) by moving them to the corresponding module (`cat.rs`'s unit tests or a new `layout.rs` test). + +- [ ] **Step 1: Identify dead items** + +Run: `cargo build --all-targets 2>&1 | rg 'unused|dead_code'` +Expected: a list of functions in `markdown.rs` that are now unused. + +- [ ] **Step 2: Move kept tests** + +The tests `wrap_text_keeps_single_overlong_word_intact` and `wrap_text_uses_display_width_when_ansi_and_wide_chars_are_present` describe `cat.rs::wrap_text` now — move to `#[cfg(test)] mod tests` in `src/cat.rs`. + +`render_table_aligns_columns_using_visual_width` describes `layout.rs::emit_table` — move to `src/layout.rs` tests, rewriting it to assert over span text rather than ANSI-tagged strings. + +- [ ] **Step 3: Delete markdown.rs** + +```sh +rm src/markdown.rs +``` + +Remove `mod markdown;` from `src/main.rs`. + +- [ ] **Step 4: Final check** + +Run: `make check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add -u src/main.rs +git rm src/markdown.rs +git commit -m "refactor: remove legacy markdown.rs now that cat.rs owns output" +``` + +--- + +## Phase 2 — TUI Scaffold + +### Task 2.1: Add dependencies + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Add the deps** + +Edit `Cargo.toml`, under `[dependencies]`: + +```toml +crossterm = "0.28" +ratatui = "0.28" +tui-textarea = "0.7" +regex = "1" +``` + +- [ ] **Step 2: Verify build** + +Run: `make build` +Expected: compiles. New crates resolve. + +- [ ] **Step 3: Commit** + +```sh +git add Cargo.toml Cargo.lock +git commit -m "chore: add ratatui/crossterm/tui-textarea/regex deps" +``` + +### Task 2.2: Parse --tui flag and dispatch + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Failing CLI test** + +Add to `tests/cli.rs`: + +```rust +#[test] +fn tui_without_file_fails_with_error() { + let output = run_termdown(&["--tui"], None, &[("TERM_PROGRAM", "ghostty")], &[]); + assert!(!output.status.success()); + assert!(stderr_text(&output).contains("--tui requires a FILE")); +} + +#[test] +fn tui_with_stdin_fails() { + let output = run_termdown( + &["--tui", "-"], + Some("# hi\n"), + &[("TERM_PROGRAM", "ghostty")], + &[], + ); + assert!(!output.status.success()); +} +``` + +- [ ] **Step 2: Add the dispatch** + +Modify `src/main.rs` (after the `--version` block, before `check_terminal_support`): + +```rust +let tui_mode = args.iter().any(|a| a == "--tui"); +``` + +Update the help text lines inside the `--help` branch to include `--tui`: + +```rust +println!(" --tui Open FILE in interactive TUI mode"); +``` + +After file_arg resolution, add a guard: + +```rust +if tui_mode { + let path = match file_arg.as_deref() { + Some("-") | None => { + eprintln!("termdown: --tui requires a FILE argument (stdin is not supported)"); + std::process::exit(2); + } + Some(p) => p.to_string(), + }; + // Dispatch to tui (module to be created in Task 2.3). + crate::tui::run(&path, &config, theme); + return; +} +``` + +- [ ] **Step 3: Create a stub `tui` module** + +Create `src/tui/mod.rs`: + +```rust +use crate::config::Config; +use crate::theme::Theme; + +pub fn run(path: &str, _config: &Config, _theme: Theme) { + eprintln!("termdown: --tui not yet implemented (file: {path})"); + std::process::exit(1); +} +``` + +Add `mod tui;` to `src/main.rs`. + +- [ ] **Step 4: Run tests** + +Run: `cargo test --test cli tui_` +Expected: both new tests PASS (exit-code-based; the second test also exits 1 since the stub unconditionally errors — adjust the assertion to `!status.success()` if needed). + +- [ ] **Step 5: Run `make check`** + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```sh +git add src/main.rs src/tui/mod.rs tests/cli.rs +git commit -m "feat(tui): add --tui CLI flag with stub dispatch" +``` + +### Task 2.3: TUI terminal setup and bare event loop + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Replace the stub with real setup** + +Overwrite `src/tui/mod.rs`: + +```rust +use std::io; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::config::Config; +use crate::layout; +use crate::theme::Theme; + +pub fn run(path: &str, config: &Config, theme: Theme) { + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("termdown: error reading {path}: {e}"); + std::process::exit(1); + } + }; + let doc = layout::build(&source, config, theme); + + if let Err(e) = run_ui(doc) { + eprintln!("termdown: tui error: {e}"); + std::process::exit(1); + } +} + +fn run_ui(_doc: layout::RenderedDoc) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = event_loop(&mut terminal); + + disable_raw_mode()?; + crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +} + +fn event_loop(terminal: &mut Terminal) -> io::Result<()> { + loop { + terminal.draw(|frame| { + use ratatui::widgets::{Block, Borders, Paragraph}; + let block = Block::default().borders(Borders::NONE); + let para = Paragraph::new("termdown TUI — press q to quit").block(block); + frame.render_widget(para, frame.area()); + })?; + + if event::poll(Duration::from_millis(16))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Char('q')) { + return Ok(()); + } + if key.kind == KeyEventKind::Press + && key.code == KeyCode::Char('c') + && key.modifiers.contains(event::KeyModifiers::CONTROL) + { + return Ok(()); + } + } + } + } +} +``` + +- [ ] **Step 2: Verify compile** + +Run: `make build` +Expected: compiles clean. + +- [ ] **Step 3: Smoke test manually (optional here, mandatory in Phase 7 QA)** + +Run: `cargo run -- --tui README.md` in a terminal. +Expected: alternate screen shows `termdown TUI — press q to quit`; `q` or `Ctrl-C` exits cleanly with terminal restored. + +- [ ] **Step 4: Run `make check`** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): raw-mode alt-screen with q/Ctrl-C exit" +``` + +### Task 2.4: Viewport module — wrap cache + scroll state + +**Files:** +- Create: `src/tui/viewport.rs` +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Failing test** + +Create `src/tui/viewport.rs`: + +```rust +use crate::layout::{Line, RenderedDoc}; + +/// A wrapped visual line, pointing back to a logical `Line` and the byte range it covers. +#[derive(Debug, Clone)] +pub struct VisualLine { + pub logical_index: usize, + pub byte_start: usize, + pub byte_end: usize, +} + +pub struct Viewport { + pub top: usize, // index into `visual_lines` + pub height: u16, + pub width: u16, + visual_lines: Vec, + cache_width: u16, +} + +impl Viewport { + pub fn new(height: u16, width: u16) -> Self { + Self { top: 0, height, width, visual_lines: Vec::new(), cache_width: 0 } + } + + pub fn ensure_wrap(&mut self, doc: &RenderedDoc) { + if self.cache_width == self.width && !self.visual_lines.is_empty() { + return; + } + self.visual_lines = wrap_all(&doc.lines, self.width); + self.cache_width = self.width; + if self.top > self.visual_lines.len().saturating_sub(1) { + self.top = self.visual_lines.len().saturating_sub(1); + } + } + + pub fn scroll_by(&mut self, delta: i32) { + let max = self.visual_lines.len().saturating_sub(self.height as usize); + let new_top = (self.top as i32 + delta).max(0) as usize; + self.top = new_top.min(max); + } + + pub fn visible<'a>(&'a self) -> &'a [VisualLine] { + let end = (self.top + self.height as usize).min(self.visual_lines.len()); + &self.visual_lines[self.top..end] + } + + pub fn total_visual_lines(&self) -> usize { self.visual_lines.len() } +} + +fn wrap_all(lines: &[Line], width: u16) -> Vec { + let mut out = Vec::with_capacity(lines.len()); + for (i, line) in lines.iter().enumerate() { + let byte_len = line_byte_len(line); + // v1 wrap: naive — no actual width-aware break yet; one visual per logical line. + // Phase 4 refines with CJK-aware break points. + let _ = width; + out.push(VisualLine { logical_index: i, byte_start: 0, byte_end: byte_len }); + } + out +} + +fn line_byte_len(line: &Line) -> usize { + line.spans.iter().map(|s| match s { + crate::layout::Span::Text { content, .. } => content.len(), + crate::layout::Span::Link { content, .. } => content.len(), + crate::layout::Span::HeadingImage { .. } => 0, + }).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::{LineKind, Span, Style}; + + fn make_doc(n: usize) -> RenderedDoc { + let lines = (0..n).map(|i| Line { + spans: vec![Span::Text { content: format!("line {i}"), style: Style::default() }], + kind: LineKind::Body, + }).collect(); + RenderedDoc { lines, headings: vec![], images: vec![] } + } + + #[test] + fn scroll_respects_bounds() { + let doc = make_doc(10); + let mut vp = Viewport::new(4, 40); + vp.ensure_wrap(&doc); + assert_eq!(vp.top, 0); + vp.scroll_by(-3); + assert_eq!(vp.top, 0); + vp.scroll_by(100); + assert_eq!(vp.top, 6); // max = 10 - 4 + assert_eq!(vp.visible().len(), 4); + } +} +``` + +- [ ] **Step 2: Wire it into tui/mod.rs** + +Add `mod viewport;` at the top of `src/tui/mod.rs`. The event loop doesn't use the viewport yet — Task 2.5 does that. + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib tui::viewport::tests` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/viewport.rs src/tui/mod.rs +git commit -m "feat(tui): viewport with wrap cache and scroll state" +``` + +### Task 2.5: Wire viewport into event loop with j/k scroll + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Build an App wrapping doc + viewport** + +Add to the top of `src/tui/mod.rs` (before `run_ui`): + +```rust +mod viewport; +use viewport::Viewport; + +struct App { + doc: layout::RenderedDoc, + viewport: Viewport, +} + +impl App { + fn new(doc: layout::RenderedDoc, height: u16, width: u16) -> Self { + Self { doc, viewport: Viewport::new(height, width) } + } +} +``` + +Replace `run_ui` and `event_loop`: + +```rust +fn run_ui(doc: layout::RenderedDoc) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let size = terminal.size()?; + let mut app = App::new(doc, size.height, size.width); + + let result = event_loop(&mut terminal, &mut app); + + disable_raw_mode()?; + crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +} + +fn event_loop( + terminal: &mut Terminal, + app: &mut App, +) -> io::Result<()> { + loop { + app.viewport.ensure_wrap(&app.doc); + terminal.draw(|frame| draw(frame, app))?; + + if event::poll(Duration::from_millis(16))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { continue; } + let ctrl = key.modifiers.contains(event::KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Char('c') if ctrl => return Ok(()), + KeyCode::Char('j') | KeyCode::Down => app.viewport.scroll_by(1), + KeyCode::Char('k') | KeyCode::Up => app.viewport.scroll_by(-1), + _ => {} + } + } + } + } +} + +fn draw(frame: &mut ratatui::Frame, app: &App) { + use ratatui::text::{Line as RLine, Span as RSpan}; + use ratatui::widgets::Paragraph; + + let rendered: Vec = app.viewport.visible().iter() + .map(|vl| { + let logical = &app.doc.lines[vl.logical_index]; + let mut rspans: Vec = Vec::new(); + for span in &logical.spans { + match span { + layout::Span::Text { content, .. } | layout::Span::Link { content, .. } => { + rspans.push(RSpan::raw(content.clone())); + } + layout::Span::HeadingImage { .. } => { + // Placeholder — Phase 3 fills with reserved rows. + rspans.push(RSpan::raw("[image]")); + } + } + } + RLine::from(rspans) + }) + .collect(); + + let para = Paragraph::new(rendered); + frame.render_widget(para, frame.area()); +} +``` + +- [ ] **Step 2: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Expected: content shows, `j`/`k` scrolls, `q` exits. + +- [ ] **Step 3: `make check`** + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): render doc with j/k scrolling" +``` + +### Task 2.6: Input module — action abstraction + +**Files:** +- Create: `src/tui/input.rs` +- Modify: `src/tui/mod.rs` + +Split the key-to-action mapping out of the event loop so Phase 4 can add many more bindings without bloating `mod.rs`. + +- [ ] **Step 1: Create the module with the full normal-mode key map** + +Create `src/tui/input.rs`: + +```rust +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Quit, + ScrollLines(i32), + ScrollHalfPage(i32), // ±1 + ScrollPage(i32), // ±1 + JumpStart, + JumpEnd, + NextHeading, + PrevHeading, + ToggleToc, + SearchBegin { reverse: bool }, + SearchNext, + SearchPrev, + OpenLink, + Back, + Forward, + None, +} + +pub fn map_normal(key: KeyEvent) -> Action { + if key.kind != KeyEventKind::Press { return Action::None; } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('c') if ctrl => Action::Quit, + KeyCode::Char('j') | KeyCode::Down => Action::ScrollLines(1), + KeyCode::Char('k') | KeyCode::Up => Action::ScrollLines(-1), + KeyCode::Char('d') => Action::ScrollHalfPage(1), + KeyCode::Char('u') => Action::ScrollHalfPage(-1), + KeyCode::Char('f') | KeyCode::Char(' ') | KeyCode::PageDown => Action::ScrollPage(1), + KeyCode::Char('b') | KeyCode::PageUp => Action::ScrollPage(-1), + KeyCode::Char('G') => Action::JumpEnd, + KeyCode::Char('g') => Action::JumpStart, // `gg` handled with prior-g state in Task 4.1 + KeyCode::Char(']') => Action::NextHeading, + KeyCode::Char('[') => Action::PrevHeading, + KeyCode::Char('t') => Action::ToggleToc, + KeyCode::Char('/') => Action::SearchBegin { reverse: false }, + KeyCode::Char('?') => Action::SearchBegin { reverse: true }, + KeyCode::Char('n') => Action::SearchNext, + KeyCode::Char('N') => Action::SearchPrev, + KeyCode::Enter => Action::OpenLink, + KeyCode::Char('o') => Action::Back, + KeyCode::Char('i') => Action::Forward, + _ => Action::None, + } +} +``` + +- [ ] **Step 2: Use it in the event loop** + +Replace the key-code match block in `event_loop` with: + +```rust +if let Event::Key(key) = event::read()? { + match input::map_normal(key) { + input::Action::Quit => return Ok(()), + input::Action::ScrollLines(d) => app.viewport.scroll_by(d), + input::Action::ScrollHalfPage(s) => { + let delta = (app.viewport.height as i32 / 2) * s; + app.viewport.scroll_by(delta); + } + input::Action::ScrollPage(s) => { + let delta = app.viewport.height as i32 * s; + app.viewport.scroll_by(delta); + } + input::Action::JumpStart => app.viewport.top = 0, + input::Action::JumpEnd => { + let max = app.viewport.total_visual_lines().saturating_sub(app.viewport.height as usize); + app.viewport.top = max; + } + _ => {} // filled in Phase 4+ + } +} +``` + +Add `mod input;` to `src/tui/mod.rs`. + +- [ ] **Step 3: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Expected: d/u/f/b/PageUp/PageDown/Space/G work. Single g doesn't yet do anything useful — Task 4.1 adds the two-key gg sequence. + +- [ ] **Step 4: `make check`** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add src/tui/input.rs src/tui/mod.rs +git commit -m "feat(tui): action-based input mapping" +``` + +--- + +## Phase 3 — Kitty Image Handling + +### Task 3.1: Extend render.rs with transmit/place/delete primitives + +**Files:** +- Modify: `src/render.rs` + +- [ ] **Step 1: Failing test** + +Append to `src/render.rs` after existing code: + +```rust +#[cfg(test)] +mod kitty_tests { + use super::*; + + #[test] + fn transmit_produces_a_eq_t_with_id() { + let mut buf = Vec::new(); + transmit(&mut buf, 42, b"\x89PNG\r\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.starts_with("\x1b_Gf=100,a=T,i=42,q=2")); + assert!(s.ends_with("\x1b\\")); + } + + #[test] + fn place_produces_a_eq_p() { + let mut buf = Vec::new(); + place(&mut buf, 7, 3, 5).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert_eq!(s, "\x1b_Ga=p,i=7,x=3,y=5,q=2;\x1b\\"); + } + + #[test] + fn delete_placement_sends_d_i() { + let mut buf = Vec::new(); + delete_placement(&mut buf, 9).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert_eq!(s, "\x1b_Ga=d,d=i,i=9,q=2;\x1b\\"); + } + + #[test] + fn delete_all_this_client_sends_d_cap_a() { + let mut buf = Vec::new(); + delete_all_for_client(&mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert_eq!(s, "\x1b_Ga=d,d=A,q=2;\x1b\\"); + } +} +``` + +- [ ] **Step 2: Implement the primitives** + +Append to `src/render.rs`: + +```rust +use std::io::Write; + +/// Kitty graphics protocol: transmit PNG data and assign it `id`. +/// Data is not displayed yet; use `place` afterwards. +pub fn transmit(w: &mut W, id: u32, png: &[u8]) -> std::io::Result<()> { + use base64::engine::general_purpose::STANDARD; + let b64 = STANDARD.encode(png); + let total = b64.len(); + let mut offset = 0; + let mut first = true; + while offset < total { + let end = (offset + 4096).min(total); + let chunk = &b64[offset..end]; + let m = if end == total { "0" } else { "1" }; + if first { + write!(w, "\x1b_Gf=100,a=T,i={id},q=2,m={m};{chunk}\x1b\\")?; + first = false; + } else { + write!(w, "\x1b_Gm={m};{chunk}\x1b\\")?; + } + offset = end; + } + Ok(()) +} + +/// Place previously-transmitted image `id` at cell (col, row). +pub fn place(w: &mut W, id: u32, col: u16, row: u16) -> std::io::Result<()> { + write!(w, "\x1b_Ga=p,i={id},x={col},y={row},q=2;\x1b\\") +} + +/// Delete a single placement of `id` (keeps image data in the terminal cache). +pub fn delete_placement(w: &mut W, id: u32) -> std::io::Result<()> { + write!(w, "\x1b_Ga=d,d=i,i={id},q=2;\x1b\\") +} + +/// Delete all placements AND image data this client has created (exit cleanup). +pub fn delete_all_for_client(w: &mut W) -> std::io::Result<()> { + write!(w, "\x1b_Ga=d,d=A,q=2;\x1b\\") +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib render::kitty_tests` +Expected: all four PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/render.rs +git commit -m "feat(render): add transmit/place/delete Kitty primitives" +``` + +### Task 3.2: tui/kitty.rs — id-based image lifecycle + +**Files:** +- Create: `src/tui/kitty.rs` +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Write the diff test** + +Create `src/tui/kitty.rs`: + +```rust +use std::collections::HashMap; +use std::io::{self, Write}; + +use crate::render; + +/// Tracks which image ids are currently placed at which (col, row) on the terminal. +/// `sync` diffs a desired set against the current state and emits the minimum +/// place/delete commands to reconcile. +#[derive(Default)] +pub struct ImageLifecycle { + placed: HashMap, + transmitted: HashMap, +} + +impl ImageLifecycle { + pub fn register( + &mut self, + w: &mut W, + id: u32, + png: &[u8], + ) -> io::Result<()> { + if self.transmitted.contains_key(&id) { return Ok(()); } + render::transmit(w, id, png)?; + self.transmitted.insert(id, true); + Ok(()) + } + + pub fn sync( + &mut self, + w: &mut W, + desired: &HashMap, + ) -> io::Result<()> { + // Delete placements no longer desired. + let to_delete: Vec = self.placed.keys() + .filter(|id| !desired.contains_key(id)) + .copied() + .collect(); + for id in &to_delete { + render::delete_placement(w, *id)?; + self.placed.remove(id); + } + // Place or re-place. + for (&id, &(col, row)) in desired { + match self.placed.get(&id) { + Some(&pos) if pos == (col, row) => {} // unchanged + Some(_) => { + render::delete_placement(w, id)?; + render::place(w, id, col, row)?; + self.placed.insert(id, (col, row)); + } + None => { + render::place(w, id, col, row)?; + self.placed.insert(id, (col, row)); + } + } + } + Ok(()) + } + + pub fn cleanup(&mut self, w: &mut W) -> io::Result<()> { + render::delete_all_for_client(w)?; + self.placed.clear(); + self.transmitted.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enter_new_then_move_then_leave() { + let mut lc = ImageLifecycle::default(); + let mut buf = Vec::new(); + + // Register transmits once. + lc.register(&mut buf, 1, b"png").unwrap(); + let first_len = buf.len(); + lc.register(&mut buf, 1, b"png").unwrap(); + assert_eq!(buf.len(), first_len, "second register should be a no-op"); + + // Place at (5, 10). + let mut desired = HashMap::new(); + desired.insert(1u32, (5u16, 10u16)); + buf.clear(); + lc.sync(&mut buf, &desired).unwrap(); + let s = String::from_utf8(buf.clone()).unwrap(); + assert!(s.contains("a=p,i=1,x=5,y=10")); + + // Move to (5, 8) — should delete then place. + desired.insert(1, (5, 8)); + buf.clear(); + lc.sync(&mut buf, &desired).unwrap(); + let s = String::from_utf8(buf.clone()).unwrap(); + assert!(s.contains("a=d,d=i,i=1")); + assert!(s.contains("a=p,i=1,x=5,y=8")); + + // Leave. + desired.remove(&1); + buf.clear(); + lc.sync(&mut buf, &desired).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("a=d,d=i,i=1")); + } +} +``` + +- [ ] **Step 2: Register in tui/mod.rs** + +Add `mod kitty;` to `src/tui/mod.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib tui::kitty::tests` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/kitty.rs src/tui/mod.rs +git commit -m "feat(tui): image lifecycle with id-based place/delete diff" +``` + +### Task 3.3: Compute desired placement and wire into draw loop + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Compute placement per frame** + +Add below `draw` in `src/tui/mod.rs`: + +```rust +fn desired_image_placements(app: &App) -> std::collections::HashMap { + use std::collections::HashMap; + let mut out = HashMap::new(); + // `MARGIN_WIDTH` is the cell offset we use for body text in cat; reuse for images. + let col = crate::style::MARGIN_WIDTH as u16; + for (visible_row, vl) in app.viewport.visible().iter().enumerate() { + let logical = &app.doc.lines[vl.logical_index]; + for span in &logical.spans { + if let layout::Span::HeadingImage { id, .. } = span { + out.insert(*id, (col, visible_row as u16)); + } + } + } + out +} +``` + +Store a `kitty::ImageLifecycle` on the `App`: + +```rust +struct App { + doc: layout::RenderedDoc, + viewport: Viewport, + images: kitty::ImageLifecycle, +} +``` + +In `App::new`, instantiate `images: kitty::ImageLifecycle::default()`. + +In `run_ui`, after `App::new`: + +```rust +let mut writer = io::stdout(); +for img in &app.doc.images { + app.images.register(&mut writer, img.id, &img.png)?; +} +``` + +In `event_loop` (after the `terminal.draw`), add: + +```rust +let mut writer = std::io::stdout(); +let desired = desired_image_placements(app); +app.images.sync(&mut writer, &desired).ok(); +let _ = writer.flush(); +``` + +Before returning in `run_ui`, call: + +```rust +let mut writer = std::io::stdout(); +let _ = app.images.cleanup(&mut writer); +``` + +- [ ] **Step 2: Reserve empty rows in the Paragraph for heading images** + +Update the `draw` function: when rendering a visible line that contains a `HeadingImage`, emit blank `RLine`s for the span's `rows` count instead of `[image]` text: + +```rust +for span in &logical.spans { + match span { + layout::Span::Text { content, .. } | layout::Span::Link { content, .. } => { + rspans.push(RSpan::raw(content.clone())); + } + layout::Span::HeadingImage { rows, .. } => { + for _ in 0..*rows { + rspans.push(RSpan::raw("")); + } + } + } +} +``` + +(Note: this produces too many RLines per logical; adjust rendered so each heading image adds `rows - 1` extra empty RLine entries after the current one. For simplicity at this stage, render one blank row and rely on `layout::HeadingImage.rows` being approximate; Phase 4 replaces with a precise ReserveRows widget.) + +- [ ] **Step 3: Manual smoke test in Ghostty** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Expected: headings show as images; body text wraps around them; scrolling moves images and text together; `q` exits without residue. + +- [ ] **Step 4: `make check`** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): register + place heading images per frame" +``` + +--- + +## Phase 4 — Navigation Polish + +### Task 4.1: Two-key `gg` sequence + +**Files:** +- Modify: `src/tui/mod.rs`, `src/tui/input.rs` + +- [ ] **Step 1: Track a "pending key" on the App** + +In `src/tui/mod.rs`: + +```rust +struct App { + doc: layout::RenderedDoc, + viewport: Viewport, + images: kitty::ImageLifecycle, + pending_g: bool, +} +``` + +Update `App::new` to initialize `pending_g: false`. + +In the event loop, before calling `map_normal`, intercept `g`: + +```rust +if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if matches!(key.code, KeyCode::Char('g')) { + if app.pending_g { + app.viewport.top = 0; + app.pending_g = false; + continue; + } else { + app.pending_g = true; + continue; + } + } else { + app.pending_g = false; + } + } + // existing action dispatch... +} +``` + +- [ ] **Step 2: Remove the single-g action from input.rs** + +Delete the `KeyCode::Char('g') => Action::JumpStart,` line in `map_normal`. + +- [ ] **Step 3: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Expected: pressing `g` once does nothing; `gg` jumps to top. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/mod.rs src/tui/input.rs +git commit -m "feat(tui): gg jumps to document start" +``` + +### Task 4.2: Heading nav (`]]` / `[[`) + +**Files:** +- Modify: `src/tui/mod.rs`, `src/tui/viewport.rs` + +- [ ] **Step 1: Failing test on viewport's heading-jump API** + +Append to `src/tui/viewport.rs` tests: + +```rust +#[test] +fn heading_jump_moves_to_heading_line() { + use crate::layout::{HeadingEntry, Line, LineKind, Span, Style}; + let lines: Vec = (0..10).map(|i| Line { + spans: vec![Span::Text { content: format!("row {i}"), style: Style::default() }], + kind: LineKind::Body, + }).collect(); + let headings = vec![ + HeadingEntry { level: 1, text: "A".into(), line_index: 3 }, + HeadingEntry { level: 1, text: "B".into(), line_index: 7 }, + ]; + let doc = RenderedDoc { lines, headings, images: vec![] }; + let mut vp = Viewport::new(3, 40); + vp.ensure_wrap(&doc); + + vp.jump_to_next_heading(&doc, 0); + assert_eq!(vp.top, 3); + vp.jump_to_next_heading(&doc, vp.top + 1); + assert_eq!(vp.top, 7); + vp.jump_to_prev_heading(&doc, 7); + assert_eq!(vp.top, 3); +} +``` + +- [ ] **Step 2: Implement** + +Add methods to `Viewport`: + +```rust +pub fn jump_to_next_heading(&mut self, doc: &RenderedDoc, after_visual: usize) { + // Find the logical line of the first visual line > after_visual + let start_logical = self.visual_lines.get(after_visual) + .map(|vl| vl.logical_index) + .unwrap_or(0); + let next = doc.headings.iter().find(|h| h.line_index > start_logical); + if let Some(h) = next { + if let Some(vi) = self.visual_lines.iter().position(|vl| vl.logical_index == h.line_index) { + self.top = vi; + } + } +} + +pub fn jump_to_prev_heading(&mut self, doc: &RenderedDoc, before_visual: usize) { + let start_logical = self.visual_lines.get(before_visual) + .map(|vl| vl.logical_index) + .unwrap_or(0); + let prev = doc.headings.iter().rev().find(|h| h.line_index < start_logical); + if let Some(h) = prev { + if let Some(vi) = self.visual_lines.iter().position(|vl| vl.logical_index == h.line_index) { + self.top = vi; + } + } +} +``` + +- [ ] **Step 3: Map the action in input.rs** + +Add: + +```rust +NextHeading, +PrevHeading, +``` + +(already present — just ensure the code path handles the `]`/`[` keys to mean `]]`/`[[` with a two-key sequence similar to `gg`. For v1 keep single-bracket activation; document two-bracket later if desired.) + +- [ ] **Step 4: Handle in event loop** + +```rust +input::Action::NextHeading => app.viewport.jump_to_next_heading(&app.doc, app.viewport.top), +input::Action::PrevHeading => app.viewport.jump_to_prev_heading(&app.doc, app.viewport.top), +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --lib tui::viewport::tests` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```sh +git add src/tui/viewport.rs src/tui/mod.rs src/tui/input.rs +git commit -m "feat(tui): heading navigation with ]/[" +``` + +### Task 4.3: Status bar widget + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Split layout into body + status** + +In `draw`: + +```rust +use ratatui::layout::{Constraint, Direction, Layout}; + +let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + +// body in chunks[0], status in chunks[1] +frame.render_widget(para, chunks[0]); +let progress = progress_fraction(&app); +let pct = (progress * 100.0).round() as u32; +let status = Paragraph::new(format!(" {} {pct}%", app.current_path_display())) + .style(ratatui::style::Style::default() + .bg(ratatui::style::Color::DarkGray) + .fg(ratatui::style::Color::White)); +frame.render_widget(status, chunks[1]); +``` + +Add on `App`: + +```rust +fn current_path_display(&self) -> String { self.path.clone() } +``` + +And store `path: String` on App (passed from `run`). + +Helper outside: + +```rust +fn progress_fraction(app: &App) -> f64 { + let total = app.viewport.total_visual_lines() as f64; + if total == 0.0 { return 1.0; } + let pos = (app.viewport.top as f64 + app.viewport.height as f64).min(total); + pos / total +} +``` + +- [ ] **Step 2: Wire path through** + +Extend `App::new` to take `path: String` and store it. + +- [ ] **Step 3: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Expected: bottom line shows path + percentage; it updates as you scroll. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): bottom status bar with path and progress" +``` + +### Task 4.4: Width-aware wrap (replace no-op wrap) + +**Files:** +- Modify: `src/tui/viewport.rs` + +- [ ] **Step 1: Failing test** + +```rust +#[test] +fn wrap_splits_long_body_line() { + use crate::layout::{Line, LineKind, Span, Style}; + let doc = RenderedDoc { + lines: vec![Line { + spans: vec![Span::Text { + content: "alpha beta gamma delta epsilon zeta eta theta".into(), + style: Style::default(), + }], + kind: LineKind::Body, + }], + headings: vec![], images: vec![], + }; + let mut vp = Viewport::new(10, 20); + vp.ensure_wrap(&doc); + assert!(vp.total_visual_lines() > 1, "expected multiple visual lines"); +} +``` + +- [ ] **Step 2: Replace `wrap_all`** + +Use `unicode_width::UnicodeWidthStr::width` to accumulate display width and break on word boundaries. URLs inside `Span::Link` are treated as single tokens (never broken). + +```rust +fn wrap_all(lines: &[Line], width: u16) -> Vec { + use unicode_width::UnicodeWidthStr; + let max = width.saturating_sub(4) as usize; // reserve margin + let mut out = Vec::new(); + for (li, line) in lines.iter().enumerate() { + // Tables / rules / blank lines are emitted as-is (one visual line). + match line.kind { + crate::layout::LineKind::Blank + | crate::layout::LineKind::HorizontalRule + | crate::layout::LineKind::Table => { + out.push(VisualLine { logical_index: li, byte_start: 0, byte_end: line_byte_len(line) }); + continue; + } + _ => {} + } + + // Flatten span text into a single string; wrap by display width. + let text: String = line.spans.iter().filter_map(|s| match s { + crate::layout::Span::Text { content, .. } | crate::layout::Span::Link { content, .. } => Some(content.as_str()), + crate::layout::Span::HeadingImage { .. } => None, + }).collect::>().join(""); + + if max == 0 || UnicodeWidthStr::width(text.as_str()) <= max { + out.push(VisualLine { logical_index: li, byte_start: 0, byte_end: text.len() }); + continue; + } + + let mut byte_start = 0usize; + let mut cur_width = 0usize; + let mut cur_byte = 0usize; + for (i, ch) in text.char_indices() { + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if cur_width + cw > max && cur_byte > byte_start { + out.push(VisualLine { logical_index: li, byte_start, byte_end: cur_byte }); + byte_start = cur_byte; + cur_width = 0; + } + cur_byte = i + ch.len_utf8(); + cur_width += cw; + } + out.push(VisualLine { logical_index: li, byte_start, byte_end: text.len() }); + } + out +} +``` + +Note: span-rendered draw now needs byte-range slicing. Update `draw` in `src/tui/mod.rs` to render spans clipped to `vl.byte_start..vl.byte_end`. Write a helper `fn line_byte_slice(line: &Line, start: usize, end: usize) -> Vec` that walks spans, accumulates byte offsets, and emits only the clipped portion of each span. + +- [ ] **Step 3: Run tests** + +Run: `cargo test --lib tui::viewport::tests` +Expected: PASS (including existing ones). + +- [ ] **Step 4: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax-zh.md` +Expected: long paragraphs wrap at terminal width; CJK width correct. + +- [ ] **Step 5: Commit** + +```sh +git add src/tui/viewport.rs src/tui/mod.rs +git commit -m "feat(tui): width-aware wrap with display-width accounting" +``` + +--- + +## Phase 5 — Search + +### Task 5.1: SearchState with substring match + +**Files:** +- Create: `src/tui/search.rs` +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Failing test** + +Create `src/tui/search.rs`: + +```rust +use crate::layout::{Line, RenderedDoc, Span}; + +#[derive(Debug, Clone)] +pub struct MatchPos { + pub line_index: usize, + pub byte_range: std::ops::Range, +} + +pub struct SearchState { + pub query: String, + pub matches: Vec, + pub current: Option, +} + +impl SearchState { + pub fn new(query: String, doc: &RenderedDoc) -> Self { + let matches = find_all(&query, doc); + Self { query, matches, current: None } + } +} + +pub fn find_all(query: &str, doc: &RenderedDoc) -> Vec { + if query.is_empty() { return Vec::new(); } + let smart_case = !query.chars().any(|c| c.is_uppercase()); + let mut out = Vec::new(); + for (i, line) in doc.lines.iter().enumerate() { + let haystack = line_text(line); + find_in_line(&haystack, query, smart_case, i, &mut out); + } + out +} + +fn line_text(line: &Line) -> String { + let mut s = String::new(); + for sp in &line.spans { + match sp { + Span::Text { content, .. } | Span::Link { content, .. } => s.push_str(content), + Span::HeadingImage { .. } => {} + } + } + s +} + +fn find_in_line(haystack: &str, needle: &str, case_insensitive: bool, line: usize, out: &mut Vec) { + if case_insensitive { + let lower = haystack.to_lowercase(); + let nlow = needle.to_lowercase(); + let mut start = 0usize; + while let Some(off) = lower[start..].find(&nlow) { + let abs = start + off; + out.push(MatchPos { line_index: line, byte_range: abs..abs + needle.len() }); + start = abs + needle.len().max(1); + } + } else { + let mut start = 0usize; + while let Some(off) = haystack[start..].find(needle) { + let abs = start + off; + out.push(MatchPos { line_index: line, byte_range: abs..abs + needle.len() }); + start = abs + needle.len().max(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::{Line, LineKind, RenderedDoc, Span, Style}; + + fn doc_with(lines: &[&str]) -> RenderedDoc { + RenderedDoc { + lines: lines.iter().map(|t| Line { + spans: vec![Span::Text { content: (*t).into(), style: Style::default() }], + kind: LineKind::Body, + }).collect(), + headings: vec![], images: vec![], + } + } + + #[test] + fn smart_case_lowercase_query_matches_insensitive() { + let doc = doc_with(&["Hello World", "hello there"]); + let m = find_all("hello", &doc); + assert_eq!(m.len(), 2); + } + + #[test] + fn mixed_case_query_is_sensitive() { + let doc = doc_with(&["Hello World", "hello there"]); + let m = find_all("Hello", &doc); + assert_eq!(m.len(), 1); + assert_eq!(m[0].line_index, 0); + } + + #[test] + fn empty_query_returns_no_matches() { + let doc = doc_with(&["anything"]); + assert!(find_all("", &doc).is_empty()); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --lib tui::search::tests` +Expected: PASS. + +- [ ] **Step 3: Register the module** + +Add `mod search;` to `src/tui/mod.rs`. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/search.rs src/tui/mod.rs +git commit -m "feat(tui): literal smart-case search state" +``` + +### Task 5.2: Search prompt input mode + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Add a Mode enum** + +In `src/tui/mod.rs`: + +```rust +enum Mode { + Normal, + Search { input: tui_textarea::TextArea<'static>, reverse: bool }, +} + +struct App { + doc: layout::RenderedDoc, + viewport: Viewport, + images: kitty::ImageLifecycle, + pending_g: bool, + path: String, + mode: Mode, + search: Option, +} +``` + +Initialize `mode: Mode::Normal, search: None`. + +- [ ] **Step 2: Route keys by mode** + +Replace the single-mode key dispatch with: + +```rust +match &mut app.mode { + Mode::Normal => { /* existing normal handling */ } + Mode::Search { input, reverse } => { + let reverse = *reverse; + let key_event = if let Event::Key(k) = event::read()? { k } else { continue; }; + match key_event.code { + KeyCode::Esc => app.mode = Mode::Normal, + KeyCode::Enter => { + let query = input.lines().join(""); + let state = search::SearchState::new(query, &app.doc); + app.search = Some(state); + app.mode = Mode::Normal; + apply_search_jump(app, reverse); + } + _ => { input.input(key_event); } + } + } +} +``` + +For the `SearchBegin` action in Normal mode: + +```rust +input::Action::SearchBegin { reverse } => { + let mut ta = tui_textarea::TextArea::default(); + ta.set_cursor_line_style(ratatui::style::Style::default()); + app.mode = Mode::Search { input: ta, reverse }; +} +``` + +- [ ] **Step 3: Render the prompt in Search mode** + +In `draw`, when `app.mode` is `Search`, overlay a single-line prompt at the bottom (replacing the status bar for that frame): + +```rust +if let Mode::Search { input, reverse } = &app.mode { + let prompt_text = format!("{}{}", if *reverse { "?" } else { "/" }, input.lines().join("")); + let prompt = Paragraph::new(prompt_text); + frame.render_widget(prompt, chunks[1]); +} +``` + +- [ ] **Step 4: Implement `apply_search_jump`** + +```rust +fn apply_search_jump(app: &mut App, reverse: bool) { + let Some(state) = app.search.as_mut() else { return; }; + if state.matches.is_empty() { return; } + + let current_logical = app.viewport.visible() + .first() + .map(|vl| vl.logical_index) + .unwrap_or(0); + + let idx = if !reverse { + state.matches.iter().position(|m| m.line_index >= current_logical).unwrap_or(0) + } else { + state.matches.iter().rposition(|m| m.line_index <= current_logical).unwrap_or(state.matches.len() - 1) + }; + state.current = Some(idx); + center_on_logical(&mut app.viewport, state.matches[idx].line_index); +} + +fn center_on_logical(vp: &mut Viewport, logical: usize) { + if let Some(vi) = vp.visual_lines_iter().position(|vl| vl.logical_index == logical) { + let third = (vp.height as usize) / 3; + vp.top = vi.saturating_sub(third); + let max = vp.total_visual_lines().saturating_sub(vp.height as usize); + vp.top = vp.top.min(max); + } +} +``` + +Add a `pub fn visual_lines_iter(&self) -> std::slice::Iter<'_, VisualLine>` helper on `Viewport`. + +- [ ] **Step 5: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md` +Press `/`, type a word, Enter → viewport jumps to first match. Esc cancels. `?` starts reverse. + +- [ ] **Step 6: `make check`** + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```sh +git add src/tui/mod.rs src/tui/search.rs src/tui/viewport.rs +git commit -m "feat(tui): search prompt and initial jump" +``` + +### Task 5.3: n / N navigation + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Wire the actions** + +Add to the `Action` handlers in the Normal-mode branch of the event loop: + +```rust +input::Action::SearchNext => advance_search(app, 1), +input::Action::SearchPrev => advance_search(app, -1), +``` + +Implement: + +```rust +fn advance_search(app: &mut App, delta: i32) { + let Some(state) = app.search.as_mut() else { return; }; + if state.matches.is_empty() { return; } + let len = state.matches.len() as i32; + let cur = state.current.unwrap_or(0) as i32; + let next = ((cur + delta) % len + len) % len; + state.current = Some(next as usize); + let line = state.matches[next as usize].line_index; + center_on_logical(&mut app.viewport, line); +} +``` + +- [ ] **Step 2: Manual smoke test** + +Run and verify `n`/`N` cycle through matches with wrap-around. + +- [ ] **Step 3: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): n/N navigate between search matches" +``` + +### Task 5.4: Highlight matches on screen + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Locate matches visible in the current frame** + +In `draw`, before building each `RLine`, compute matches intersecting the visible byte range of that visual line: + +```rust +let visible_matches: Vec<&search::MatchPos> = app.search.as_ref() + .map(|s| s.matches.iter() + .filter(|m| m.line_index == vl.logical_index) + .filter(|m| m.byte_range.start < vl.byte_end && m.byte_range.end > vl.byte_start) + .collect()) + .unwrap_or_default(); +``` + +- [ ] **Step 2: Clip-slice spans with highlight background** + +Write a helper `highlighted_line_spans(line, vl, &visible_matches, current_match_index) -> Vec` that walks the line's spans, respecting `vl.byte_start..vl.byte_end`, and whenever a byte range overlaps a match, emits a dedicated `RSpan` with: + +- Current-match highlight: `Color::Yellow` bg, `Color::Black` fg. +- Non-current match highlight: `Color::Rgb(80, 80, 0)` bg (dim yellow). + +Use the match's `byte_range` start/end to split the text into before/match/after chunks. + +- [ ] **Step 3: Manual smoke test** + +Run: `cargo run -- --tui fixtures/full-syntax.md`, `/word` → all `word` occurrences highlighted; `n`/`N` moves the brighter one. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): inverse-highlight search matches" +``` + +--- + +## Phase 6 — ToC Panel + +### Task 6.1: Layout split + toggleable ToC list + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Add `toc_open: bool` to App** + +- [ ] **Step 2: Handle ToggleToc action** + +```rust +input::Action::ToggleToc => app.toc_open = !app.toc_open, +``` + +- [ ] **Step 3: Split the layout when open** + +In `draw`: + +```rust +let body_area = if app.toc_open { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(30), Constraint::Min(20)]) + .split(chunks[0]); + let toc_items: Vec = app.doc.headings.iter() + .map(|h| { + let indent = " ".repeat((h.level as usize).saturating_sub(1) * 2); + ratatui::widgets::ListItem::new(format!("{indent}{}", h.text)) + }) + .collect(); + let toc = ratatui::widgets::List::new(toc_items) + .block(ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::RIGHT) + .title("Contents")); + frame.render_widget(toc, split[0]); + split[1] +} else { + chunks[0] +}; + +// Render the paragraph into body_area instead of chunks[0]. +``` + +- [ ] **Step 4: Manual smoke test** + +Press `t` toggles ToC on/off; widths rebalance correctly. + +- [ ] **Step 5: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): toggleable ToC side panel" +``` + +--- + +## Phase 7 — Multi-File Back/Forward + Links + +### Task 7.1: DocEntry list with history/forward stacks + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Refactor state to per-doc** + +```rust +struct DocEntry { + path: String, + doc: layout::RenderedDoc, + viewport: Viewport, + search: Option, + placed_images_registered: bool, + pending_g: bool, + toc_open: bool, +} + +struct App { + docs: Vec, + cursor: usize, + history: Vec, + forward: Vec, + mode: Mode, + images: kitty::ImageLifecycle, + next_image_id: u32, +} + +impl App { + fn active(&self) -> &DocEntry { &self.docs[self.cursor] } + fn active_mut(&mut self) -> &mut DocEntry { &mut self.docs[self.cursor] } +} +``` + +Replace every `app.viewport` with `app.active_mut().viewport` throughout the event loop and draw. + +- [ ] **Step 2: Allocate image ids globally** + +The `layout::build` call should accept an id counter so ids stay unique across docs. Change its signature to take `&mut u32` or return the last id + allocate from `App`. Simpler: expose a thin re-number pass in App when a new doc is added: + +```rust +fn add_doc(&mut self, path: String, doc: layout::RenderedDoc) -> usize { + let mut doc = doc; + for img in &mut doc.images { + img.id = self.next_image_id; + self.next_image_id += 1; + } + // Also patch Span::HeadingImage id references so they still match. + renumber_image_refs(&mut doc); + let size = /* current term size */; + let vp = Viewport::new(size.1, size.0); + self.docs.push(DocEntry { path, doc, viewport: vp, search: None, + placed_images_registered: false, pending_g: false, toc_open: false }); + self.docs.len() - 1 +} +``` + +Renumber the `Span::HeadingImage { id, .. }` entries in lockstep with `doc.images`. Because image ids are assigned in sequence 1..N during `layout::build` and then shifted by a constant offset, this is a simple offset pass: remember the old starting id, compute the shift, apply to every `Span::HeadingImage` and `LineKind::Heading { id, .. }`. + +- [ ] **Step 3: Implement back / forward handlers** + +```rust +input::Action::Back => { + if let Some(prev) = app.history.pop() { + app.forward.push(app.cursor); + app.cursor = prev; + } +} +input::Action::Forward => { + if let Some(next) = app.forward.pop() { + app.history.push(app.cursor); + app.cursor = next; + } +} +``` + +When navigating to a new doc (Task 7.2 below), push `app.cursor` onto `history` and clear `forward`. + +- [ ] **Step 4: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): multi-doc history with back/forward" +``` + +### Task 7.2: Link opening (Enter + LinkSelect overlay) + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Collect visible links** + +Add `fn visible_links(app: &App) -> Vec<(String, String)>` returning `(content, url)` tuples in document order for the current viewport. + +- [ ] **Step 2: Handle OpenLink action** + +```rust +input::Action::OpenLink => { + let links = visible_links(app); + match links.len() { + 0 => {} + 1 => open_link(&links[0].1, app), + _ => app.mode = Mode::LinkSelect { links }, + } +} +``` + +Add `LinkSelect { links: Vec<(String, String)> }` to the `Mode` enum. Mode handler: + +```rust +Mode::LinkSelect { links } => { + if let Event::Key(k) = event::read()? { + if k.kind != KeyEventKind::Press { continue; } + match k.code { + KeyCode::Esc => app.mode = Mode::Normal, + KeyCode::Char(c) if c.is_ascii_digit() => { + let idx = (c as u8 - b'0') as usize; + if idx > 0 && idx <= links.len() { + let (_, url) = links[idx - 1].clone(); + app.mode = Mode::Normal; + open_link(&url, app); + } + } + _ => {} + } + } +} +``` + +- [ ] **Step 3: Draw numbered overlay** + +In `draw`, when `Mode::LinkSelect`, overlay bracketed digits beside each visible link. Implementation sketch: when building visible line spans, track link span order; for each visible link at index `i`, prepend `[i+1]` as an extra RSpan with a distinct style. + +- [ ] **Step 4: Open URL** + +```rust +fn open_link(url: &str, app: &App) { + if url.ends_with(".md") && std::path::Path::new(url).exists() { + // Handled in Task 7.3 (local-file navigation) + return; + } + let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + let _ = std::process::Command::new(cmd).arg(url).spawn(); + let _ = app; // reserved for future telemetry +} +``` + +- [ ] **Step 5: Manual smoke test** + +Open a doc with multiple external links; Enter in single-link case opens it; multi-link shows digits; pressing 2 opens the second. + +- [ ] **Step 6: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): open links via Enter with numeric overlay for ambiguity" +``` + +### Task 7.3: Local .md link navigation + +**Files:** +- Modify: `src/tui/mod.rs` + +- [ ] **Step 1: Extend `open_link` to treat `*.md` paths specially** + +```rust +fn open_link(url: &str, app: &mut App) { + let as_path = std::path::Path::new(url); + if url.ends_with(".md") && as_path.exists() { + match std::fs::read_to_string(as_path) { + Ok(src) => { + let active_theme = /* store theme on App; thread through */; + let config = /* ditto */; + let doc = layout::build(&src, &config, active_theme); + app.history.push(app.cursor); + app.forward.clear(); + let new_cursor = app.add_doc(url.to_string(), doc); + app.cursor = new_cursor; + } + Err(_) => {} + } + return; + } + let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + let _ = std::process::Command::new(cmd).arg(url).spawn(); +} +``` + +Store `theme: Theme` and `config: Config` fields on `App` so `open_link` can rebuild a doc for a followed link. + +- [ ] **Step 2: Manual smoke test** + +Create two linked fixtures (or use existing docs in the repo): `README.md` → `docs/TUI_MODE_DESIGN.md`. Follow the link, press `o` to go back, `i` to go forward. Verify each doc's scroll position and search state are preserved. + +- [ ] **Step 3: Commit** + +```sh +git add src/tui/mod.rs +git commit -m "feat(tui): follow local .md links into the doc stack" +``` + +--- + +## Phase 8 — Final QA and Docs + +### Task 8.1: Manual pre-merge checklist + +**Files:** +- None modified. This is manual verification run in Ghostty and iTerm2. + +- [ ] Run the checklist from `docs/TUI_MODE_DESIGN.md` § "Manual Pre-merge Checklist": + - Short / mid / long docs. + - Heading-dense docs. + - Mixed script, emoji, wide tables, long code blocks. + - Held-`j` for 10 s — no flicker, lag, or image residue. + - Search hit / miss / wrap, re-center at 1/3. + - Multi-file back/forward, state preserved. + - Resize mid-session. + - Link open: 0/1/>1 visible. + - `q` exit: no image residue. + +- [ ] Record any bug findings as follow-up issues; fix in-plan only if blocker. + +### Task 8.2: Update README and help text + +**Files:** +- Modify: `README.md`, `README_CN.md`, `src/main.rs` (help text) + +- [ ] **Step 1: Add a "TUI mode" section** + +Append to `README.md` under Usage: + +```markdown +### TUI mode + +For long files, use `--tui` for a vim-style interactive browser: + + termdown --tui README.md + +Key bindings: `j/k` scroll, `d/u` half page, `f/b` page, `gg`/`G` start/end, +`]`/`[` heading nav, `t` table of contents, `/` search, `n/N` next/prev match, +`Enter` follow link, `o/i` back/forward across docs, `q` quit. +``` + +- [ ] **Step 2: Mirror in README_CN.md** + +- [ ] **Step 3: Update --help** + +In `src/main.rs` help block, ensure `--tui` is listed with one-line description. + +- [ ] **Step 4: Commit** + +```sh +git add README.md README_CN.md src/main.rs +git commit -m "docs: document --tui mode and key bindings" +``` + +### Task 8.3: Bump version + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Bump** + +Edit `Cargo.toml`: + +```toml +version = "0.4.0" +``` + +- [ ] **Step 2: Commit** + +```sh +git add Cargo.toml Cargo.lock +git commit -m "chore: bump version to 0.4.0" +``` + +--- + +## Plan Self-Review Notes + +- **Spec coverage:** Every section of `docs/TUI_MODE_DESIGN.md` is reflected: activation (Task 2.2), module layout (Phases 1-3 cover `layout.rs`, `cat.rs`, `tui/{mod,viewport,input,search,kitty}.rs`, `render.rs` extensions), data model (Task 1.1), cat rewrite (Tasks 1.2-1.10), runtime state (Tasks 2.3-2.6, 7.1), event loop and layered rendering (Tasks 2.5, 3.3), key bindings (Tasks 2.6, 4.1-4.2), link opening (Tasks 7.2-7.3), search (Phase 5), Kitty image lifecycle (Phase 3), testing strategy (Phase 0 + per-task TDD), open questions (deferred, consistent with spec). +- **Placeholders:** No TBD/TODO in code. Where a task body mentions "refined in Phase N", the referenced task implements it. +- **Type consistency:** `Style` fields (`fg/bg/bold/italic/underline/strikethrough/dim`) introduced in Task 1.1 are used consistently through `cat.rs` (Task 1.8) and `viewport.rs`/`search.rs`. `HeadingImage` moved to `render.rs` in Task 1.1 and re-referenced in `tui/kitty.rs` (Task 3.2) via path `crate::render::HeadingImage`. Kitty protocol APIs in Task 3.1 (`transmit/place/delete_placement/delete_all_for_client`) are the ones called by `ImageLifecycle` in Task 3.2. +- **Known manual gates:** Task 1.9 requires human audit of snapshot diffs; Task 8.1 is the Ghostty/iTerm2 QA pass. Both are called out explicitly. diff --git a/docs/TUI_MODE_RESEARCH.md b/docs/TUI_MODE_RESEARCH.md new file mode 100644 index 0000000..f854210 --- /dev/null +++ b/docs/TUI_MODE_RESEARCH.md @@ -0,0 +1,148 @@ +# TUI Mode — Research & Approach Comparison + +Background notes that led to the approach chosen for termdown's `--tui` mode. +The final design lives in `docs/superpowers/specs/` once it is written. + +## Problem Statement + +termdown today is a `cat`-like Markdown renderer: it prints once and exits. +Long documents exceed the terminal height and become hard to navigate. +We want a second mode — activated via `--tui` — that provides vim-style +paging, forward/back, and search so users can browse long documents. + +## Pagers, Editors, TUIs — What's the Difference? + +The term "TUI" loosely means "runs in alternate screen + raw mode + keyboard-driven". +Within that umbrella, the complexity varies widely. + +| Tool | Category | Core model | Complexity | +|---|---|---|---| +| `less` | Minimal pager | `Vec` + viewport offset; hand-rolled input loop | Low | +| `mdfried` | Ratatui-based TUI | Widget tree + immediate-mode redraw each frame | Medium | +| `vim` | Modal editor | Full cursor control, modal state, windows, plugins | High | + +termdown needs the middle ground: more than `less` (wants structured layout, +status bar, ToC, search highlight), less than `vim` (not editing). + +## Rust TUI Library Landscape (early 2026) + +| Library | Role | Activity | Used by | +|---|---|---|---| +| **ratatui** | De-facto standard immediate-mode framework | 🟢 Very active | gitui, bottom, atuin, yazi, helix's early UI, mdfried | +| **cursive** | Retained-mode widget tree | 🟡 Maintained, slow | Menu-driven apps (less suitable for free-scroll text) | +| **termwiz** | Low-level primitives + thin widget layer | 🟢 Active | wezterm itself | +| **crossterm** | Cross-platform terminal primitives (not a TUI lib) | 🟢 Active | ratatui and hand-rolled pagers | +| **termion** | Unix-only primitives, predecessor of crossterm | 🟡 | Legacy | + +Fringe options (`iocraft`, `ratzilla`, dioxus TUI renderer) are either too +new or aimed at different targets and were not considered. + +## Three Implementation Approaches + +### Approach 1 — Pure `crossterm` (less-style, roll everything) + +- Dependencies: `crossterm` only. +- Model: `Vec` + `top` offset; move cursor + write; hand-rolled + wrap, status bar, search prompt, image placement tracking. +- Estimated size: 1500–2000 lines for v1. +- Pros: smallest dependency footprint; full control. +- Cons: reinvents everything ratatui provides; Kitty image lifecycle + (clip, redraw, delete, resize) all DIY. + +### Approach 2 — `ratatui` + `ratatui-image` + +- Dependencies: ratatui, crossterm, ratatui-image, tui-textarea, regex. +- Model: every frame `terminal.draw(|f| {...})`; wrap, layout, borders from + widgets; headings go through `ratatui-image::StatefulImage`. +- Estimated size: 800–1200 lines for v1. +- Pros: wrap, Layout split, scrollbar, status bar, search highlight all + framework-native; path is proven (mdfried uses this stack). +- Cons: image-handling inherits `ratatui-image`'s synchronous encode + + re-transmission behavior (see "Performance Investigation" below). + +### Approach 3 — `ratatui` + self-managed Kitty images (**chosen**) + +- Dependencies: ratatui, crossterm, tui-textarea, regex. +- Model: text goes through ratatui; heading images are transmitted to the + terminal once with an id (Kitty `a=T, i=N`), and on each redraw we emit + lightweight placement commands (`a=p, i=N, x=col, y=row`). No image bytes + leave memory during scrolling. +- Estimated size: 1000–1500 lines for v1. +- Pros: control over image lifecycle; avoids the re-transmission cost that + is the most plausible cause of mdfried's sluggishness; extends code + already in `render.rs`. +- Cons: need to coordinate image placement with ratatui's per-frame clear; + `a=d` delete for scroll-off cases needs careful state tracking. + +## Performance Investigation — "Why does mdfried feel sluggish?" + +User report: mdfried feels laggy when scrolling long documents. + +Searched for public confirmation: + +- `benjajaja/mdfried` issues page (10 open at time of search): **no + performance-tagged issues**. Either the pool of users is small or people + accept the lag as inherent to "TUI + images". +- `ratatui-image` README explicitly states: *"resizing and encoding is + **blocking** by default, but it is possible to offload this to another + thread or async task"* — acknowledges the cost. +- Same README: *"Kitty graphics protocol is essentially stateful, but at + least provides a way to re-render an image that has been loaded, at a + different or same position."* — i.e. the `a=p` placement path exists but + is not the default widget behavior. +- `ratatui-image` exposes `Image` (stateless, re-encodes each frame) and + `StatefulImage` (more robust but still synchronous encode). + +### Likely causes of mdfried's lag (ranked) + +1. **Per-frame image re-transmission.** ratatui's immediate-mode redraw + doesn't know an image is unchanged; `ratatui-image` conservatively + re-sends base64 PNG data each frame. One H1 ≈ 10–40 KB base64; three + headings on-screen × 30 fps of held-j scrolling = MB/s of escape data + pushed to the terminal. +2. **Wrap / layout in the draw loop.** If layout runs on every frame rather + than once at load time, it's O(N lines) per frame. +3. **No scroll throttling / coalescing.** Key repeat (~30/s) drives a full + redraw each event. +4. **Unbuffered stdout writes.** Each terminal write as a syscall amplifies + the overhead of (1). + +### How termdown's approach 3 avoids each + +| Cause | Mitigation in approach 3 | +|---|---| +| Per-frame re-transmission | Transmit each heading PNG once with an id; subsequent frames emit placement-only commands (~dozens of bytes) | +| Wrap in draw loop | Generate `Vec` once at load; maintain a wrap cache keyed on terminal width | +| Scroll thrash | Coalesce scroll events within a tick; redraw once per frame budget | +| Unbuffered writes | Flush a `BufWriter` once per frame | + +## Decision + +**Approach 3** — ratatui for text layout and widgets, self-managed Kitty +image lifecycle via `a=T` + `a=p`. + +Rationale: + +- mdfried's observed lag matches exactly the default path `ratatui-image` + documents. Avoiding that path is the point. +- termdown already owns half of the Kitty protocol plumbing in `render.rs`; + extending it with id-based placement is a natural fit. +- ratatui's layout, wrap, and diff rendering handle every UI need except + the one thing we want to control ourselves (images). +- Adds four dependencies; `strip + lto` cost expected ≤ 2–3 MB. + +## Shared Layout Module + +Orthogonal to the TUI-library decision: introduce `layout.rs` that turns a +pulldown-cmark event stream into a structured `Vec`. Both the cat +path and the TUI path consume it. This prevents the two rendering paths +from drifting apart as features are added. + +## References + +- [benjajaja/mdfried — issues](https://github.com/benjajaja/mdfried/issues) +- [ratatui-image — crates.io](https://crates.io/crates/ratatui-image) +- [ratatui-image — README](https://github.com/benjajaja/ratatui-image/blob/master/README.md) +- [ratatui — rendering concepts](https://ratatui.rs/concepts/rendering/) +- Kitty graphics protocol — image id + placement semantics (`a=T` transmit, + `a=p` put, `a=d` delete) diff --git a/fixtures/expected/emoji-test.ansi b/fixtures/expected/emoji-test.ansi new file mode 100644 index 0000000..917f602 --- /dev/null +++ b/fixtures/expected/emoji-test.ansi @@ -0,0 +1,35 @@ + + + + + 这是一份专门用于验证标题图片渲染的测试文档。 + + + + • 单个 emoji: 😀 😎 ✨ 🚀 + • 中英混排: Hello 世界 🌍 + • 符号混排: ✅ Done · ⚠ Warning · ❌ Failed + • 常见 emoji 变体: ☀️ ❤️ ⭐️ + + + + 正文仍然由终端自身字体渲染,所以这里主要用来对比标题和正文的表现差异。 + + │ 引用块里也放几个字符:💡 🛠 📦 + + + + Case  │ Example + ────────────────── ┼ ─────────────────── + Single emoji  │ 😀 + Mixed text  │ 修正版 ✨ version 2 + Symbol-like  │ ✅ ⚠ ❌ + Variation selector │ ☀️ ❤️ + + + + 这一行用于观察复杂 ZWJ emoji 的边界表现:👨‍👩‍👧‍👦 👩🏽‍💻 🧑‍🚀 + + + + 如果修复生效,H1-H3 里的大部分单 emoji 和常见符号不应再显示成缺字框。 diff --git a/fixtures/expected/full-syntax-zh.ansi b/fixtures/expected/full-syntax-zh.ansi new file mode 100644 index 0000000..26d3451 --- /dev/null +++ b/fixtures/expected/full-syntax-zh.ansi @@ -0,0 +1,70 @@ + + + + + + + + + 这是六级标题 h6 + + + + 这段文字将显示为斜体 + 这也是斜体 + + 这段文字将显示为粗体 + 这也是粗体 + + 你 可以 组合使用它们 + + + + + + • 项目 1 + • 项目 2 + • 项目 2a + • 项目 2b + • 项目 3a + • 项目 3b + + + + 1. 项目 1 + 2. 项目 2 + 3. 项目 3 + 1. 项目 3a + 2. 项目 3b + + + + [🖼 这是替代文本。](/image/Markdown-mark.svg) + + + + 你可能正在使用 Markdown 实时预览 (https://markdownlivepreview.com/)。 + + + + │ Markdown 是一种轻量级标记语言,使用纯文本格式语法,由 John Gruber 和 + │ Aaron Swartz 于 2004 年创建。 + │ │ Markdown 常用于编写 readme + │ │ 文件、在线论坛中的消息格式化,以及使用纯文本编辑器创建富文本。 + + + + 左对齐列 │ 居中对齐列 + ──────── ┼ ────────── + 左侧 foo │ 右侧 foo + 左侧 bar │ 右侧 bar + 左侧 baz │ 右侧 baz + + + +  let message = '你好,世界';  +  alert(message);  + + + + 这个网站使用了  markedjs/marked 。 diff --git a/fixtures/expected/full-syntax.ansi b/fixtures/expected/full-syntax.ansi new file mode 100644 index 0000000..43ae30b --- /dev/null +++ b/fixtures/expected/full-syntax.ansi @@ -0,0 +1,71 @@ + + + + + + + + + This is a Heading h6 + + + + This text will be italic + This will also be italic + + This text will be bold + This will also be bold + + You can combine them + + + + + + • Item 1 + • Item 2 + • Item 2a + • Item 2b + • Item 3a + • Item 3b + + + + 1. Item 1 + 2. Item 2 + 3. Item 3 + 1. Item 3a + 2. Item 3b + + + + [🖼 This is an alt text.](/image/Markdown-mark.svg) + + + + You may be using Markdown Live Preview (https://markdownlivepreview.com/). + + + + │ Markdown is a lightweight markup language with plain-text-formatting + │ syntax, created in 2004 by John Gruber with Aaron Swartz. + │ │ Markdown is often used to format readme files, for writing messages + │ │ in online discussion forums, and to create rich text using a plain + │ │ text editor. + + + + Left columns │ Right columns + ──────────── ┼ ───────────── + left foo  │ right foo + left bar  │ right bar + left baz  │ right baz + + + +  let message = 'Hello world';  +  alert(message);  + + + + This web site is using  markedjs/marked . diff --git a/fixtures/expected/tasklist.ansi b/fixtures/expected/tasklist.ansi new file mode 100644 index 0000000..3f89221 --- /dev/null +++ b/fixtures/expected/tasklist.ansi @@ -0,0 +1,36 @@ + + + + + [✓] Setup project structure + [✓] Add markdown parser + [ ] Implement task list rendering + [ ] Add configuration options + [ ] Write documentation + + + + [✓] Phase 1 + [✓] Design architecture + [✓] Write prototype + [ ] Code review + [ ] Phase 2 + [ ] Performance optimization + [ ] Integration testing + + + + [✓] Completed task + [ ] Pending task + • Regular list item + [✓] Another done item + + + + 1. Ordered item one + 2. Ordered item two + + [ ] Task after ordered list + [✓] Completed and struck through + [ ] Task with bold and italic text + [ ] Task with  inline code  diff --git a/fixtures/expected/unsupported-syntax.ansi b/fixtures/expected/unsupported-syntax.ansi new file mode 100644 index 0000000..de165a9 --- /dev/null +++ b/fixtures/expected/unsupported-syntax.ansi @@ -0,0 +1,207 @@ + ──────────────────────────────────────────────────────────── + + + + + + This fixture collects every Markdown feature listed as missing or partial in +  docs/MARKDOWN_FEATURE_COVERAGE.md . Use it to verify what termdown renders + today and + as a regression fixture as features are added. + + The YAML frontmatter above should be hidden by the renderer. Currently it + leaks into + the rendered output. + + + + A raw HTML block: + + 
 +  Hello from inline HTML. + 
 + + Inline HTML in a paragraph: this word is underlined via HTML and this one is + red via HTML. An inline + break and an + HTML abbreviation. + + An HTML comment: end of line. + + + + Bare URL: https://example.com/docs/readme.html + Bare email: support@example.com + URL in text: visit https://github.com/rrbe/termdown for the source. + + + + │ [!NOTE] + │ Useful information that users should know, even when skimming content. + + │ [!TIP] + │ Helpful advice for doing things better or more easily. + + │ [!IMPORTANT] + │ Key information users need to know to achieve their goal. + + │ [!WARNING] + │ Urgent info that needs immediate user attention to avoid problems. + + │ [!CAUTION] + │ Advises about risks or negative outcomes of certain actions. + + + + Here is a sentence with a footnote.[^1] And another one.[^longnote] + + Inline footnote: text with an inline footnote.^[This is an inline footnote + body.] + + [^1]: This is the first footnote body. + [^longnote]: This footnote has bold,  code , and multiple + +  paragraphs. It should render as a numbered reference in the main text,  +  with the body collected at the bottom of the document.  + + + + Inline math: the Pythagorean theorem says $a^2 + b^2 = c^2$. + + Display math: + + $$ + \int_{-\infty}^{\infty} e^{-x^2} , dx = \sqrt{\pi} + $$ + + A matrix: + + $$ + A = \begin{pmatrix} 1 & 2 \ 3 & 4 \end{pmatrix} + $$ + + + + Term 1 + : Definition of term 1. + + Term 2 + : First paragraph of the definition. + +  Second paragraph of the definition, indented.  + + Apple + : A red or green fruit. + + Orange + : A citrus fruit with a tough rind. + + + + Straight quotes that should become curly: "Hello," she said. 'Yes,' he + replied. + Ellipsis from three dots... and an em-dash -- like this, and an en-dash -- + too. + + + + Reference a page with [[WikiLink]] syntax, and with an alias like + [[Target Page|the display text]]. + + + + Water is H~2~O and Einstein said E=mc^2^. Also 10^th^ and x~n+1~. + + + + A flowchart: + +  flowchart LR  +  A[Start] --> B{Decision}  +  B -->|Yes| C[Do thing]  +  B -->|No| D[Skip]  +  C --> E[End]  +  D --> E  + + A sequence diagram: + +  sequenceDiagram  +  Alice->>Bob: Hello Bob  +  Bob-->>Alice: Hi Alice  +  Alice-)Bob: See you later!  + + A class diagram: + +  classDiagram  +  class Animal {  +  +String name  +  +int age  +  +makeSound() void  +  }  +  Animal <|-- Dog  +  Animal <|-- Cat  + + + +  @startuml  +  Alice -> Bob: Authentication Request  +  Bob --> Alice: Authentication Response  +  @enduml  + +  digraph G {  +  rankdir=LR;  +  A -> B -> C;  +  A -> C;  +  }  + + + +  fn main() {  +  let greeting = "Hello, termdown!";  +  println!("{}", greeting);  +  }  + +  def fib(n: int) -> int:  +  a, b = 0, 1  +  for _ in range(n):  +  a, b = b, a + b  +  return a  + +  {  +  "name": "termdown",  +  "features": ["headings", "tables", "tasklists"],  +  "version": "0.2.0"  +  }  + + + + Local image (does not exist, exercises error path): + + [🖼 local image alt](./fixtures/nonexistent.png) + + Remote image: + + [🖼 remote image](https://example.com/banner.png) + + Reference-style image: + + [🖼 ref image](https://example.com/banner.png) + + + + Shortcodes like :smile:, :rocket:, :tada: should ideally become 😄 🚀 🎉. + Unicode emoji themselves work fine: 😄 🚀 🎉. + + + + Left  │ Center  │ Right + ───────────────── ┼ ──────────── ┼ ────── + a  │ b  │ c + long left content │ center  │ 1 + x  │ bold in cell │  code  + + + + If every section above renders with rich formatting, termdown has full + coverage of the + audited feature set. diff --git a/fixtures/links/a.md b/fixtures/links/a.md new file mode 100644 index 0000000..5ff9250 --- /dev/null +++ b/fixtures/links/a.md @@ -0,0 +1,14 @@ +# Page A + +You reached Page A. This document lives at `fixtures/links/a.md`. + +## Where to go next + +- [Back to index](index.md) — same directory +- [Down to Page B](sub/b.md) — nested subdirectory +- [Up-and-over link](./sub/b.md) — same target, different spelling +- [Sibling page C](c.md) — broken on purpose; should spawn external opener + +## History check + +Press `o` now — you should return to wherever you came from (index, or Page B if you arrived via back/forward). Press `i` to redo. diff --git a/fixtures/links/index.md b/fixtures/links/index.md new file mode 100644 index 0000000..c874d9a --- /dev/null +++ b/fixtures/links/index.md @@ -0,0 +1,39 @@ +# Link Navigation Test — Index + +This is the **entry point** for testing local `.md` link navigation in TUI mode. + +## Expected keybindings + +- `Enter` — follow link under/near cursor. If multiple links are visible, a numeric overlay appears; press `1`–`9` to pick. +- `o` — Back (pops history). +- `i` — Forward (re-applies a previously popped entry). + +## Links to try + +### Single link on its own screen + +Scroll so only this paragraph is visible, then press Enter: + +[Go to page A](a.md) + +### Multiple links — triggers the numeric overlay + +When both are visible, Enter should open the numeric picker: + +1. [Page A (relative)](./a.md) +2. [Page B (nested)](sub/b.md) +3. [External link — should open in browser, not in TUI](https://example.com) +4. [Broken local link — should fall back to spawn_open](does-not-exist.md) + +### Back / forward flow + +Suggested script: + +1. From **index** → Enter on `a.md` → arrives at Page A. +2. On Page A → Enter on `sub/b.md` → arrives at Page B. +3. Press `o` → back to Page A. +4. Press `o` → back to index. +5. Press `i` → forward to Page A. +6. Press `i` → forward to Page B. + +History stack should preserve scroll position per entry. diff --git a/fixtures/links/sub/b.md b/fixtures/links/sub/b.md new file mode 100644 index 0000000..7e00b1e --- /dev/null +++ b/fixtures/links/sub/b.md @@ -0,0 +1,15 @@ +# Page B (nested) + +You reached Page B at `fixtures/links/sub/b.md`. Relative paths here must climb one directory. + +## Links + +- [Back to index (`../index.md`)](../index.md) +- [Sibling Page A (`../a.md`)](../a.md) +- [External — anthropic.com](https://www.anthropic.com) + +## What to verify + +1. Arrival here from Page A means the resolver handled `sub/b.md` relative to `a.md`'s parent directory. +2. Pressing `o` should pop history back to Page A, not to index. +3. Pressing Enter on `../index.md` walks back up one level — the status bar should now show the index path. diff --git a/fixtures/toc-test.md b/fixtures/toc-test.md new file mode 100644 index 0000000..2add127 --- /dev/null +++ b/fixtures/toc-test.md @@ -0,0 +1,137 @@ +# TOC Test Document + +A document with a deep, varied heading hierarchy for exercising termdown's +TUI table-of-contents panel (toggle with the TOC key, then use ↑/↓/Enter to +jump between sections). + +## 1. Introduction + +Short intro paragraph to establish some body content under the first H2 so +scrolling the document moves the active TOC highlight. + +### 1.1 Goals + +- Exercise heading levels H1 through H6 +- Verify TOC scroll-sync with the document +- Verify Enter jumps to the right section + +### 1.2 Non-goals + +Things deliberately **not** tested by this file: + +1. Image rendering +2. Emoji fallback +3. Code block syntax highlighting + +## 2. Shallow Section + +Only one subsection here, to contrast with the deeper sections below. + +### 2.1 Only child + +Filler paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +## 3. Deep Section + +This section goes all the way down to H6 so the TOC has to render indent +levels correctly. + +### 3.1 Level three + +Paragraph at level three. + +#### 3.1.1 Level four + +Paragraph at level four. + +##### 3.1.1.1 Level five + +Paragraph at level five. + +###### 3.1.1.1.1 Level six + +Paragraph at level six — the deepest heading level Markdown supports. + +###### 3.1.1.1.2 Another level six + +A second H6 sibling, to make sure consecutive same-level headings both +appear in the TOC. + +##### 3.1.1.2 Another level five + +Back up to level five. + +#### 3.1.2 Another level four + +Back up to level four. + +### 3.2 Sibling of 3.1 + +A sibling at level three, to verify the TOC collapses back to the correct +indent after a deep descent. + +## 4. Section with Repeated Titles + +Two subsections share the same title to check slug/id disambiguation in the +TOC (if implemented) and that both entries are independently navigable. + +### 4.1 Duplicate + +First occurrence. + +### 4.1 Duplicate + +Second occurrence. + +## 5. Section with Long Title That Might Wrap in a Narrow TOC Panel Across Multiple Lines + +The TOC panel has a fixed width; this heading is deliberately long enough +to test truncation or wrapping behavior. + +### 5.1 Short child + +Kept short so the contrast with the parent is obvious. + +## 6. Section with Mixed Content + +Headings alone are boring — this section includes a table, list, code +block, and blockquote between subheadings so navigation lands on headings +regardless of what surrounds them. + +### 6.1 Table + +| Col A | Col B | Col C | +| ----- | ----- | ----- | +| 1 | 2 | 3 | +| 4 | 5 | 6 | + +### 6.2 Code + +```rust +fn main() { + println!("hello, toc"); +} +``` + +### 6.3 Quote + +> A heading preceded by a blockquote should still land correctly when +> selected from the TOC. + +### 6.4 List + +- alpha +- beta + - beta.1 + - beta.2 +- gamma + +## 7. Tail Section + +Final top-level section. Useful for checking that "jump to last heading" +lands at the bottom of the document rather than mid-scroll. + +### 7.1 Last child + +End of file. diff --git a/src/cat.rs b/src/cat.rs new file mode 100644 index 0000000..14b5502 --- /dev/null +++ b/src/cat.rs @@ -0,0 +1,334 @@ +//! Stream a `RenderedDoc` to stdout as ANSI text, matching the existing +//! cat-mode visual output. Wrapping, margins, quote prefixes, list +//! indentation, and Kitty heading image emission all happen here. + +use std::io::{BufWriter, Write}; + +use crate::layout::{Color, Line, LineKind, RenderedDoc, Span, Style}; +use crate::render; +use crate::style::{ + display_width, Colors, BOLD_ON, DIM_ON, ITALIC_OFF, ITALIC_ON, MARGIN, MARGIN_WIDTH, RESET, + STRIKETHROUGH_OFF, STRIKETHROUGH_ON, UNDERLINE_OFF, UNDERLINE_ON, +}; + +pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors) { + let stdout = std::io::stdout(); + let mut out = BufWriter::new(stdout.lock()); + + let mut i = 0; + while i < doc.lines.len() { + let line = &doc.lines[i]; + // Group consecutive code-block lines so the colored background pads to + // a uniform width (matches the legacy renderer, which buffered the + // entire fenced block before emitting it). + if matches!(line.kind, LineKind::CodeBlock { .. }) { + let mut end = i; + while end < doc.lines.len() && matches!(doc.lines[end].kind, LineKind::CodeBlock { .. }) + { + end += 1; + } + emit_code_block(&mut out, &doc.lines[i..end], colors); + i = end; + continue; + } + + write_line(&mut out, line, &doc.images, term_width, colors); + i += 1; + } + let _ = out.flush(); +} + +fn write_line( + out: &mut W, + line: &Line, + images: &[crate::render::HeadingImage], + term_width: usize, + colors: &Colors, +) { + match &line.kind { + LineKind::Blank => { + let _ = writeln!(out); + } + LineKind::HorizontalRule => { + let width = term_width.min(62).saturating_sub(2); + let _ = writeln!(out, "{MARGIN}{DIM_ON}{}{RESET}", "\u{2500}".repeat(width)); + } + LineKind::Heading { id, .. } => { + if let Some(image_id) = id { + if let Some(img) = images.iter().find(|i| i.id == *image_id) { + let _ = writeln!(out, "{MARGIN}{}", render::kitty_display(&img.png)); + return; + } + } + let text = render_spans_plain(&line.spans); + let _ = writeln!(out, "{MARGIN}{BOLD_ON}{text}{RESET}"); + } + LineKind::BlockQuote { depth } => { + write_paragraph(out, &line.spans, *depth as usize, term_width, colors); + } + LineKind::Body => { + write_paragraph(out, &line.spans, 0, term_width, colors); + } + LineKind::ListItem { .. } => { + // Layout has already baked the per-depth indent and the bullet or + // numbered marker into the first text span, so cat only needs to + // prepend the outer margin. + let body = render_spans_ansi(&line.spans, colors); + let buf = format!("{MARGIN}{body}"); + wrap_and_write(out, &buf, term_width, ""); + } + LineKind::CodeBlock { .. } => { + // Single-line code blocks are handled via emit_code_block; this + // branch is unreachable in practice because `print` batches them. + let text = render_spans_plain(&line.spans); + let _ = writeln!( + out, + "{MARGIN}{}{} {text} {RESET}", + colors.code_bg, colors.code_fg + ); + } + LineKind::Table => { + let rendered = render_spans_ansi(&line.spans, colors); + let _ = writeln!(out, "{MARGIN} {rendered}"); + } + } +} + +/// Emit a consecutive run of `LineKind::CodeBlock` lines, padding each to the +/// longest line in the group so the background renders as a clean rectangle. +fn emit_code_block(out: &mut W, group: &[Line], colors: &Colors) { + let texts: Vec = group.iter().map(|l| render_spans_plain(&l.spans)).collect(); + let max_w = texts.iter().map(|t| display_width(t)).max().unwrap_or(0); + for text in &texts { + let pad = max_w.saturating_sub(display_width(text)); + let _ = writeln!( + out, + "{MARGIN}{}{} {text}{} {RESET}", + colors.code_bg, + colors.code_fg, + " ".repeat(pad) + ); + } +} + +fn write_paragraph( + out: &mut W, + spans: &[Span], + quote_depth: usize, + term_width: usize, + colors: &Colors, +) { + let body = render_spans_ansi(spans, colors); + let prefix = if quote_depth > 0 { + let bars: String = (0..quote_depth) + .map(|_| format!("{}\u{2502} ", colors.quote_bar)) + .collect(); + format!("{MARGIN}{bars}{}", colors.quote_text) + } else { + MARGIN.to_string() + }; + let suffix = if quote_depth > 0 { RESET } else { "" }; + let prefix_visual_width = MARGIN_WIDTH + quote_depth * 3; + let max_text_width = term_width.saturating_sub(prefix_visual_width); + + if max_text_width == 0 || display_width(&body) <= max_text_width { + let _ = writeln!(out, "{prefix}{body}{suffix}"); + } else { + for wrapped in wrap_text(&body, max_text_width) { + let _ = writeln!(out, "{prefix}{wrapped}{suffix}"); + } + } +} + +fn wrap_and_write(out: &mut W, text: &str, term_width: usize, suffix: &str) { + let max = term_width.saturating_sub(MARGIN_WIDTH); + if max == 0 || display_width(text) <= max { + let _ = writeln!(out, "{text}{suffix}"); + return; + } + for wrapped in wrap_text(text, max) { + let _ = writeln!(out, "{wrapped}{suffix}"); + } +} + +fn wrap_text(text: &str, max_width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width: usize = 0; + for word in text.split_inclusive(' ') { + let w = display_width(word); + if current_width + w > max_width && !current.is_empty() { + lines.push(current.trim_end().to_string()); + current = String::new(); + current_width = 0; + } + current.push_str(word); + current_width += w; + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(text.to_string()); + } + lines +} + +fn render_spans_plain(spans: &[Span]) -> String { + let mut s = String::new(); + for sp in spans { + match sp { + Span::Text { content, .. } | Span::Link { content, .. } => s.push_str(content), + Span::HeadingImage { .. } => {} + } + } + s +} + +fn render_spans_ansi(spans: &[Span], colors: &Colors) -> String { + let mut out = String::new(); + for sp in spans { + match sp { + Span::Text { content, style } => { + push_style_on(&mut out, style); + out.push_str(content); + push_style_off(&mut out, style); + } + Span::Link { + content, + url, + style, + } => { + out.push_str(colors.link); + out.push_str(UNDERLINE_ON); + push_style_on(&mut out, style); + out.push_str(content); + push_style_off(&mut out, style); + out.push_str(UNDERLINE_OFF); + out.push_str(RESET); + if !url.is_empty() { + out.push_str(&format!(" {}({url}){RESET}", colors.url)); + } + } + Span::HeadingImage { .. } => {} + } + } + out +} + +fn push_style_on(out: &mut String, style: &Style) { + if style.bold { + out.push_str(BOLD_ON); + } + if style.italic { + out.push_str(ITALIC_ON); + } + if style.underline { + out.push_str(UNDERLINE_ON); + } + if style.strikethrough { + out.push_str(STRIKETHROUGH_ON); + } + if style.dim { + out.push_str(DIM_ON); + } + if let Some(fg) = &style.fg { + out.push_str(&color_fg(*fg)); + } + if let Some(bg) = &style.bg { + out.push_str(&color_bg(*bg)); + } +} + +fn push_style_off(out: &mut String, style: &Style) { + // Use RESET when any attribute that can't be cleanly "turned off" was set. + if style.fg.is_some() || style.bg.is_some() || style.bold || style.dim { + out.push_str(RESET); + } else { + if style.italic { + out.push_str(ITALIC_OFF); + } + if style.underline { + out.push_str(UNDERLINE_OFF); + } + if style.strikethrough { + out.push_str(STRIKETHROUGH_OFF); + } + } +} + +fn color_fg(c: Color) -> String { + match c { + Color::Indexed(n) => format!("\x1b[38;5;{n}m"), + Color::Rgb(r, g, b) => format!("\x1b[38;2;{r};{g};{b}m"), + } +} + +fn color_bg(c: Color) -> String { + match c { + Color::Indexed(n) => format!("\x1b[48;5;{n}m"), + Color::Rgb(r, g, b) => format!("\x1b[48;2;{r};{g};{b}m"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::style::{display_width, strip_ansi, BOLD_ON, RESET}; + + // ── wrap_text tests (migrated from markdown.rs) ────────────────────────── + + #[test] + fn wrap_text_keeps_single_overlong_word_intact() { + assert_eq!( + wrap_text("supercalifragilistic", 5), + vec!["supercalifragilistic"] + ); + } + + #[test] + fn wrap_text_uses_display_width_when_ansi_and_wide_chars_are_present() { + let text = format!("{BOLD_ON}你好{RESET} world"); + let lines = wrap_text(&text, 6); + + assert_eq!(lines.len(), 2); + assert_eq!(strip_ansi(&lines[0]), "你好"); + assert_eq!(strip_ansi(&lines[1]), "world"); + assert!(lines.iter().all(|line| display_width(line) <= 6)); + } + + // ── write_paragraph wrapping (adapted from markdown::flush_line) ───────── + // The old `flush_line` took a `&mut String` directly; the new equivalent is + // `write_paragraph` which takes a `&[Span]`. We test the same invariant: + // quoted content that overflows is word-wrapped and the buffer is consumed. + + #[test] + fn write_paragraph_wraps_quoted_content() { + use crate::layout::{Span, Style}; + use crate::style::{Colors, MARGIN}; + use crate::theme::Theme; + + let colors = Colors::for_theme(Theme::Dark); + let mut out: Vec = Vec::new(); + + let spans = vec![Span::Text { + content: "alpha beta gamma".into(), + style: Style::default(), + }]; + + // width=12, quote_depth=1 → prefix width = MARGIN_WIDTH(2) + 1*3 = 5, + // so max_text_width = 12 - 5 = 7. "alpha" fits (5), "beta" fits (4 → 9 + // > 7 alone? No: 5+1+4=10 > 7), so lines: "alpha", "beta", "gamma". + write_paragraph(&mut out, &spans, 1, 12, &colors); + + let got = String::from_utf8(out).unwrap(); + let prefix = format!( + "{MARGIN}{}\u{2502} {}", + colors.quote_bar, colors.quote_text + ); + // Each wrapped word should appear on its own prefixed line. + assert!(got.contains(&format!("{prefix}alpha{RESET}"))); + assert!(got.contains(&format!("{prefix}beta{RESET}"))); + assert!(got.contains(&format!("{prefix}gamma{RESET}"))); + } +} diff --git a/src/config.rs b/src/config.rs index 23c1c09..03e3374 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use serde::Deserialize; -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] pub struct Config { #[serde(default)] pub font: FontSection, @@ -11,13 +11,13 @@ pub struct Config { pub theme: Option, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] pub struct FontSection { #[serde(default)] pub heading: HeadingFontConfig, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Clone)] pub struct HeadingFontConfig { /// Font for Latin / ASCII text (sans-serif recommended, e.g. "Inter"). pub latin: Option, diff --git a/src/font.rs b/src/font.rs index f2cceba..a4056da 100644 --- a/src/font.rs +++ b/src/font.rs @@ -1,3 +1,4 @@ +use std::cell::OnceCell; use std::collections::HashMap; use std::fs; use std::sync::{Mutex, OnceLock}; @@ -317,42 +318,78 @@ fn resolve_optional_font( None } +/// Per-level cache of resolved font sets. `Config` is loaded once at startup +/// and is constant for the process lifetime, so a level → FontSet mapping is +/// safe to memoize. `SystemSource::new()` and the family-resolution walk are +/// the dominant cost in heading rasterization (~30–40ms each on macOS), and +/// without this cache they were re-paid for every H1–H3 in the document. +static FONT_SETS: [OnceLock>; 6] = [ + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), +]; + /// Resolve a Latin + CJK + optional emoji font set for the given heading level. -pub fn get_fonts(level: u8, config: &Config) -> Option { - let source = SystemSource::new(); - let props = Properties { - style: Style::Normal, - weight: weight_for_level(level), - stretch: Stretch::NORMAL, - }; - let emoji_props = Properties { - style: Style::Normal, - weight: Weight::NORMAL, - stretch: Stretch::NORMAL, - }; +/// Cached per-level for the process lifetime — the first call's `config` wins; +/// subsequent calls with a different `config` return the originally-resolved +/// fonts. Safe today because `Config` is loaded once at startup, but callers +/// shouldn't rely on per-call config. +pub fn get_fonts(level: u8, config: &Config) -> Option<&'static FontSet> { + let idx = level.saturating_sub(1).min(5) as usize; + FONT_SETS[idx] + .get_or_init(|| resolve_font_set(level, config)) + .as_ref() +} + +// Shared across heading levels within a thread. `SystemSource::new()` walks +// the OS font registry (CoreText / fontconfig / DirectWrite), ~20-30ms per +// call, and we'd otherwise pay it once per heading level. Thread-local +// instead of `static` because on Linux `SystemSource` wraps a raw +// `*mut FcConfig` pointer and is neither `Send` nor `Sync`. +thread_local! { + static SYSTEM_SOURCE: OnceCell = const { OnceCell::new() }; +} + +fn resolve_font_set(level: u8, config: &Config) -> Option { + SYSTEM_SOURCE.with(|cell| { + let source = cell.get_or_init(SystemSource::new); + let props = Properties { + style: Style::Normal, + weight: weight_for_level(level), + stretch: Stretch::NORMAL, + }; + let emoji_props = Properties { + style: Style::Normal, + weight: Weight::NORMAL, + stretch: Stretch::NORMAL, + }; + + let latin = resolve_font( + source, + &props, + config.font.heading.latin.as_deref(), + preferred_latin_families(), + )?; + + let cjk = resolve_font( + source, + &props, + config.font.heading.cjk.as_deref(), + preferred_cjk_families(), + )?; + + let emoji = resolve_optional_font( + source, + &emoji_props, + config.font.heading.emoji.as_deref(), + preferred_emoji_families(), + ); - let latin = resolve_font( - &source, - &props, - config.font.heading.latin.as_deref(), - preferred_latin_families(), - )?; - - let cjk = resolve_font( - &source, - &props, - config.font.heading.cjk.as_deref(), - preferred_cjk_families(), - )?; - - let emoji = resolve_optional_font( - &source, - &emoji_props, - config.font.heading.emoji.as_deref(), - preferred_emoji_families(), - ); - - Some(FontSet { latin, cjk, emoji }) + Some(FontSet { latin, cjk, emoji }) + }) } #[cfg(test)] diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..623163d --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,1360 @@ +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use rayon::prelude::*; + +use crate::config::Config; +use crate::render::HeadingImage; +use crate::theme::Theme; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedDoc { + pub lines: Vec, + pub headings: Vec, + pub images: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Line { + pub spans: Vec, + pub kind: LineKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LineKind { + Body, + Heading { + level: u8, + id: Option, // Some for H1-H3 (image), None for H4-H6 (text) + }, + CodeBlock { + lang: Option, + }, + BlockQuote { + depth: u8, + }, + ListItem { + depth: u8, + }, + Table, + HorizontalRule, + Blank, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Span { + Text { + content: String, + style: Style, + }, + HeadingImage { + id: u32, + rows: u16, + }, + Link { + content: String, + url: String, + style: Style, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, + pub dim: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Color { + /// 256-color index (what the existing style.rs already emits). + Indexed(u8), + /// Truecolor fallback for future use. + #[allow(dead_code)] // Reserved for TUI pipeline. + Rgb(u8, u8, u8), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeadingEntry { + pub level: u8, + pub text: String, + pub line_index: usize, +} + +struct ListState { + ordered: bool, + counter: u64, +} + +pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_STRIKETHROUGH); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_TASKLISTS); + let parser = Parser::new_ext(md, opts); + + let mut lines: Vec = Vec::new(); + let mut spans: Vec = Vec::new(); + let mut text_buf = String::new(); + let mut style = Style::default(); + let mut pending_link_url: Option = None; + let mut heading_level: u8 = 0; + let mut heading_text = String::new(); + let mut images: Vec = Vec::new(); + let mut headings: Vec = Vec::new(); + // Deferred so all H1-H3 rasterizations can run in parallel after the parse. + struct Pending { + level: u8, + text: String, + line_index: usize, + } + let mut pending_headings: Vec = Vec::new(); + let mut quote_depth: u8 = 0; + let mut list_stack: Vec = Vec::new(); + let mut in_code_block: Option> = None; + let mut table_rows: Vec>> = Vec::new(); + let mut current_row: Vec> = Vec::new(); + let mut in_table_header = false; + let mut image_url: Option = None; + let mut in_html_block = false; + let mut html_block_lines: Vec = Vec::new(); + let mut in_item = false; + + // Helper to decide whether a blank line is needed before the next block. + // Returns true if a blank separator should be emitted. + fn push_block_gap(lines: &mut Vec) { + if !lines.is_empty() && !matches!(lines.last().map(|l| &l.kind), Some(LineKind::Blank)) { + lines.push(Line { + spans: vec![], + kind: LineKind::Blank, + }); + } + } + + for event in parser { + match event { + Event::Start(Tag::Heading { level, .. }) => { + push_block_gap(&mut lines); + heading_level = match level { + pulldown_cmark::HeadingLevel::H1 => 1, + pulldown_cmark::HeadingLevel::H2 => 2, + pulldown_cmark::HeadingLevel::H3 => 3, + pulldown_cmark::HeadingLevel::H4 => 4, + pulldown_cmark::HeadingLevel::H5 => 5, + pulldown_cmark::HeadingLevel::H6 => 6, + }; + heading_text.clear(); + } + Event::End(TagEnd::Heading(_)) => { + let text = std::mem::take(&mut heading_text); + headings.push(HeadingEntry { + level: heading_level, + text: text.clone(), + line_index: lines.len(), + }); + + // Seed with the bold-text fallback; the parallel pass below + // overwrites it with a HeadingImage span on rasterization + // success, leaves it as-is on failure. + let fallback_spans = vec![Span::Text { + content: text.clone(), + style: Style { + bold: true, + ..Style::default() + }, + }]; + let line_index = lines.len(); + lines.push(Line { + spans: fallback_spans, + kind: LineKind::Heading { + level: heading_level, + id: None, + }, + }); + if heading_level <= 3 { + pending_headings.push(Pending { + level: heading_level, + text, + line_index, + }); + } + heading_level = 0; + } + Event::Start(Tag::Paragraph) if quote_depth == 0 && !in_item => { + push_block_gap(&mut lines); + } + Event::End(TagEnd::Paragraph) => { + flush_text(&mut text_buf, &mut spans, &style); + let kind = if quote_depth > 0 { + LineKind::BlockQuote { depth: quote_depth } + } else if in_item { + let depth = list_stack.len() as u8; + LineKind::ListItem { depth } + } else { + LineKind::Body + }; + if !spans.is_empty() { + lines.push(Line { + spans: std::mem::take(&mut spans), + kind, + }); + } + } + Event::Start(Tag::Strong) => { + flush_text(&mut text_buf, &mut spans, &style); + style.bold = true; + } + Event::End(TagEnd::Strong) => { + flush_text(&mut text_buf, &mut spans, &style); + style.bold = false; + } + Event::Start(Tag::Emphasis) => { + flush_text(&mut text_buf, &mut spans, &style); + style.italic = true; + } + Event::End(TagEnd::Emphasis) => { + flush_text(&mut text_buf, &mut spans, &style); + style.italic = false; + } + Event::Start(Tag::Strikethrough) => { + flush_text(&mut text_buf, &mut spans, &style); + style.strikethrough = true; + } + Event::End(TagEnd::Strikethrough) => { + flush_text(&mut text_buf, &mut spans, &style); + style.strikethrough = false; + } + Event::Start(Tag::Link { dest_url, .. }) => { + flush_text(&mut text_buf, &mut spans, &style); + pending_link_url = Some(dest_url.to_string()); + } + Event::End(TagEnd::Link) => { + if let Some(url) = pending_link_url.take() { + let content = std::mem::take(&mut text_buf); + spans.push(Span::Link { + content, + url, + style: style.clone(), + }); + } + } + Event::Code(code) => { + flush_text(&mut text_buf, &mut spans, &style); + let mut code_style = style.clone(); + code_style.bg = Some(Color::Indexed(236)); + code_style.fg = Some(Color::Indexed(213)); + // Mirror the legacy renderer, which padded inline code with one + // space on each side so the colored background isn't flush against + // surrounding text. + spans.push(Span::Text { + content: format!(" {code} "), + style: code_style, + }); + } + Event::Text(t) => { + if heading_level > 0 { + heading_text.push_str(&t); + } else if let Some(lang) = in_code_block.clone() { + for line in t.lines() { + lines.push(Line { + spans: vec![Span::Text { + content: line.to_string(), + style: Style::default(), + }], + kind: LineKind::CodeBlock { lang: lang.clone() }, + }); + } + } else { + text_buf.push_str(&t); + } + } + Event::Start(Tag::BlockQuote(..)) => { + if quote_depth == 0 { + push_block_gap(&mut lines); + } + quote_depth += 1; + } + Event::End(TagEnd::BlockQuote(..)) => quote_depth = quote_depth.saturating_sub(1), + + Event::Start(Tag::List(start)) => { + if list_stack.is_empty() && !in_item { + push_block_gap(&mut lines); + } else if in_item { + // Nested list: flush the current parent-item prefix+content + // as its own ListItem line before emitting the sublist. + flush_text(&mut text_buf, &mut spans, &style); + if !spans.is_empty() { + let depth = list_stack.len() as u8; + lines.push(Line { + spans: std::mem::take(&mut spans), + kind: LineKind::ListItem { depth }, + }); + } + } + list_stack.push(ListState { + ordered: start.is_some(), + counter: start.unwrap_or(1), + }); + } + Event::End(TagEnd::List(..)) => { + list_stack.pop(); + } + + Event::Start(Tag::Item) => { + in_item = true; + // Reset the per-item buffer and seed it with the marker that this + // item needs (bullet or number). Indentation is baked in so + // cat.rs only needs to append a margin. + spans.clear(); + text_buf.clear(); + let depth = list_stack.len(); + let indent = " ".repeat(depth); + if let Some(state) = list_stack.last_mut() { + if state.ordered { + text_buf.push_str(&format!("{indent}{}. ", state.counter)); + state.counter += 1; + } else { + text_buf.push_str(&format!("{indent}\u{2022} ")); + } + } + } + Event::End(TagEnd::Item) => { + flush_text(&mut text_buf, &mut spans, &style); + let depth = list_stack.len() as u8; + // If `spans` only contains the bullet/number marker we originally + // seeded, that means the item was entirely consumed by a nested + // list (which already emitted its own parent line). Skip the + // phantom empty line in that case. + let only_marker = spans.len() == 1 + && matches!( + &spans[0], + Span::Text { content, .. } if content.trim_end().ends_with('.') + || content.trim_end().ends_with('\u{2022}') + ); + if !spans.is_empty() && !only_marker { + lines.push(Line { + spans: std::mem::take(&mut spans), + kind: LineKind::ListItem { depth }, + }); + } else { + spans.clear(); + } + in_item = false; + } + + Event::Start(Tag::CodeBlock(kind)) => { + push_block_gap(&mut lines); + let lang = match kind { + pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => { + Some(s.to_string()) + } + _ => None, + }; + in_code_block = Some(lang); + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = None; + } + + Event::Rule => { + push_block_gap(&mut lines); + lines.push(Line { + spans: vec![], + kind: LineKind::HorizontalRule, + }); + } + + // Tables + Event::Start(Tag::Table(..)) => { + push_block_gap(&mut lines); + table_rows.clear(); + in_table_header = false; + } + Event::End(TagEnd::Table) => { + emit_table(&mut lines, &table_rows); + table_rows.clear(); + } + Event::Start(Tag::TableHead) => { + in_table_header = true; + current_row.clear(); + } + Event::End(TagEnd::TableHead) => { + table_rows.push(std::mem::take(&mut current_row)); + in_table_header = false; + } + Event::Start(Tag::TableRow) => { + current_row.clear(); + } + Event::End(TagEnd::TableRow) => { + table_rows.push(std::mem::take(&mut current_row)); + } + Event::Start(Tag::TableCell) => { + spans.clear(); + text_buf.clear(); + } + Event::End(TagEnd::TableCell) => { + flush_text(&mut text_buf, &mut spans, &style); + if in_table_header { + for s in spans.iter_mut() { + if let Span::Text { style, .. } = s { + style.bold = true; + } + } + } + current_row.push(std::mem::take(&mut spans)); + } + + // Images + Event::Start(Tag::Image { dest_url, .. }) => { + flush_text(&mut text_buf, &mut spans, &style); + image_url = Some(dest_url.to_string()); + } + Event::End(TagEnd::Image) => { + flush_text(&mut text_buf, &mut spans, &style); + let alt = spans_plain_text_inline(&spans); + spans.clear(); + let url = image_url.take().unwrap_or_default(); + let content = format!("[\u{1f5bc} {alt}]({url})"); + let dim_style = Style { + dim: true, + ..Style::default() + }; + lines.push(Line { + spans: vec![Span::Text { + content, + style: dim_style, + }], + kind: LineKind::Body, + }); + } + + // Task list marker: replace the bullet that Start(Item) seeded with + // the task-box marker, following glow's style. + Event::TaskListMarker(checked) => { + let marker = if checked { "[\u{2713}] " } else { "[ ] " }; + if let Some(pos) = text_buf.rfind('\u{2022}') { + let end = pos + '\u{2022}'.len_utf8() + " ".len(); + text_buf.replace_range(pos..end, marker); + } + } + + // HTML block — buffer lines, strip comments, then emit each non-empty + // line as a dim Body line. + Event::Start(Tag::HtmlBlock) => { + push_block_gap(&mut lines); + in_html_block = true; + html_block_lines.clear(); + } + Event::End(TagEnd::HtmlBlock) => { + let joined = html_block_lines.join("\n"); + let stripped = strip_html_comments(&joined); + let dim_style = Style { + dim: true, + ..Style::default() + }; + for line in stripped.lines().filter(|l| !l.trim().is_empty()) { + lines.push(Line { + spans: vec![Span::Text { + content: line.to_string(), + style: dim_style.clone(), + }], + kind: LineKind::Body, + }); + } + in_html_block = false; + html_block_lines.clear(); + } + Event::Html(s) => { + if in_html_block { + for line in s.lines() { + html_block_lines.push(line.to_string()); + } + } else { + // Orphan block-ish HTML outside an HtmlBlock tag pair. + // Treat like the legacy renderer and emit dim Body lines. + let dim_style = Style { + dim: true, + ..Style::default() + }; + for line in s.lines() { + lines.push(Line { + spans: vec![Span::Text { + content: line.to_string(), + style: dim_style.clone(), + }], + kind: LineKind::Body, + }); + } + } + } + + // Inline HTML — interpret the common formatting tags (, , , + // //, /), handle `
` / `
`, and + // drop everything else (comments, unknown tags, DOCTYPE, …). + Event::InlineHtml(s) => match parse_html_fragment(&s) { + HtmlFragment::Comment | HtmlFragment::Other => {} + HtmlFragment::SelfClose { name } => { + if name.eq_ignore_ascii_case("br") { + // Flush what we have as its own line in the current block. + flush_text(&mut text_buf, &mut spans, &style); + let kind = if quote_depth > 0 { + LineKind::BlockQuote { depth: quote_depth } + } else if in_item { + let depth = list_stack.len() as u8; + LineKind::ListItem { depth } + } else { + LineKind::Body + }; + if !spans.is_empty() { + lines.push(Line { + spans: std::mem::take(&mut spans), + kind, + }); + } + } else if name.eq_ignore_ascii_case("hr") { + flush_text(&mut text_buf, &mut spans, &style); + if !spans.is_empty() { + let kind = if quote_depth > 0 { + LineKind::BlockQuote { depth: quote_depth } + } else { + LineKind::Body + }; + lines.push(Line { + spans: std::mem::take(&mut spans), + kind, + }); + } + lines.push(Line { + spans: vec![], + kind: LineKind::HorizontalRule, + }); + } + } + HtmlFragment::Open { name } => { + if heading_level == 0 { + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, name); + } + } + HtmlFragment::Close { name } => { + if heading_level == 0 { + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, name); + } + } + }, + + // Breaks — a SoftBreak or HardBreak inside a paragraph flushes the + // accumulated line as a new Body/Quote/ListItem line (mirrors the + // legacy `flush_line` behavior). + Event::SoftBreak | Event::HardBreak => { + if heading_level > 0 { + heading_text.push(' '); + } else if in_code_block.is_some() { + // no-op — the Text event already split on newlines. + } else { + flush_text(&mut text_buf, &mut spans, &style); + let kind = if quote_depth > 0 { + LineKind::BlockQuote { depth: quote_depth } + } else if in_item { + let depth = list_stack.len() as u8; + LineKind::ListItem { depth } + } else { + LineKind::Body + }; + if !spans.is_empty() { + lines.push(Line { + spans: std::mem::take(&mut spans), + kind, + }); + } + // If we're inside a list item, indent the continuation so + // the next line lines up under the content column (after + // the bullet/number marker). + if in_item { + let depth = list_stack.len(); + let indent = " ".repeat(depth); + text_buf.push_str(&format!("{indent} ")); + } + } + } + + _ => {} + } + } + + let results: Vec, u32, u32)>> = pending_headings + .par_iter() + .map(|p| crate::render::render_heading(&p.text, p.level, config, theme)) + .collect(); + + // Walk in document order so image IDs are deterministic across runs. + let mut next_image_id: u32 = 1; + for (p, result) in pending_headings.into_iter().zip(results) { + if let Some((png, px_width, px_height)) = result { + let id = next_image_id; + next_image_id += 1; + // Conservative upper bound; TUI refines once it knows cell pixel height. + let rows = match p.level { + 1 => 6, + 2 => 4, + _ => 3, + }; + images.push(HeadingImage { + id, + png, + cols: 0, + rows, + px_width, + px_height, + }); + let line = &mut lines[p.line_index]; + line.spans = vec![Span::HeadingImage { id, rows }]; + line.kind = LineKind::Heading { + level: p.level, + id: Some(id), + }; + } + } + + RenderedDoc { + lines, + headings, + images, + } +} + +// ─── Inline HTML helpers ──────────────────────────────────────────────────── + +enum HtmlFragment<'a> { + Comment, + Open { name: &'a str }, + Close { name: &'a str }, + SelfClose { name: &'a str }, + Other, +} + +fn html_tag_name(s: &str) -> &str { + let end = s + .find(|c: char| c.is_whitespace() || c == '/' || c == '>') + .unwrap_or(s.len()); + &s[..end] +} + +fn parse_html_fragment(s: &str) -> HtmlFragment<'_> { + let s = s.trim(); + if !s.starts_with('<') || !s.ends_with('>') { + return HtmlFragment::Other; + } + if s.starts_with("` spans from `s`, including spans that cross newlines. +/// Unterminated comments drop the tail of the input. +fn strip_html_comments(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + 4 + end + 3..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out +} + +/// Render accumulated table rows into `LineKind::Table` lines with padding and separators. +/// Keeps the margin-less column layout the existing cat mode produces — the outer +/// " " margin is added by `cat.rs`. +fn emit_table(lines: &mut Vec, rows: &[Vec>]) { + if rows.is_empty() { + return; + } + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + let mut widths = vec![0usize; cols]; + for row in rows { + for (i, cell) in row.iter().enumerate() { + let w: usize = cell.iter().map(plain_width).sum(); + if let Some(slot) = widths.get_mut(i) { + *slot = (*slot).max(w); + } + } + } + + for (ri, row) in rows.iter().enumerate() { + let mut out_spans: Vec = Vec::new(); + for (i, cell) in row.iter().enumerate() { + for s in cell { + out_spans.push(s.clone()); + } + let w: usize = cell.iter().map(plain_width).sum(); + let pad = widths[i].saturating_sub(w); + if pad > 0 { + out_spans.push(Span::Text { + content: " ".repeat(pad), + style: Style::default(), + }); + } + if i < row.len() - 1 { + let dim_style = Style { + dim: true, + ..Style::default() + }; + out_spans.push(Span::Text { + content: " \u{2502} ".into(), + style: dim_style, + }); + } + } + lines.push(Line { + spans: out_spans, + kind: LineKind::Table, + }); + + // Separator after header row. + if ri == 0 { + let mut sep_spans: Vec = Vec::new(); + let dim_style = Style { + dim: true, + ..Style::default() + }; + for (i, &w) in widths.iter().enumerate() { + sep_spans.push(Span::Text { + content: "\u{2500}".repeat(w), + style: dim_style.clone(), + }); + if i < widths.len() - 1 { + sep_spans.push(Span::Text { + content: " \u{253c} ".into(), + style: dim_style.clone(), + }); + } + } + lines.push(Line { + spans: sep_spans, + kind: LineKind::Table, + }); + } + } +} + +fn plain_width(span: &Span) -> usize { + match span { + Span::Text { content, .. } | Span::Link { content, .. } => { + crate::style::display_width(content) + } + Span::HeadingImage { .. } => 0, + } +} + +fn spans_plain_text_inline(spans: &[Span]) -> String { + let mut s = String::new(); + for sp in spans { + match sp { + Span::Text { content, .. } | Span::Link { content, .. } => s.push_str(content), + Span::HeadingImage { .. } => {} + } + } + s +} + +/// Flush the pending plain-text buffer into a styled span and clear it. +fn flush_text(text_buf: &mut String, spans: &mut Vec, style: &Style) { + if !text_buf.is_empty() { + spans.push(Span::Text { + content: std::mem::take(text_buf), + style: style.clone(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn types_compile() { + let _ = RenderedDoc { + lines: vec![Line { + spans: vec![Span::Text { + content: "hi".into(), + style: Style::default(), + }], + kind: LineKind::Body, + }], + headings: vec![], + images: vec![], + }; + } + + use crate::config::Config; + use crate::theme::Theme; + + fn build_plain(md: &str) -> RenderedDoc { + let cfg = Config::default(); + super::build(md, &cfg, Theme::Dark) + } + + #[test] + fn build_single_paragraph() { + let doc = build_plain("hello world\n"); + assert!(doc.lines.iter().any( + |l| matches!(l.kind, LineKind::Body) && spans_plain_text(&l.spans) == "hello world" + )); + } + + fn spans_plain_text(spans: &[Span]) -> String { + let mut out = String::new(); + for s in spans { + match s { + Span::Text { content, .. } => out.push_str(content), + Span::Link { content, .. } => out.push_str(content), + Span::HeadingImage { .. } => {} + } + } + out + } + + #[test] + fn build_inline_bold_and_italic() { + let doc = build_plain("hello **bold** and *it*\n"); + let line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Body)) + .unwrap(); + + let bold_span = line + .spans + .iter() + .find(|s| matches!(s, Span::Text { style, .. } if style.bold)); + let italic_span = line + .spans + .iter() + .find(|s| matches!(s, Span::Text { style, .. } if style.italic)); + + assert!(matches!(bold_span, Some(Span::Text { content, .. }) if content == "bold")); + assert!(matches!(italic_span, Some(Span::Text { content, .. }) if content == "it")); + } + + #[test] + fn build_inline_strikethrough() { + let doc = build_plain("keep ~~drop~~ go\n"); + let line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Body)) + .unwrap(); + let strike = line + .spans + .iter() + .find(|s| matches!(s, Span::Text { style, .. } if style.strikethrough)); + assert!(matches!(strike, Some(Span::Text { content, .. }) if content == "drop")); + } + + #[test] + fn build_link_becomes_link_span() { + let doc = build_plain("see [docs](https://example.com) now\n"); + let line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Body)) + .unwrap(); + let link = line.spans.iter().find_map(|s| match s { + Span::Link { content, url, .. } => Some((content.clone(), url.clone())), + _ => None, + }); + assert_eq!(link, Some(("docs".into(), "https://example.com".into()))); + } + + #[test] + fn build_inline_code_has_styled_bg() { + let doc = build_plain("run `ls` now\n"); + let line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Body)) + .unwrap(); + let code = line.spans.iter().find_map(|s| match s { + // Layout pads inline code with a space on each side so the + // colored background isn't flush against neighboring text. + Span::Text { content, style } if content.trim() == "ls" && style.bg.is_some() => { + Some(()) + } + _ => None, + }); + assert!(code.is_some()); + } + + #[test] + fn build_h1_emits_heading_line_and_entry() { + let md = "# Title\n\nbody\n"; + let doc = build_plain(md); + + let heading_line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Heading { level: 1, .. })) + .expect("H1 line should exist"); + + // Either image span (if fonts resolved) or text fallback — both are valid. + let ok = heading_line + .spans + .iter() + .any(|s| matches!(s, Span::HeadingImage { .. })) + || heading_line.spans.iter().any(|s| { + matches!( + s, + Span::Text { content, style } if content == "Title" && style.bold + ) + }); + assert!(ok); + + let entry = doc.headings.iter().find(|h| h.level == 1); + assert!(matches!(entry, Some(e) if e.text == "Title")); + } + + #[test] + fn build_h5_emits_text_only_line() { + let doc = build_plain("##### tiny\n"); + let line = doc + .lines + .iter() + .find(|l| matches!(l.kind, LineKind::Heading { level: 5, id: None })) + .expect("H5 line should exist with id=None"); + let bold = line.spans.iter().any(|s| { + matches!( + s, + Span::Text { content, style } if content == "tiny" && style.bold + ) + }); + assert!(bold); + } + + #[test] + fn build_h1_h2_h3_get_unique_image_ids() { + // Only meaningful when fonts are present; assert uniqueness *if* multiple images produced. + let doc = build_plain("# A\n\n## B\n\n### C\n"); + let ids: Vec = doc.images.iter().map(|i| i.id).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(ids.len(), sorted.len(), "image ids should be unique"); + } + + #[test] + fn build_blockquote_carries_depth() { + let doc = build_plain("> quoted\n"); + assert!(doc + .lines + .iter() + .any(|l| matches!(l.kind, LineKind::BlockQuote { depth: 1 }))); + } + + #[test] + fn build_unordered_list_item_has_depth() { + let doc = build_plain("- a\n- b\n"); + let items: Vec<_> = doc + .lines + .iter() + .filter(|l| matches!(l.kind, LineKind::ListItem { depth: 1 })) + .collect(); + assert_eq!(items.len(), 2); + } + + #[test] + fn build_rule_emits_horizontal_rule_line() { + let doc = build_plain("---\n"); + assert!(doc + .lines + .iter() + .any(|l| matches!(l.kind, LineKind::HorizontalRule))); + } + + #[test] + fn build_code_block_emits_codeblock_lines_with_lang() { + let doc = build_plain("```rust\nfn main() {}\n```\n"); + let has_lang = doc.lines.iter().any(|l| { + matches!( + &l.kind, + LineKind::CodeBlock { lang: Some(s) } if s == "rust" + ) + }); + assert!(has_lang); + } + + #[test] + fn build_table_emits_table_lines() { + let doc = build_plain("| A | B |\n| - | - |\n| x | y |\n"); + let rows: Vec<_> = doc + .lines + .iter() + .filter(|l| matches!(l.kind, LineKind::Table)) + .collect(); + // Header + separator + body = 3 lines minimum. + assert!(rows.len() >= 3); + } + + #[test] + fn build_task_list_marker_replaces_bullet() { + let doc = build_plain("- [x] done\n- [ ] todo\n"); + let items: Vec<_> = doc + .lines + .iter() + .filter(|l| matches!(l.kind, LineKind::ListItem { .. })) + .collect(); + assert_eq!(items.len(), 2); + let first = spans_plain_text(&items[0].spans); + let second = spans_plain_text(&items[1].spans); + assert!(first.contains("[✓]") || first.contains("[x]")); + assert!(second.contains("[ ]")); + } + + #[test] + fn build_image_renders_placeholder_text() { + let doc = build_plain("![alt](https://example.com/x.png)\n"); + let placeholder = doc.lines.iter().any(|l| { + spans_plain_text(&l.spans).contains("alt") + && spans_plain_text(&l.spans).contains("https://example.com/x.png") + }); + assert!(placeholder); + } + + #[test] + fn build_html_block_emits_body_line_per_source_line() { + let doc = build_plain("
\n

x

\n
\n"); + let html_text: Vec<_> = doc + .lines + .iter() + .filter_map(|l| { + if matches!(l.kind, LineKind::Body) { + Some(spans_plain_text(&l.spans)) + } else { + None + } + }) + .collect(); + let joined = html_text.join("\n"); + assert!(joined.contains("
")); + assert!(joined.contains("

x

")); + assert!(joined.contains("
")); + } + + // ── HTML helper tests (migrated from markdown.rs) ──────────────────────── + + #[test] + fn parse_html_fragment_recognizes_every_shape() { + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Comment + )); + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Other + )); + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Other + )); + assert!(matches!( + parse_html_fragment("
"), + HtmlFragment::SelfClose { name } if name == "br" + )); + assert!(matches!( + parse_html_fragment("
"), + HtmlFragment::SelfClose { name } if name == "br" + )); + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Open { name } if name == "b" + )); + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Open { name } if name == "span" + )); + assert!(matches!( + parse_html_fragment(""), + HtmlFragment::Close { name } if name.eq_ignore_ascii_case("strong") + )); + assert!(matches!( + parse_html_fragment("not a tag"), + HtmlFragment::Other + )); + } + + // The legacy `inline_tag_on`/`inline_tag_off` were replaced by + // `apply_inline_tag_on`/`apply_inline_tag_off`, which mutate a `Style` + // instead of returning ANSI strings. We test the same behavioral intent: + // known formatting tags toggle the corresponding style fields. + #[test] + fn apply_inline_tag_on_off_maps_known_format_tags() { + let mut text_buf = String::new(); + let mut spans: Vec = Vec::new(); + let mut style = Style::default(); + + // → bold on + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "b"); + assert!(style.bold); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "strong"); + assert!(!style.bold); + + // (uppercase normalised) → bold on + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "STRONG"); + assert!(style.bold); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "b"); + assert!(!style.bold); + + // → italic + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "i"); + assert!(style.italic); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "em"); + assert!(!style.italic); + + // → underline + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "u"); + assert!(style.underline); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "u"); + assert!(!style.underline); + + // → strikethrough + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "s"); + assert!(style.strikethrough); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "del"); + assert!(!style.strikethrough); + + // → bg/fg set + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "code"); + assert!(style.bg.is_some()); + assert!(style.fg.is_some()); + apply_inline_tag_off(&mut text_buf, &mut spans, &mut style, "code"); + assert!(style.bg.is_none()); + assert!(style.fg.is_none()); + + // (unknown) → no style change + apply_inline_tag_on(&mut text_buf, &mut spans, &mut style, "span"); + assert_eq!(style, Style::default()); + } + + #[test] + fn strip_html_comments_handles_inline_and_multiline() { + assert_eq!( + strip_html_comments("a b c"), + "a b c" + ); + assert_eq!( + strip_html_comments("pre\n\npost"), + "pre\n\npost" + ); + assert_eq!(strip_html_comments("head ` spans from `s`, including spans that cross newlines. -/// Unterminated comments drop the tail of the input. -fn strip_html_comments(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut rest = s; - while let Some(start) = rest.find("") { - Some(end) => rest = &rest[start + 4 + end + 3..], - None => { - rest = ""; - break; - } - } - } - out.push_str(rest); - out -} - -fn flush_html_block(out: &mut impl Write, lines: &[String]) { - for line in lines { - let _ = writeln!(out, "{MARGIN}{DIM_ON}{line}{RESET}"); - } -} - -fn handle_inline_break( - out: &mut impl Write, - line_buf: &mut String, - list_depth: usize, - in_item: bool, - quote_depth: usize, - term_width: usize, - colors: &Colors, -) { - flush_line(out, line_buf, quote_depth, term_width, colors); - if in_item { - let depth = list_depth.saturating_sub(1); - let indent = " ".repeat(depth); - line_buf.push_str(&format!("{indent} ")); - } -} - -fn handle_inline_rule( - out: &mut impl Write, - line_buf: &mut String, - quote_depth: usize, - term_width: usize, - colors: &Colors, -) { - flush_line(out, line_buf, quote_depth, term_width, colors); - let width = term_width.min(62).saturating_sub(2); - let _ = writeln!(out, "{MARGIN}{DIM_ON}{}{RESET}", "\u{2500}".repeat(width)); -} - -// ─── Main Renderer ────────────────────────────────────────────────────────── - -pub fn render(text: &str, term_width: usize, config: &Config, theme: Theme, colors: &Colors) { - let mut opts = Options::empty(); - opts.insert(Options::ENABLE_STRIKETHROUGH); - opts.insert(Options::ENABLE_TABLES); - opts.insert(Options::ENABLE_TASKLISTS); - - let parser = Parser::new_ext(text, opts); - let stdout = io::stdout(); - let mut out = io::BufWriter::new(stdout.lock()); - - // ── State ── - let mut in_heading = false; - let mut heading_level: u8 = 0; - let mut heading_text = String::new(); - let mut line_buf = String::new(); - let mut first_block = true; - let mut in_item = false; - let mut list_stack: Vec = Vec::new(); - let mut quote_depth: usize = 0; - let mut in_code_block = false; - let mut code_lines: Vec = Vec::new(); - let mut link_url = String::new(); - let mut image_url = String::new(); - let mut image_title = String::new(); - let mut table_row: Vec = Vec::new(); - let mut table_header = false; - let mut table_rows: Vec> = Vec::new(); - let mut in_html_block = false; - let mut html_block_lines: Vec = Vec::new(); - - for event in parser { - match event { - // ── Headings ────────────────────────────────────────────── - Event::Start(Tag::Heading { level, .. }) => { - in_heading = true; - heading_level = level_to_u8(level); - heading_text.clear(); - } - Event::End(TagEnd::Heading(..)) => { - block_gap(&mut out, &mut first_block); - if heading_level <= 3 { - match render::render_heading(&heading_text, heading_level, config, theme) { - Some(png) => { - let _ = writeln!(out, "{MARGIN}{}", render::kitty_display(&png)); - } - None => { - let _ = writeln!(out, "{MARGIN}{BOLD_ON}{heading_text}{RESET}"); - } - } - } else { - let _ = writeln!(out, "{MARGIN}{BOLD_ON}{heading_text}{RESET}"); - } - in_heading = false; - } - - // ── Paragraphs ──────────────────────────────────────────── - Event::Start(Tag::Paragraph) => { - if quote_depth == 0 { - block_gap(&mut out, &mut first_block); - } - line_buf.clear(); - } - Event::End(TagEnd::Paragraph) => { - flush_line(&mut out, &mut line_buf, quote_depth, term_width, colors); - } - - // ── Blockquotes ─────────────────────────────────────────── - Event::Start(Tag::BlockQuote(..)) => { - if quote_depth == 0 { - block_gap(&mut out, &mut first_block); - } - quote_depth += 1; - } - Event::End(TagEnd::BlockQuote(..)) => { - quote_depth = quote_depth.saturating_sub(1); - } - - // ── Lists (ordered + unordered) ─────────────────────────── - Event::Start(Tag::List(start)) => { - if list_stack.is_empty() { - block_gap(&mut out, &mut first_block); - } else { - // Flush parent item text before starting nested list - flush_line(&mut out, &mut line_buf, quote_depth, term_width, colors); - } - list_stack.push(ListState { - ordered: start.is_some(), - counter: start.unwrap_or(1), - }); - } - Event::End(TagEnd::List(..)) => { - list_stack.pop(); - } - Event::Start(Tag::Item) => { - in_item = true; - line_buf.clear(); - let depth = list_stack.len(); - let indent = " ".repeat(depth); - if let Some(state) = list_stack.last_mut() { - if state.ordered { - line_buf.push_str(&format!("{indent}{}. ", state.counter)); - state.counter += 1; - } else { - line_buf.push_str(&format!("{indent}\u{2022} ")); - } - } - } - Event::End(TagEnd::Item) => { - flush_line(&mut out, &mut line_buf, quote_depth, term_width, colors); - in_item = false; - } - - // ── Task list checkboxes ───────────────────────────────── - Event::TaskListMarker(checked) => { - // Replace the bullet "• " that was already appended by Start(Item) - // with the checkbox marker, following glow's style: [✓] / [ ] - let marker = if checked { "[\u{2713}] " } else { "[ ] " }; - if let Some(pos) = line_buf.rfind('\u{2022}') { - line_buf.replace_range(pos..pos + '\u{2022}'.len_utf8() + " ".len(), marker); - } - } - - // ── Code blocks (buffered for uniform width) ────────────── - Event::Start(Tag::CodeBlock(..)) => { - block_gap(&mut out, &mut first_block); - in_code_block = true; - code_lines.clear(); - } - Event::End(TagEnd::CodeBlock) => { - let max_w = code_lines - .iter() - .map(|l| display_width(l)) - .max() - .unwrap_or(0); - for line in &code_lines { - let pad = max_w.saturating_sub(display_width(line)); - let _ = writeln!( - out, - "{MARGIN}{}{} {line}{} {RESET}", - colors.code_bg, - colors.code_fg, - " ".repeat(pad) - ); - } - in_code_block = false; - code_lines.clear(); - } - - // ── HTML block ─────────────────────────────────────────── - Event::Start(Tag::HtmlBlock) => { - block_gap(&mut out, &mut first_block); - in_html_block = true; - html_block_lines.clear(); - } - Event::End(TagEnd::HtmlBlock) => { - let joined = html_block_lines.join("\n"); - let stripped = strip_html_comments(&joined); - let lines: Vec = stripped - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| l.to_string()) - .collect(); - flush_html_block(&mut out, &lines); - in_html_block = false; - html_block_lines.clear(); - } - Event::Html(s) if in_html_block => { - for line in s.lines() { - html_block_lines.push(line.to_string()); - } - } - - // ── Inline formatting ───────────────────────────────────── - Event::Start(Tag::Strong) if !in_heading => { - line_buf.push_str(BOLD_ON); - } - Event::End(TagEnd::Strong) if !in_heading => { - line_buf.push_str(RESET); - } - Event::Start(Tag::Emphasis) if !in_heading => { - line_buf.push_str(ITALIC_ON); - } - Event::End(TagEnd::Emphasis) if !in_heading => { - line_buf.push_str(ITALIC_OFF); - } - Event::Start(Tag::Strikethrough) if !in_heading => { - line_buf.push_str(STRIKETHROUGH_ON); - } - Event::End(TagEnd::Strikethrough) if !in_heading => { - line_buf.push_str(STRIKETHROUGH_OFF); - } - - // ── Links ───────────────────────────────────────────────── - Event::Start(Tag::Link { dest_url, .. }) => { - link_url = dest_url.to_string(); - line_buf.push_str(colors.link); - line_buf.push_str(UNDERLINE_ON); - } - Event::End(TagEnd::Link) => { - line_buf.push_str(UNDERLINE_OFF); - line_buf.push_str(RESET); - if !link_url.is_empty() { - line_buf.push_str(&format!(" {}({link_url}){RESET}", colors.url)); - } - link_url.clear(); - } - - // ── Tables ──────────────────────────────────────────────── - Event::Start(Tag::Table(..)) => { - block_gap(&mut out, &mut first_block); - table_rows.clear(); - table_header = false; - } - Event::End(TagEnd::Table) => { - render_table(&mut out, &table_rows); - } - Event::Start(Tag::TableHead) => { - table_header = true; - table_row.clear(); - } - Event::End(TagEnd::TableHead) => { - table_rows.push(table_row.clone()); - table_row.clear(); - table_header = false; - } - Event::Start(Tag::TableRow) => { - table_row.clear(); - } - Event::End(TagEnd::TableRow) => { - table_rows.push(table_row.clone()); - table_row.clear(); - } - Event::Start(Tag::TableCell) => { - line_buf.clear(); - if table_header { - line_buf.push_str(BOLD_ON); - } - } - Event::End(TagEnd::TableCell) => { - if table_header { - line_buf.push_str(RESET); - } - table_row.push(line_buf.clone()); - line_buf.clear(); - } - - // ── Images ──────────────────────────────────────────────── - Event::Start(Tag::Image { - dest_url, title, .. - }) => { - image_url = dest_url.to_string(); - image_title = title.to_string(); - } - Event::End(TagEnd::Image) => { - let alt = if !line_buf.is_empty() { - line_buf.clone() - } else { - image_title.clone() - }; - let _ = writeln!(out, "{MARGIN}{DIM_ON}[\u{1f5bc} {alt}]({image_url}){RESET}"); - line_buf.clear(); - image_url.clear(); - image_title.clear(); - } - - // ── Inline code ─────────────────────────────────────────── - Event::Code(code) => { - if in_heading { - heading_text.push_str(&code); - } else { - line_buf.push_str(&format!( - "{}{} {code} {RESET}", - colors.code_bg, colors.code_fg - )); - } - } - - // ── Inline HTML ─────────────────────────────────────────── - Event::InlineHtml(s) => match parse_html_fragment(&s) { - HtmlFragment::Comment | HtmlFragment::Other => {} - HtmlFragment::SelfClose { name } => { - if name.eq_ignore_ascii_case("br") { - handle_inline_break( - &mut out, - &mut line_buf, - list_stack.len(), - in_item, - quote_depth, - term_width, - colors, - ); - } else if name.eq_ignore_ascii_case("hr") { - handle_inline_rule( - &mut out, - &mut line_buf, - quote_depth, - term_width, - colors, - ); - } - } - HtmlFragment::Open { name } => { - if !in_heading { - if let Some(on) = inline_tag_on(name, colors) { - line_buf.push_str(&on); - } - } - } - HtmlFragment::Close { name } => { - if !in_heading { - if let Some(off) = inline_tag_off(name) { - line_buf.push_str(off); - } - } - } - }, - - // ── Text ────────────────────────────────────────────────── - Event::Text(t) => { - if in_heading { - heading_text.push_str(&t); - } else if in_code_block { - for line in t.lines() { - code_lines.push(line.to_string()); - } - } else { - line_buf.push_str(&t); - } - } - - // ── Breaks ──────────────────────────────────────────────── - Event::SoftBreak => { - if in_heading { - heading_text.push(' '); - } else if !in_code_block { - flush_line(&mut out, &mut line_buf, quote_depth, term_width, colors); - if in_item { - let depth = list_stack.len(); - let indent = " ".repeat(depth); - line_buf.push_str(&format!("{indent} ")); - } - } - } - Event::HardBreak => { - flush_line(&mut out, &mut line_buf, quote_depth, term_width, colors); - } - Event::Rule => { - block_gap(&mut out, &mut first_block); - let width = term_width.min(62).saturating_sub(2); - let _ = writeln!(out, "{MARGIN}{DIM_ON}{}{RESET}", "\u{2500}".repeat(width)); - } - _ => {} - } - } - - flush_line(&mut out, &mut line_buf, 0, term_width, colors); - let _ = out.flush(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::style::strip_ansi; - - #[test] - fn wrap_text_keeps_single_overlong_word_intact() { - assert_eq!( - wrap_text("supercalifragilistic", 5), - vec!["supercalifragilistic"] - ); - } - - #[test] - fn wrap_text_uses_display_width_when_ansi_and_wide_chars_are_present() { - let text = format!("{BOLD_ON}你好{RESET} world"); - let lines = wrap_text(&text, 6); - - assert_eq!(lines.len(), 2); - assert_eq!(strip_ansi(&lines[0]), "你好"); - assert_eq!(strip_ansi(&lines[1]), "world"); - assert!(lines.iter().all(|line| display_width(line) <= 6)); - } - - #[test] - fn flush_line_wraps_quoted_content_and_clears_buffer() { - let dark = Colors::for_theme(crate::theme::Theme::Dark); - let mut out = Vec::new(); - let mut line = String::from("alpha beta gamma"); - - flush_line(&mut out, &mut line, 1, 12, &dark); - - let prefix = format!("{MARGIN}{}\u{2502} {}", dark.quote_bar, dark.quote_text); - let expected = format!("{prefix}alpha{RESET}\n{prefix}beta{RESET}\n{prefix}gamma{RESET}\n"); - - assert_eq!(String::from_utf8(out).unwrap(), expected); - assert!(line.is_empty()); - } - - #[test] - fn parse_html_fragment_recognizes_every_shape() { - assert!(matches!( - parse_html_fragment(""), - HtmlFragment::Comment - )); - assert!(matches!( - parse_html_fragment(""), - HtmlFragment::Other - )); - assert!(matches!( - parse_html_fragment(""), - HtmlFragment::Other - )); - assert!(matches!( - parse_html_fragment("
"), - HtmlFragment::SelfClose { name } if name == "br" - )); - assert!(matches!( - parse_html_fragment("
"), - HtmlFragment::SelfClose { name } if name == "br" - )); - assert!(matches!( - parse_html_fragment(""), - HtmlFragment::Open { name } if name == "b" - )); - assert!(matches!( - parse_html_fragment(""), - HtmlFragment::Open { name } if name == "span" - )); - assert!(matches!( - parse_html_fragment("
"), - HtmlFragment::Close { name } if name.eq_ignore_ascii_case("strong") - )); - assert!(matches!( - parse_html_fragment("not a tag"), - HtmlFragment::Other - )); - } - - #[test] - fn inline_tag_on_off_maps_known_format_tags() { - let colors = Colors::for_theme(crate::theme::Theme::Dark); - assert_eq!(inline_tag_on("b", &colors).unwrap(), BOLD_ON); - assert_eq!(inline_tag_on("STRONG", &colors).unwrap(), BOLD_ON); - assert_eq!(inline_tag_on("i", &colors).unwrap(), ITALIC_ON); - assert_eq!(inline_tag_on("u", &colors).unwrap(), UNDERLINE_ON); - assert_eq!(inline_tag_on("s", &colors).unwrap(), STRIKETHROUGH_ON); - assert_eq!(inline_tag_on("del", &colors).unwrap(), STRIKETHROUGH_ON); - assert!(inline_tag_on("code", &colors) - .unwrap() - .contains(colors.code_bg)); - assert!(inline_tag_on("span", &colors).is_none()); - - assert_eq!(inline_tag_off("strong"), Some(RESET)); - assert_eq!(inline_tag_off("em"), Some(ITALIC_OFF)); - assert_eq!(inline_tag_off("u"), Some(UNDERLINE_OFF)); - assert_eq!(inline_tag_off("strike"), Some(STRIKETHROUGH_OFF)); - assert_eq!(inline_tag_off("code"), Some(" \x1b[0m")); - assert!(inline_tag_off("div").is_none()); - } - - #[test] - fn strip_html_comments_handles_inline_and_multiline() { - assert_eq!( - strip_html_comments("a b c"), - "a b c" - ); - assert_eq!( - strip_html_comments("pre\n\npost"), - "pre\n\npost" - ); - assert_eq!(strip_html_comments("head