Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

1.0.0

RivoLink ff1ba049

+4031
+11
.gitignore
··· 1 + # Custom 2 + *.rl 3 + /.notes 4 + 5 + # Rust/Cargo 6 + /debug 7 + /target 8 + **/*.rs.bk 9 + 10 + # Logs 11 + *.log
+990
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 12 + name = "allocator-api2" 13 + version = "0.2.21" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 16 + 17 + [[package]] 18 + name = "anyhow" 19 + version = "1.0.102" 20 + source = "registry+https://github.com/rust-lang/crates.io-index" 21 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 22 + 23 + [[package]] 24 + name = "base64" 25 + version = "0.22.1" 26 + source = "registry+https://github.com/rust-lang/crates.io-index" 27 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 28 + 29 + [[package]] 30 + name = "bincode" 31 + version = "1.3.3" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 34 + dependencies = [ 35 + "serde", 36 + ] 37 + 38 + [[package]] 39 + name = "bitflags" 40 + version = "2.11.0" 41 + source = "registry+https://github.com/rust-lang/crates.io-index" 42 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 43 + 44 + [[package]] 45 + name = "cassowary" 46 + version = "0.3.0" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 49 + 50 + [[package]] 51 + name = "castaway" 52 + version = "0.2.4" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 55 + dependencies = [ 56 + "rustversion", 57 + ] 58 + 59 + [[package]] 60 + name = "cc" 61 + version = "1.2.59" 62 + source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" 64 + dependencies = [ 65 + "find-msvc-tools", 66 + "shlex", 67 + ] 68 + 69 + [[package]] 70 + name = "cfg-if" 71 + version = "1.0.4" 72 + source = "registry+https://github.com/rust-lang/crates.io-index" 73 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 74 + 75 + [[package]] 76 + name = "compact_str" 77 + version = "0.8.1" 78 + source = "registry+https://github.com/rust-lang/crates.io-index" 79 + checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 80 + dependencies = [ 81 + "castaway", 82 + "cfg-if", 83 + "itoa", 84 + "rustversion", 85 + "ryu", 86 + "static_assertions", 87 + ] 88 + 89 + [[package]] 90 + name = "crc32fast" 91 + version = "1.5.0" 92 + source = "registry+https://github.com/rust-lang/crates.io-index" 93 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 94 + dependencies = [ 95 + "cfg-if", 96 + ] 97 + 98 + [[package]] 99 + name = "crossterm" 100 + version = "0.28.1" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 103 + dependencies = [ 104 + "bitflags", 105 + "crossterm_winapi", 106 + "mio", 107 + "parking_lot", 108 + "rustix", 109 + "signal-hook", 110 + "signal-hook-mio", 111 + "winapi", 112 + ] 113 + 114 + [[package]] 115 + name = "crossterm_winapi" 116 + version = "0.9.1" 117 + source = "registry+https://github.com/rust-lang/crates.io-index" 118 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 119 + dependencies = [ 120 + "winapi", 121 + ] 122 + 123 + [[package]] 124 + name = "darling" 125 + version = "0.23.0" 126 + source = "registry+https://github.com/rust-lang/crates.io-index" 127 + checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" 128 + dependencies = [ 129 + "darling_core", 130 + "darling_macro", 131 + ] 132 + 133 + [[package]] 134 + name = "darling_core" 135 + version = "0.23.0" 136 + source = "registry+https://github.com/rust-lang/crates.io-index" 137 + checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" 138 + dependencies = [ 139 + "ident_case", 140 + "proc-macro2", 141 + "quote", 142 + "strsim", 143 + "syn", 144 + ] 145 + 146 + [[package]] 147 + name = "darling_macro" 148 + version = "0.23.0" 149 + source = "registry+https://github.com/rust-lang/crates.io-index" 150 + checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" 151 + dependencies = [ 152 + "darling_core", 153 + "quote", 154 + "syn", 155 + ] 156 + 157 + [[package]] 158 + name = "deranged" 159 + version = "0.5.8" 160 + source = "registry+https://github.com/rust-lang/crates.io-index" 161 + checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 162 + dependencies = [ 163 + "powerfmt", 164 + ] 165 + 166 + [[package]] 167 + name = "either" 168 + version = "1.15.0" 169 + source = "registry+https://github.com/rust-lang/crates.io-index" 170 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 171 + 172 + [[package]] 173 + name = "equivalent" 174 + version = "1.0.2" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 177 + 178 + [[package]] 179 + name = "errno" 180 + version = "0.3.14" 181 + source = "registry+https://github.com/rust-lang/crates.io-index" 182 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 183 + dependencies = [ 184 + "libc", 185 + "windows-sys 0.61.2", 186 + ] 187 + 188 + [[package]] 189 + name = "find-msvc-tools" 190 + version = "0.1.9" 191 + source = "registry+https://github.com/rust-lang/crates.io-index" 192 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 193 + 194 + [[package]] 195 + name = "flate2" 196 + version = "1.1.9" 197 + source = "registry+https://github.com/rust-lang/crates.io-index" 198 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 199 + dependencies = [ 200 + "crc32fast", 201 + "miniz_oxide", 202 + ] 203 + 204 + [[package]] 205 + name = "fnv" 206 + version = "1.0.7" 207 + source = "registry+https://github.com/rust-lang/crates.io-index" 208 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 209 + 210 + [[package]] 211 + name = "foldhash" 212 + version = "0.1.5" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 215 + 216 + [[package]] 217 + name = "getopts" 218 + version = "0.2.24" 219 + source = "registry+https://github.com/rust-lang/crates.io-index" 220 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 221 + dependencies = [ 222 + "unicode-width 0.2.0", 223 + ] 224 + 225 + [[package]] 226 + name = "hashbrown" 227 + version = "0.15.5" 228 + source = "registry+https://github.com/rust-lang/crates.io-index" 229 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 230 + dependencies = [ 231 + "allocator-api2", 232 + "equivalent", 233 + "foldhash", 234 + ] 235 + 236 + [[package]] 237 + name = "hashbrown" 238 + version = "0.16.1" 239 + source = "registry+https://github.com/rust-lang/crates.io-index" 240 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 241 + 242 + [[package]] 243 + name = "heck" 244 + version = "0.5.0" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 247 + 248 + [[package]] 249 + name = "ident_case" 250 + version = "1.0.1" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 253 + 254 + [[package]] 255 + name = "indexmap" 256 + version = "2.13.1" 257 + source = "registry+https://github.com/rust-lang/crates.io-index" 258 + checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" 259 + dependencies = [ 260 + "equivalent", 261 + "hashbrown 0.16.1", 262 + ] 263 + 264 + [[package]] 265 + name = "indoc" 266 + version = "2.0.7" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 269 + dependencies = [ 270 + "rustversion", 271 + ] 272 + 273 + [[package]] 274 + name = "instability" 275 + version = "0.3.12" 276 + source = "registry+https://github.com/rust-lang/crates.io-index" 277 + checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" 278 + dependencies = [ 279 + "darling", 280 + "indoc", 281 + "proc-macro2", 282 + "quote", 283 + "syn", 284 + ] 285 + 286 + [[package]] 287 + name = "itertools" 288 + version = "0.13.0" 289 + source = "registry+https://github.com/rust-lang/crates.io-index" 290 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 291 + dependencies = [ 292 + "either", 293 + ] 294 + 295 + [[package]] 296 + name = "itoa" 297 + version = "1.0.18" 298 + source = "registry+https://github.com/rust-lang/crates.io-index" 299 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 300 + 301 + [[package]] 302 + name = "leaf" 303 + version = "1.0.0" 304 + dependencies = [ 305 + "anyhow", 306 + "crossterm", 307 + "pulldown-cmark", 308 + "ratatui", 309 + "syntect", 310 + "unicode-width 0.1.14", 311 + ] 312 + 313 + [[package]] 314 + name = "libc" 315 + version = "0.2.184" 316 + source = "registry+https://github.com/rust-lang/crates.io-index" 317 + checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" 318 + 319 + [[package]] 320 + name = "linked-hash-map" 321 + version = "0.5.6" 322 + source = "registry+https://github.com/rust-lang/crates.io-index" 323 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 324 + 325 + [[package]] 326 + name = "linux-raw-sys" 327 + version = "0.4.15" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 330 + 331 + [[package]] 332 + name = "lock_api" 333 + version = "0.4.14" 334 + source = "registry+https://github.com/rust-lang/crates.io-index" 335 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 336 + dependencies = [ 337 + "scopeguard", 338 + ] 339 + 340 + [[package]] 341 + name = "log" 342 + version = "0.4.29" 343 + source = "registry+https://github.com/rust-lang/crates.io-index" 344 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 345 + 346 + [[package]] 347 + name = "lru" 348 + version = "0.12.5" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 351 + dependencies = [ 352 + "hashbrown 0.15.5", 353 + ] 354 + 355 + [[package]] 356 + name = "memchr" 357 + version = "2.8.0" 358 + source = "registry+https://github.com/rust-lang/crates.io-index" 359 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 360 + 361 + [[package]] 362 + name = "miniz_oxide" 363 + version = "0.8.9" 364 + source = "registry+https://github.com/rust-lang/crates.io-index" 365 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 366 + dependencies = [ 367 + "adler2", 368 + "simd-adler32", 369 + ] 370 + 371 + [[package]] 372 + name = "mio" 373 + version = "1.2.0" 374 + source = "registry+https://github.com/rust-lang/crates.io-index" 375 + checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" 376 + dependencies = [ 377 + "libc", 378 + "log", 379 + "wasi", 380 + "windows-sys 0.61.2", 381 + ] 382 + 383 + [[package]] 384 + name = "num-conv" 385 + version = "0.2.1" 386 + source = "registry+https://github.com/rust-lang/crates.io-index" 387 + checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" 388 + 389 + [[package]] 390 + name = "once_cell" 391 + version = "1.21.4" 392 + source = "registry+https://github.com/rust-lang/crates.io-index" 393 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 394 + 395 + [[package]] 396 + name = "onig" 397 + version = "6.5.1" 398 + source = "registry+https://github.com/rust-lang/crates.io-index" 399 + checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" 400 + dependencies = [ 401 + "bitflags", 402 + "libc", 403 + "once_cell", 404 + "onig_sys", 405 + ] 406 + 407 + [[package]] 408 + name = "onig_sys" 409 + version = "69.9.1" 410 + source = "registry+https://github.com/rust-lang/crates.io-index" 411 + checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" 412 + dependencies = [ 413 + "cc", 414 + "pkg-config", 415 + ] 416 + 417 + [[package]] 418 + name = "parking_lot" 419 + version = "0.12.5" 420 + source = "registry+https://github.com/rust-lang/crates.io-index" 421 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 422 + dependencies = [ 423 + "lock_api", 424 + "parking_lot_core", 425 + ] 426 + 427 + [[package]] 428 + name = "parking_lot_core" 429 + version = "0.9.12" 430 + source = "registry+https://github.com/rust-lang/crates.io-index" 431 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 432 + dependencies = [ 433 + "cfg-if", 434 + "libc", 435 + "redox_syscall", 436 + "smallvec", 437 + "windows-link", 438 + ] 439 + 440 + [[package]] 441 + name = "paste" 442 + version = "1.0.15" 443 + source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 445 + 446 + [[package]] 447 + name = "pkg-config" 448 + version = "0.3.32" 449 + source = "registry+https://github.com/rust-lang/crates.io-index" 450 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 451 + 452 + [[package]] 453 + name = "plist" 454 + version = "1.8.0" 455 + source = "registry+https://github.com/rust-lang/crates.io-index" 456 + checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" 457 + dependencies = [ 458 + "base64", 459 + "indexmap", 460 + "quick-xml", 461 + "serde", 462 + "time", 463 + ] 464 + 465 + [[package]] 466 + name = "powerfmt" 467 + version = "0.2.0" 468 + source = "registry+https://github.com/rust-lang/crates.io-index" 469 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 470 + 471 + [[package]] 472 + name = "proc-macro2" 473 + version = "1.0.106" 474 + source = "registry+https://github.com/rust-lang/crates.io-index" 475 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 476 + dependencies = [ 477 + "unicode-ident", 478 + ] 479 + 480 + [[package]] 481 + name = "pulldown-cmark" 482 + version = "0.12.2" 483 + source = "registry+https://github.com/rust-lang/crates.io-index" 484 + checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" 485 + dependencies = [ 486 + "bitflags", 487 + "getopts", 488 + "memchr", 489 + "pulldown-cmark-escape", 490 + "unicase", 491 + ] 492 + 493 + [[package]] 494 + name = "pulldown-cmark-escape" 495 + version = "0.11.0" 496 + source = "registry+https://github.com/rust-lang/crates.io-index" 497 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 498 + 499 + [[package]] 500 + name = "quick-xml" 501 + version = "0.38.4" 502 + source = "registry+https://github.com/rust-lang/crates.io-index" 503 + checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" 504 + dependencies = [ 505 + "memchr", 506 + ] 507 + 508 + [[package]] 509 + name = "quote" 510 + version = "1.0.45" 511 + source = "registry+https://github.com/rust-lang/crates.io-index" 512 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 513 + dependencies = [ 514 + "proc-macro2", 515 + ] 516 + 517 + [[package]] 518 + name = "ratatui" 519 + version = "0.29.0" 520 + source = "registry+https://github.com/rust-lang/crates.io-index" 521 + checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 522 + dependencies = [ 523 + "bitflags", 524 + "cassowary", 525 + "compact_str", 526 + "crossterm", 527 + "indoc", 528 + "instability", 529 + "itertools", 530 + "lru", 531 + "paste", 532 + "strum", 533 + "unicode-segmentation", 534 + "unicode-truncate", 535 + "unicode-width 0.2.0", 536 + ] 537 + 538 + [[package]] 539 + name = "redox_syscall" 540 + version = "0.5.18" 541 + source = "registry+https://github.com/rust-lang/crates.io-index" 542 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 543 + dependencies = [ 544 + "bitflags", 545 + ] 546 + 547 + [[package]] 548 + name = "regex-syntax" 549 + version = "0.8.10" 550 + source = "registry+https://github.com/rust-lang/crates.io-index" 551 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 552 + 553 + [[package]] 554 + name = "rustix" 555 + version = "0.38.44" 556 + source = "registry+https://github.com/rust-lang/crates.io-index" 557 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 558 + dependencies = [ 559 + "bitflags", 560 + "errno", 561 + "libc", 562 + "linux-raw-sys", 563 + "windows-sys 0.59.0", 564 + ] 565 + 566 + [[package]] 567 + name = "rustversion" 568 + version = "1.0.22" 569 + source = "registry+https://github.com/rust-lang/crates.io-index" 570 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 571 + 572 + [[package]] 573 + name = "ryu" 574 + version = "1.0.23" 575 + source = "registry+https://github.com/rust-lang/crates.io-index" 576 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 577 + 578 + [[package]] 579 + name = "same-file" 580 + version = "1.0.6" 581 + source = "registry+https://github.com/rust-lang/crates.io-index" 582 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 583 + dependencies = [ 584 + "winapi-util", 585 + ] 586 + 587 + [[package]] 588 + name = "scopeguard" 589 + version = "1.2.0" 590 + source = "registry+https://github.com/rust-lang/crates.io-index" 591 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 592 + 593 + [[package]] 594 + name = "serde" 595 + version = "1.0.228" 596 + source = "registry+https://github.com/rust-lang/crates.io-index" 597 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 598 + dependencies = [ 599 + "serde_core", 600 + ] 601 + 602 + [[package]] 603 + name = "serde_core" 604 + version = "1.0.228" 605 + source = "registry+https://github.com/rust-lang/crates.io-index" 606 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 607 + dependencies = [ 608 + "serde_derive", 609 + ] 610 + 611 + [[package]] 612 + name = "serde_derive" 613 + version = "1.0.228" 614 + source = "registry+https://github.com/rust-lang/crates.io-index" 615 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 616 + dependencies = [ 617 + "proc-macro2", 618 + "quote", 619 + "syn", 620 + ] 621 + 622 + [[package]] 623 + name = "serde_json" 624 + version = "1.0.149" 625 + source = "registry+https://github.com/rust-lang/crates.io-index" 626 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 627 + dependencies = [ 628 + "itoa", 629 + "memchr", 630 + "serde", 631 + "serde_core", 632 + "zmij", 633 + ] 634 + 635 + [[package]] 636 + name = "shlex" 637 + version = "1.3.0" 638 + source = "registry+https://github.com/rust-lang/crates.io-index" 639 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 640 + 641 + [[package]] 642 + name = "signal-hook" 643 + version = "0.3.18" 644 + source = "registry+https://github.com/rust-lang/crates.io-index" 645 + checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 646 + dependencies = [ 647 + "libc", 648 + "signal-hook-registry", 649 + ] 650 + 651 + [[package]] 652 + name = "signal-hook-mio" 653 + version = "0.2.5" 654 + source = "registry+https://github.com/rust-lang/crates.io-index" 655 + checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 656 + dependencies = [ 657 + "libc", 658 + "mio", 659 + "signal-hook", 660 + ] 661 + 662 + [[package]] 663 + name = "signal-hook-registry" 664 + version = "1.4.8" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 667 + dependencies = [ 668 + "errno", 669 + "libc", 670 + ] 671 + 672 + [[package]] 673 + name = "simd-adler32" 674 + version = "0.3.9" 675 + source = "registry+https://github.com/rust-lang/crates.io-index" 676 + checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" 677 + 678 + [[package]] 679 + name = "smallvec" 680 + version = "1.15.1" 681 + source = "registry+https://github.com/rust-lang/crates.io-index" 682 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 683 + 684 + [[package]] 685 + name = "static_assertions" 686 + version = "1.1.0" 687 + source = "registry+https://github.com/rust-lang/crates.io-index" 688 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 689 + 690 + [[package]] 691 + name = "strsim" 692 + version = "0.11.1" 693 + source = "registry+https://github.com/rust-lang/crates.io-index" 694 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 695 + 696 + [[package]] 697 + name = "strum" 698 + version = "0.26.3" 699 + source = "registry+https://github.com/rust-lang/crates.io-index" 700 + checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 701 + dependencies = [ 702 + "strum_macros", 703 + ] 704 + 705 + [[package]] 706 + name = "strum_macros" 707 + version = "0.26.4" 708 + source = "registry+https://github.com/rust-lang/crates.io-index" 709 + checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 710 + dependencies = [ 711 + "heck", 712 + "proc-macro2", 713 + "quote", 714 + "rustversion", 715 + "syn", 716 + ] 717 + 718 + [[package]] 719 + name = "syn" 720 + version = "2.0.117" 721 + source = "registry+https://github.com/rust-lang/crates.io-index" 722 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 723 + dependencies = [ 724 + "proc-macro2", 725 + "quote", 726 + "unicode-ident", 727 + ] 728 + 729 + [[package]] 730 + name = "syntect" 731 + version = "5.3.0" 732 + source = "registry+https://github.com/rust-lang/crates.io-index" 733 + checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" 734 + dependencies = [ 735 + "bincode", 736 + "flate2", 737 + "fnv", 738 + "once_cell", 739 + "onig", 740 + "plist", 741 + "regex-syntax", 742 + "serde", 743 + "serde_derive", 744 + "serde_json", 745 + "thiserror", 746 + "walkdir", 747 + "yaml-rust", 748 + ] 749 + 750 + [[package]] 751 + name = "thiserror" 752 + version = "2.0.18" 753 + source = "registry+https://github.com/rust-lang/crates.io-index" 754 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 755 + dependencies = [ 756 + "thiserror-impl", 757 + ] 758 + 759 + [[package]] 760 + name = "thiserror-impl" 761 + version = "2.0.18" 762 + source = "registry+https://github.com/rust-lang/crates.io-index" 763 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 764 + dependencies = [ 765 + "proc-macro2", 766 + "quote", 767 + "syn", 768 + ] 769 + 770 + [[package]] 771 + name = "time" 772 + version = "0.3.47" 773 + source = "registry+https://github.com/rust-lang/crates.io-index" 774 + checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" 775 + dependencies = [ 776 + "deranged", 777 + "itoa", 778 + "num-conv", 779 + "powerfmt", 780 + "serde_core", 781 + "time-core", 782 + "time-macros", 783 + ] 784 + 785 + [[package]] 786 + name = "time-core" 787 + version = "0.1.8" 788 + source = "registry+https://github.com/rust-lang/crates.io-index" 789 + checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" 790 + 791 + [[package]] 792 + name = "time-macros" 793 + version = "0.2.27" 794 + source = "registry+https://github.com/rust-lang/crates.io-index" 795 + checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" 796 + dependencies = [ 797 + "num-conv", 798 + "time-core", 799 + ] 800 + 801 + [[package]] 802 + name = "unicase" 803 + version = "2.9.0" 804 + source = "registry+https://github.com/rust-lang/crates.io-index" 805 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 806 + 807 + [[package]] 808 + name = "unicode-ident" 809 + version = "1.0.24" 810 + source = "registry+https://github.com/rust-lang/crates.io-index" 811 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 812 + 813 + [[package]] 814 + name = "unicode-segmentation" 815 + version = "1.12.0" 816 + source = "registry+https://github.com/rust-lang/crates.io-index" 817 + checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 818 + 819 + [[package]] 820 + name = "unicode-truncate" 821 + version = "1.1.0" 822 + source = "registry+https://github.com/rust-lang/crates.io-index" 823 + checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 824 + dependencies = [ 825 + "itertools", 826 + "unicode-segmentation", 827 + "unicode-width 0.1.14", 828 + ] 829 + 830 + [[package]] 831 + name = "unicode-width" 832 + version = "0.1.14" 833 + source = "registry+https://github.com/rust-lang/crates.io-index" 834 + checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 835 + 836 + [[package]] 837 + name = "unicode-width" 838 + version = "0.2.0" 839 + source = "registry+https://github.com/rust-lang/crates.io-index" 840 + checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 841 + 842 + [[package]] 843 + name = "walkdir" 844 + version = "2.5.0" 845 + source = "registry+https://github.com/rust-lang/crates.io-index" 846 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 847 + dependencies = [ 848 + "same-file", 849 + "winapi-util", 850 + ] 851 + 852 + [[package]] 853 + name = "wasi" 854 + version = "0.11.1+wasi-snapshot-preview1" 855 + source = "registry+https://github.com/rust-lang/crates.io-index" 856 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 857 + 858 + [[package]] 859 + name = "winapi" 860 + version = "0.3.9" 861 + source = "registry+https://github.com/rust-lang/crates.io-index" 862 + checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 863 + dependencies = [ 864 + "winapi-i686-pc-windows-gnu", 865 + "winapi-x86_64-pc-windows-gnu", 866 + ] 867 + 868 + [[package]] 869 + name = "winapi-i686-pc-windows-gnu" 870 + version = "0.4.0" 871 + source = "registry+https://github.com/rust-lang/crates.io-index" 872 + checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 873 + 874 + [[package]] 875 + name = "winapi-util" 876 + version = "0.1.11" 877 + source = "registry+https://github.com/rust-lang/crates.io-index" 878 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 879 + dependencies = [ 880 + "windows-sys 0.61.2", 881 + ] 882 + 883 + [[package]] 884 + name = "winapi-x86_64-pc-windows-gnu" 885 + version = "0.4.0" 886 + source = "registry+https://github.com/rust-lang/crates.io-index" 887 + checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 888 + 889 + [[package]] 890 + name = "windows-link" 891 + version = "0.2.1" 892 + source = "registry+https://github.com/rust-lang/crates.io-index" 893 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 894 + 895 + [[package]] 896 + name = "windows-sys" 897 + version = "0.59.0" 898 + source = "registry+https://github.com/rust-lang/crates.io-index" 899 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 900 + dependencies = [ 901 + "windows-targets", 902 + ] 903 + 904 + [[package]] 905 + name = "windows-sys" 906 + version = "0.61.2" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 909 + dependencies = [ 910 + "windows-link", 911 + ] 912 + 913 + [[package]] 914 + name = "windows-targets" 915 + version = "0.52.6" 916 + source = "registry+https://github.com/rust-lang/crates.io-index" 917 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 918 + dependencies = [ 919 + "windows_aarch64_gnullvm", 920 + "windows_aarch64_msvc", 921 + "windows_i686_gnu", 922 + "windows_i686_gnullvm", 923 + "windows_i686_msvc", 924 + "windows_x86_64_gnu", 925 + "windows_x86_64_gnullvm", 926 + "windows_x86_64_msvc", 927 + ] 928 + 929 + [[package]] 930 + name = "windows_aarch64_gnullvm" 931 + version = "0.52.6" 932 + source = "registry+https://github.com/rust-lang/crates.io-index" 933 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 934 + 935 + [[package]] 936 + name = "windows_aarch64_msvc" 937 + version = "0.52.6" 938 + source = "registry+https://github.com/rust-lang/crates.io-index" 939 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 940 + 941 + [[package]] 942 + name = "windows_i686_gnu" 943 + version = "0.52.6" 944 + source = "registry+https://github.com/rust-lang/crates.io-index" 945 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 946 + 947 + [[package]] 948 + name = "windows_i686_gnullvm" 949 + version = "0.52.6" 950 + source = "registry+https://github.com/rust-lang/crates.io-index" 951 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 952 + 953 + [[package]] 954 + name = "windows_i686_msvc" 955 + version = "0.52.6" 956 + source = "registry+https://github.com/rust-lang/crates.io-index" 957 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 958 + 959 + [[package]] 960 + name = "windows_x86_64_gnu" 961 + version = "0.52.6" 962 + source = "registry+https://github.com/rust-lang/crates.io-index" 963 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 964 + 965 + [[package]] 966 + name = "windows_x86_64_gnullvm" 967 + version = "0.52.6" 968 + source = "registry+https://github.com/rust-lang/crates.io-index" 969 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 970 + 971 + [[package]] 972 + name = "windows_x86_64_msvc" 973 + version = "0.52.6" 974 + source = "registry+https://github.com/rust-lang/crates.io-index" 975 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 976 + 977 + [[package]] 978 + name = "yaml-rust" 979 + version = "0.4.5" 980 + source = "registry+https://github.com/rust-lang/crates.io-index" 981 + checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 982 + dependencies = [ 983 + "linked-hash-map", 984 + ] 985 + 986 + [[package]] 987 + name = "zmij" 988 + version = "1.0.21" 989 + source = "registry+https://github.com/rust-lang/crates.io-index" 990 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+18
Cargo.toml
··· 1 + [package] 2 + name = "leaf" 3 + version = "1.0.0" 4 + edition = "2021" 5 + description = "A friendly terminal Markdown previewer" 6 + license = "MIT" 7 + 8 + [[bin]] 9 + name = "leaf" 10 + path = "src/main.rs" 11 + 12 + [dependencies] 13 + ratatui = "0.29" 14 + crossterm = "0.28" 15 + pulldown-cmark = "0.12" 16 + syntect = "5.2" 17 + anyhow = "1.0" 18 + unicode-width = "0.1"
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Rivo Link <rivo.link@gmail.com> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+97
README.md
··· 1 + <p align="center"> 2 + <img src="images/logo-wordmark.svg" alt="leaf" width="360" /> 3 + </p> 4 + 5 + <p align="center"> 6 + Terminal Markdown previewer — GUI-like experience. 7 + </p> 8 + 9 + ## Build & install 10 + 11 + Build the release binary: 12 + 13 + ```bash 14 + cargo build --release 15 + ``` 16 + 17 + Create a local bin directory if needed and symlink `leaf` into it: 18 + 19 + ```bash 20 + mkdir -p ~/.local/bin 21 + ln -sf "$(pwd)/target/release/leaf" ~/.local/bin/leaf 22 + ``` 23 + 24 + If `~/.local/bin` is not already on your `PATH`, add it to `~/.bashrc` or `~/.zshrc`: 25 + 26 + ```bash 27 + export PATH="$HOME/.local/bin:$PATH" 28 + ``` 29 + 30 + Check the installed version: 31 + 32 + ```bash 33 + leaf --version 34 + ``` 35 + 36 + ## Usage 37 + 38 + ```bash 39 + # Preview a file 40 + leaf TESTING.md 41 + 42 + # Watch mode — reloads automatically on save 43 + leaf --watch TESTING.md 44 + leaf -w TESTING.md 45 + 46 + # Open a dash-prefixed filename 47 + leaf -- -notes.md 48 + 49 + # Pipe from stdin 50 + claude "explain Rust lifetimes" | leaf 51 + cat TESTING.md | leaf 52 + ``` 53 + 54 + ## Keybindings 55 + 56 + | Key | Action | 57 + |---|---| 58 + | `j` / `↓` | Scroll down | 59 + | `k` / `↑` | Scroll up | 60 + | `d` / PgDn | Page down (20 lines) | 61 + | `u` / PgUp | Page up (20 lines) | 62 + | `g` / Home | Top | 63 + | `G` / End | Bottom | 64 + | `t` | Toggle TOC sidebar | 65 + | `1`–`9` | Jump to TOC section N | 66 + | `/` | Search | 67 + | `n` / `N` | Next / prev match | 68 + | `r` | Force reload (watch mode) | 69 + | `q` | Quit | 70 + 71 + ## Features 72 + 73 + - ✅ **Watch mode** `--watch` / `-w` — reloads every 250ms, with `⟳ reloaded` flash feedback 74 + - ✅ Syntax highlighting (200+ languages, syntect) 75 + - ✅ Unicode box-drawing tables with left / center / right alignment 76 + - ✅ TOC sidebar with active section tracking and two-level navigation 77 + - ✅ Search with match highlighting and `n` / `N` 78 + - ✅ Code blocks `╭─ lang ───╮` 79 + - ✅ Bold, italic, strikethrough, blockquotes, lists, and horizontal rules 80 + - ✅ YAML frontmatter is ignored in both preview and TOC 81 + - ✅ Native stdin input 82 + 83 + ## Typical AI Workflow 84 + 85 + ```bash 86 + # Terminal 1: generate the file 87 + aichat "..." > notes.md 88 + 89 + # Terminal 2: live watch 90 + leaf --watch notes.md 91 + ``` 92 + 93 + ## Roadmap 94 + 95 + - [ ] Themes (light / custom) 96 + - [ ] Copy code block `y` 97 + - [ ] Improve search performance on large files
+256
TESTING.md
··· 1 + # Testing 2 + 3 + This project has two kinds of testing: 4 + 5 + - automated unit tests with `cargo test` 6 + - manual end-to-end checks using the fixture in this file 7 + 8 + ## Quick Start 9 + 10 + Run automated tests: 11 + 12 + ```bash 13 + cargo test 14 + ``` 15 + 16 + Open the manual fixture from a file: 17 + 18 + ```bash 19 + leaf TESTING.md 20 + ``` 21 + 22 + Open it in watch mode: 23 + 24 + ```bash 25 + leaf --watch TESTING.md 26 + ``` 27 + 28 + Open it through stdin: 29 + 30 + ```bash 31 + cat TESTING.md | leaf 32 + ``` 33 + 34 + ## Manual Coverage 35 + 36 + Use the fixture in the `Manual Fixture` section of this file to verify the current feature set. 37 + 38 + ### Rendering 39 + 40 + Confirm these render correctly: 41 + 42 + - headings and TOC entries 43 + - paragraphs with normal spacing 44 + - bold, italic, strikethrough, inline code, and links 45 + - blockquotes with multiple paragraphs 46 + - unordered, loose, nested, and ordered lists 47 + - horizontal rules 48 + - tables with left, center, and right alignment 49 + - fenced code blocks with language labels 50 + - wide characters such as `東京` 51 + 52 + ### Navigation And Search 53 + 54 + Use these keys while viewing the fixture: 55 + 56 + - `j`, `k`, `d`, `u` for scrolling 57 + - `g`, `G` for top and bottom 58 + - `t` to toggle the TOC 59 + - `1` through `9` to jump to TOC entries 60 + - `/` to search for `tokyo-signal` 61 + - `n` and `N` to move through search matches 62 + 63 + ### Watch Mode 64 + 65 + While running `leaf --watch TESTING.md`: 66 + 67 + - edit one of the repeated search terms 68 + - confirm the content reloads automatically 69 + - confirm the `⟳ reloaded` indicator appears 70 + - press `r` to force a reload manually 71 + 72 + ### Stdin Mode 73 + 74 + While running `cat TESTING.md | leaf`: 75 + 76 + - confirm the content matches file-backed rendering 77 + - confirm watch mode is not available 78 + 79 + ### Startup And Error Handling 80 + 81 + Run these checks manually: 82 + 83 + ```bash 84 + leaf --watch 85 + leaf /path/that/does/not/exist.md 86 + leaf --help 87 + ``` 88 + 89 + Confirm each command exits cleanly and does not leave the terminal in raw mode or an alternate screen. 90 + 91 + ## Notes 92 + 93 + The fixture intentionally includes repeated search terms, loose list items, ordered lists starting at non-`1` values, tables, code blocks, and wide characters because those are easy places for terminal Markdown renderers to regress. 94 + 95 + ## Manual Fixture 96 + 97 + Open this file in `leaf` and use the content below as the end-to-end render sample. 98 + 99 + Search terms: 100 + 101 + - `tokyo-signal` 102 + - `watch-reload-marker` 103 + - `table-edge-check` 104 + - `unicode-width-check` 105 + 106 + ### Navigation 107 + 108 + This section exists to populate the TOC and provide enough content for scrolling and search. 109 + 110 + tokyo-signal appears here once. 111 + 112 + #### Repeated Search Block 113 + 114 + tokyo-signal appears here twice. 115 + 116 + tokyo-signal appears here three times. 117 + 118 + watch-reload-marker appears here once. 119 + 120 + watch-reload-marker appears here twice. 121 + 122 + ### Paragraph Styles 123 + 124 + Plain paragraph text should render with the default body style and spacing. 125 + 126 + This line mixes **bold**, *italic*, ~~strikethrough~~, and `inline code` in a single paragraph. 127 + 128 + This paragraph also includes a [link to Rust](https://www.rust-lang.org/) so link styling and the leading link marker can be checked. 129 + 130 + ### Blockquote 131 + 132 + > This is a blockquote with *emphasis* and `inline code`. 133 + > 134 + > The second quoted paragraph ensures paragraph flushing still keeps the quote prefix. 135 + > 136 + > unicode-width-check is present here too. 137 + 138 + ### Lists 139 + 140 + #### Unordered Tight List 141 + 142 + - first bullet 143 + - second bullet 144 + - third bullet with `inline code` 145 + 146 + #### Unordered Loose List 147 + 148 + - first loose item 149 + 150 + - second loose item after a blank line 151 + 152 + - third loose item with two paragraphs 153 + 154 + This continuation paragraph should keep list structure without repeating the bullet. 155 + 156 + #### Nested List 157 + 158 + - outer one 159 + - inner one 160 + - inner two 161 + - deeper item 162 + - outer two 163 + 164 + #### Ordered List 165 + 166 + 3. item three 167 + 4. item four 168 + 5. item five 169 + 170 + #### Ordered Loose List 171 + 172 + 7. item seven 173 + 174 + 8. item eight with a second paragraph 175 + 176 + This continuation paragraph should align with item eight instead of relying on the numeric marker. 177 + 178 + ### Rules 179 + 180 + The horizontal rule below should span cleanly. 181 + 182 + --- 183 + 184 + The document should continue normally after the rule. 185 + 186 + ### Tables 187 + 188 + | Name | Align Left | Center | Right | 189 + | --- | :--- | :---: | ---: | 190 + | Alpha | left | mid | 12 | 191 + | Beta | table-edge-check | centered | 345 | 192 + | Tokyo | unicode-width-check 東京 | wide | 6789 | 193 + | Tabs | tab value | cell | 10 | 194 + 195 + The table above is intended to check borders, alignment, tab expansion, and wide-character handling. 196 + 197 + ### Code Blocks 198 + 199 + ```rust 200 + fn main() { 201 + let city = "東京"; 202 + println!("tokyo-signal: {city}"); 203 + } 204 + ``` 205 + 206 + ```bash 207 + printf '%s\n' "watch-reload-marker" 208 + leaf --watch TESTING.md 209 + ``` 210 + 211 + ```yaml 212 + search: 213 + primary: tokyo-signal 214 + secondary: unicode-width-check 215 + ``` 216 + 217 + ### Wide Characters 218 + 219 + These lines are here to verify width calculations: 220 + 221 + - 東京 222 + - café 223 + - naïve 224 + 225 + ### Long Scroll Area 226 + 227 + Line 01: scrolling sample 228 + Line 02: scrolling sample 229 + Line 03: scrolling sample 230 + Line 04: scrolling sample 231 + Line 05: scrolling sample 232 + Line 06: scrolling sample 233 + Line 07: scrolling sample 234 + Line 08: scrolling sample 235 + Line 09: scrolling sample 236 + Line 10: scrolling sample 237 + Line 11: scrolling sample 238 + Line 12: scrolling sample 239 + Line 13: scrolling sample 240 + Line 14: scrolling sample 241 + Line 15: scrolling sample 242 + Line 16: scrolling sample 243 + Line 17: scrolling sample 244 + Line 18: scrolling sample 245 + Line 19: scrolling sample 246 + Line 20: scrolling sample 247 + Line 21: scrolling sample 248 + Line 22: scrolling sample 249 + Line 23: scrolling sample 250 + Line 24: scrolling sample 251 + Line 25: scrolling sample 252 + Line 26: scrolling sample 253 + Line 27: scrolling sample 254 + Line 28: scrolling sample 255 + Line 29: scrolling sample 256 + Line 30: scrolling sample
+57
images/logo-wordmark.svg
··· 1 + <?xml version="1.0" standalone="no"?> 2 + <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" 3 + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> 4 + <svg version="1.0" xmlns="http://www.w3.org/2000/svg" 5 + width="993.090215pt" height="313.500000pt" viewBox="885.080403 592.500000 993.090215 313.500000" 6 + preserveAspectRatio="xMidYMid meet"> 7 + 8 + <defs> 9 + <mask id="cutouts-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="27520" height="15360"> 10 + <rect x="0" y="0" width="27520" height="15360" fill="white"/> 11 + <!-- Leaf veins/stem cut out --> 12 + <path fill="black" d="M10910 8628 l-295 -301 -5 244 -5 244 -62 3 -63 3 0 -316 0 -316 13 + -337 -340 -338 -341 1 382 c1 210 -1 391 -4 402 -4 16 -12 19 -66 16 l-61 -3 14 + -3 -469 -2 -470 -92 -95 c-106 -110 -294 -307 -352 -369 -39 -41 -39 -42 -21 15 + -62 10 -11 34 -30 52 -41 l34 -21 127 134 c71 73 179 187 241 253 l113 120 16 + 486 5 487 5 0 65 0 65 -412 5 -412 5 325 333 326 332 256 -1 c142 0 285 0 320 17 + 0 l62 1 0 65 0 66 -249 -3 c-230 -3 -248 -2 -240 14 5 9 135 145 289 301 154 18 + 157 280 292 280 299 0 13 -69 88 -80 88 -3 0 -138 -136 -300 -302z"/> 19 + <!-- Letter counters cut out --> 20 + <path fill="black" d="M14693 8099 c-63 -24 -132 -90 -162 -155 -23 -49 -49 -219 -38 -248 21 + 6 -14 47 -16 352 -16 l345 0 0 78 c0 177 -78 297 -224 346 -71 23 -206 21 22 + -273 -5z"/> 23 + <path fill="black" d="M16396 7488 c-92 -5 -118 -11 -165 -35 -134 -68 -179 -227 -105 -364 24 + 33 -60 84 -101 156 -125 71 -23 245 -16 308 12 69 32 145 107 180 177 30 62 25 + 31 64 28 202 l-3 140 -145 0 c-80 0 -194 -3 -254 -7z"/> 26 + </mask> 27 + </defs> 28 + 29 + <g transform="translate(0.000000,1536.000000) scale(0.100000,-0.100000)" stroke="none"> 30 + <!-- Green leaf + text, with transparent cutouts for veins and counters --> 31 + <path mask="url(#cutouts-mask)" fill="#5fc894" d="M11705 9060 c-1 -358 -5 -433 -46 -714 -43 -297 -138 -629 -243 32 + -851 -211 -445 -519 -734 -922 -868 -230 -76 -457 -95 -672 -58 -189 33 -373 33 + 97 -472 163 l-45 30 -33 -38 c-44 -51 -148 -209 -200 -304 -66 -120 -56 -115 34 + -134 -69 -38 22 -68 46 -68 53 0 23 128 226 221 350 l90 119 -25 37 c-142 215 35 + -226 564 -196 820 44 391 182 676 449 932 418 402 1091 640 2121 751 19 2 67 36 + 4 105 3 l70 -1 0 -355z m1535 -967 c0 -412 3 -779 6 -818 11 -139 56 -219 143 37 + -258 40 -18 67 -20 266 -19 122 0 227 -3 234 -7 8 -5 11 -40 9 -117 l-3 -109 38 + -280 0 c-266 0 -283 1 -346 23 -133 46 -226 136 -271 261 l-23 66 -3 748 -3 39 + 747 -244 0 -245 0 0 115 0 115 380 0 380 0 0 -747z m5520 633 l0 -114 -227 -4 40 + c-220 -3 -230 -4 -278 -28 -81 -40 -95 -77 -102 -260 l-6 -150 301 0 302 0 0 41 + -115 0 -115 -297 -2 -298 -3 -3 -587 -2 -588 -130 0 -130 0 0 590 0 590 -225 42 + 0 -225 0 0 115 0 115 224 0 224 0 4 178 c3 160 6 183 28 241 29 73 92 150 155 43 + 186 88 52 167 64 438 64 l247 1 0 -114z m-3755 -401 c147 -31 282 -121 356 44 + -236 17 -27 43 -84 57 -127 23 -70 26 -97 30 -280 4 -168 2 -203 -9 -202 -8 2 45 + -225 2 -483 1 l-469 -1 7 -132 c6 -109 11 -142 31 -185 66 -146 165 -204 345 46 + -204 141 -1 243 50 289 144 l21 42 135 0 136 1 -7 -31 c-25 -122 -136 -256 47 + -264 -318 -109 -54 -199 -70 -345 -63 -198 9 -321 55 -438 165 -75 71 -135 48 + 176 -159 281 -18 75 -17 632 1 710 33 142 136 291 246 356 144 84 348 115 520 49 + 79z m1690 -10 c101 -28 166 -65 233 -131 72 -72 103 -133 126 -246 13 -65 16 50 + -167 16 -628 l0 -550 -125 0 -125 0 -1 68 c0 66 -8 154 -13 160 -2 1 -26 -29 51 + -54 -67 -57 -76 -123 -125 -217 -161 -50 -20 -80 -23 -185 -24 -76 -1 -147 4 52 + -180 13 -142 35 -277 144 -319 256 -59 159 -45 338 38 458 44 65 101 112 183 53 + 150 120 58 197 69 482 69 l248 1 -4 111 c-3 97 -7 118 -31 168 -55 111 -162 54 + 164 -316 156 -144 -9 -226 -58 -272 -165 l-19 -44 -132 7 c-73 3 -134 7 -135 55 + 8 -5 4 26 95 42 127 77 152 217 243 435 283 51 9 267 -4 325 -19z"/> 56 + </g> 57 + </svg>
+23
images/logo.svg
··· 1 + <?xml version="1.0" standalone="no"?> 2 + <svg xmlns="http://www.w3.org/2000/svg" width="2752" height="1536" viewBox="885 592 288 314" preserveAspectRatio="xMidYMid meet"> 3 + <defs> 4 + <mask id="leaf-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="27520" height="15360"> 5 + <rect x="0" y="0" width="27520" height="15360" fill="white"/> 6 + <path fill="black" d="M10910 8628 l-295 -301 -5 244 -5 244 -62 3 -63 3 0 -316 0 -316 7 + -337 -340 -338 -341 1 382 c1 210 -1 391 -4 402 -4 16 -12 19 -66 16 l-61 -3 8 + -3 -469 -2 -470 -92 -95 c-106 -110 -294 -307 -352 -369 -39 -41 -39 -42 -21 9 + -62 10 -11 34 -30 52 -41 l34 -21 127 134 c71 73 179 187 241 253 l113 120 10 + 486 5 487 5 0 65 0 65 -412 5 -412 5 325 333 326 332 256 -1 c142 0 285 0 320 11 + 0 l62 1 0 65 0 66 -249 -3 c-230 -3 -248 -2 -240 14 5 9 135 145 289 301 154 12 + 157 280 292 280 299 0 13 -69 88 -80 88 -3 0 -138 -136 -300 -302z"/> 13 + </mask> 14 + </defs> 15 + <g transform="translate(0,1536) scale(0.1,-0.1)" stroke="none"> 16 + <path mask="url(#leaf-mask)" fill="#5fc894" d="M11705 9060 c-1 -358 -5 -433 -46 -714 -43 -297 -138 -629 -243 17 + -851 -211 -445 -519 -734 -922 -868 -230 -76 -457 -95 -672 -58 -189 33 -373 18 + 97 -472 163 l-45 30 -33 -38 c-44 -51 -148 -209 -200 -304 -66 -120 -56 -115 19 + -134 -69 -38 22 -68 46 -68 53 0 23 128 226 221 350 l90 119 -25 37 c-142 215 20 + -226 564 -196 820 44 391 182 676 449 932 418 402 1091 640 2121 751 19 2 67 21 + 4 105 3 l70 -1 0 -355z"/> 22 + </g> 23 + </svg>
+36
images/wordmark.svg
··· 1 + <?xml version="1.0" standalone="no"?> 2 + <svg xmlns="http://www.w3.org/2000/svg" width="2752" height="1536" viewBox="1245 650 633 215" preserveAspectRatio="xMidYMid meet"> 3 + <defs> 4 + <mask id="text-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="27520" height="15360"> 5 + <rect x="0" y="0" width="27520" height="15360" fill="white"/> 6 + <path fill="black" d="M14693 8099 c-63 -24 -132 -90 -162 -155 -23 -49 -49 -219 -38 -248 7 + 6 -14 47 -16 352 -16 l345 0 0 78 c0 177 -78 297 -224 346 -71 23 -206 21 8 + -273 -5z"/> 9 + <path fill="black" d="M16396 7488 c-92 -5 -118 -11 -165 -35 -134 -68 -179 -227 -105 -364 10 + 33 -60 84 -101 156 -125 71 -23 245 -16 308 12 69 32 145 107 180 177 30 62 11 + 31 64 28 202 l-3 140 -145 0 c-80 0 -194 -3 -254 -7z"/> 12 + </mask> 13 + </defs> 14 + <g transform="translate(0,1536) scale(0.1,-0.1)" stroke="none"> 15 + <path mask="url(#text-mask)" fill="#5fc894" d="M13240 8093 c0 -412 3 -779 6 -818 11 -139 56 -219 143 16 + -258 40 -18 67 -20 266 -19 122 0 227 -3 234 -7 8 -5 11 -40 9 -117 l-3 -109 17 + -280 0 c-266 0 -283 1 -346 23 -133 46 -226 136 -271 261 l-23 66 -3 748 -3 18 + 747 -244 0 -245 0 0 115 0 115 380 0 380 0 0 -747z m5520 633 l0 -114 -227 -4 19 + c-220 -3 -230 -4 -278 -28 -81 -40 -95 -77 -102 -260 l-6 -150 301 0 302 0 0 20 + -115 0 -115 -297 -2 -298 -3 -3 -587 -2 -588 -130 0 -130 0 0 590 0 590 -225 21 + 0 -225 0 0 115 0 115 224 0 224 0 4 178 c3 160 6 183 28 241 29 73 92 150 155 22 + 186 88 52 167 64 438 64 l247 1 0 -114z m-3755 -401 c147 -31 282 -121 356 23 + -236 17 -27 43 -84 57 -127 23 -70 26 -97 30 -280 4 -168 2 -203 -9 -202 -8 2 24 + -225 2 -483 1 l-469 -1 7 -132 c6 -109 11 -142 31 -185 66 -146 165 -204 345 25 + -204 141 -1 243 50 289 144 l21 42 135 0 136 1 -7 -31 c-25 -122 -136 -256 26 + -264 -318 -109 -54 -199 -70 -345 -63 -198 9 -321 55 -438 165 -75 71 -135 27 + 176 -159 281 -18 75 -17 632 1 710 33 142 136 291 246 356 144 84 348 115 520 28 + 79z m1690 -10 c101 -28 166 -65 233 -131 72 -72 103 -133 126 -246 13 -65 16 29 + -167 16 -628 l0 -550 -125 0 -125 0 -1 68 c0 66 -8 154 -13 160 -2 1 -26 -29 30 + -54 -67 -57 -76 -123 -125 -217 -161 -50 -20 -80 -23 -185 -24 -76 -1 -147 4 31 + -180 13 -142 35 -277 144 -319 256 -59 159 -45 338 38 458 44 65 101 112 183 32 + 150 120 58 197 69 482 69 l248 1 -4 111 c-3 97 -7 118 -31 168 -55 111 -162 33 + 164 -316 156 -144 -9 -226 -58 -272 -165 l-19 -44 -132 7 c-73 3 -134 7 -135 34 + 8 -5 4 26 95 42 127 77 152 217 243 435 283 51 9 267 -4 325 -19z"/> 35 + </g> 36 + </svg>
+471
src/app.rs
··· 1 + use crate::{ 2 + markdown::{build_plain_lines, hash_file_contents, hash_str, parse_markdown, read_file_state}, 3 + render::{build_status_bar, build_toc_line_with_index, toc_header_line}, 4 + }; 5 + use ratatui::text::Line; 6 + use std::{ 7 + path::PathBuf, 8 + time::{Duration, Instant, SystemTime}, 9 + }; 10 + use syntect::{highlighting::Theme, parsing::SyntaxSet}; 11 + 12 + #[derive(Clone)] 13 + pub(crate) struct TocEntry { 14 + pub(crate) level: u8, 15 + pub(crate) title: String, 16 + pub(crate) line: usize, 17 + } 18 + 19 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 20 + pub(crate) struct FileState { 21 + pub(crate) modified: SystemTime, 22 + pub(crate) len: u64, 23 + } 24 + 25 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 26 + pub(crate) enum FileChange { 27 + Metadata(FileState), 28 + Content(FileState), 29 + } 30 + 31 + #[derive(Clone, Debug, PartialEq, Eq)] 32 + pub(crate) struct StatusCacheKey { 33 + pub(crate) pct: u16, 34 + pub(crate) search_mode: bool, 35 + pub(crate) search_draft_hash: u64, 36 + pub(crate) search_query_hash: u64, 37 + pub(crate) search_draft_len: usize, 38 + pub(crate) search_query_len: usize, 39 + pub(crate) search_match_count: usize, 40 + pub(crate) search_idx: usize, 41 + pub(crate) watch: bool, 42 + pub(crate) flash_active: bool, 43 + } 44 + 45 + pub(crate) struct App { 46 + pub(crate) lines: Vec<Line<'static>>, 47 + pub(crate) plain_lines: Vec<String>, 48 + pub(crate) folded_plain_lines: Option<Vec<String>>, 49 + pub(crate) scroll: usize, 50 + pub(crate) toc: Vec<TocEntry>, 51 + pub(crate) toc_visible: bool, 52 + pub(crate) search_mode: bool, 53 + pub(crate) search_draft: String, 54 + pub(crate) search_query: String, 55 + pub(crate) search_matches: Vec<usize>, 56 + pub(crate) search_idx: usize, 57 + pub(crate) debug_input: bool, 58 + pub(crate) filename: String, 59 + pub(crate) watch: bool, 60 + pub(crate) filepath: Option<PathBuf>, 61 + pub(crate) last_file_state: Option<FileState>, 62 + pub(crate) last_content_hash: u64, 63 + pub(crate) last_hash_check: Option<Instant>, 64 + pub(crate) reload_flash: Option<Instant>, 65 + pub(crate) highlighted_line_cache: Option<(usize, Line<'static>)>, 66 + pub(crate) toc_display_lines: Vec<Line<'static>>, 67 + pub(crate) toc_header_line: Line<'static>, 68 + pub(crate) toc_active_idx: Option<usize>, 69 + pub(crate) status_line: Line<'static>, 70 + pub(crate) status_cache_key: Option<StatusCacheKey>, 71 + } 72 + 73 + impl App { 74 + pub(crate) fn new( 75 + lines: Vec<Line<'static>>, 76 + toc: Vec<TocEntry>, 77 + filename: String, 78 + debug_input: bool, 79 + watch: bool, 80 + filepath: Option<PathBuf>, 81 + last_file_state: Option<FileState>, 82 + ) -> Self { 83 + let plain_lines = build_plain_lines(&lines); 84 + let mut app = Self { 85 + lines, 86 + plain_lines, 87 + folded_plain_lines: None, 88 + scroll: 0, 89 + toc, 90 + toc_visible: false, 91 + search_mode: false, 92 + search_draft: String::new(), 93 + search_query: String::new(), 94 + search_matches: vec![], 95 + search_idx: 0, 96 + debug_input, 97 + filename, 98 + watch, 99 + filepath, 100 + last_file_state, 101 + last_content_hash: 0, 102 + last_hash_check: None, 103 + reload_flash: None, 104 + highlighted_line_cache: None, 105 + toc_display_lines: Vec::new(), 106 + toc_header_line: toc_header_line(), 107 + toc_active_idx: None, 108 + status_line: Line::default(), 109 + status_cache_key: None, 110 + }; 111 + app.refresh_static_caches(); 112 + app 113 + } 114 + 115 + pub(crate) fn set_last_content_hash(&mut self, last_content_hash: u64) { 116 + self.last_content_hash = last_content_hash; 117 + } 118 + 119 + pub(crate) fn total(&self) -> usize { 120 + self.lines.len() 121 + } 122 + 123 + pub(crate) fn replace_content(&mut self, lines: Vec<Line<'static>>, toc: Vec<TocEntry>) { 124 + self.plain_lines = build_plain_lines(&lines); 125 + self.folded_plain_lines = None; 126 + self.lines = lines; 127 + self.toc = toc; 128 + self.highlighted_line_cache = None; 129 + self.refresh_static_caches(); 130 + } 131 + 132 + pub(crate) fn active_highlight_line(&self) -> Option<usize> { 133 + if self.search_matches.is_empty() { 134 + None 135 + } else { 136 + Some(self.search_matches[self.search_idx]) 137 + } 138 + } 139 + 140 + pub(crate) fn active_toc_index(&self) -> Option<usize> { 141 + let hide_single_h1 = should_hide_single_h1(&self.toc); 142 + let mut first_visible = None; 143 + let mut active = None; 144 + for (idx, entry) in self 145 + .toc 146 + .iter() 147 + .enumerate() 148 + .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 149 + { 150 + if first_visible.is_none() { 151 + first_visible = Some((idx, entry.line)); 152 + } 153 + if entry.line > self.scroll { 154 + break; 155 + } 156 + active = Some(idx); 157 + } 158 + 159 + let (first_idx, first_line) = first_visible?; 160 + if self.scroll < first_line { 161 + Some(first_idx) 162 + } else { 163 + active.or(Some(first_idx)) 164 + } 165 + } 166 + 167 + pub(crate) fn folded_plain_lines(&mut self) -> &[String] { 168 + if self.folded_plain_lines.is_none() { 169 + self.folded_plain_lines = Some( 170 + self.plain_lines 171 + .iter() 172 + .map(|line| line.to_lowercase()) 173 + .collect(), 174 + ); 175 + } 176 + self.folded_plain_lines.as_deref().unwrap_or(&[]) 177 + } 178 + 179 + pub(crate) fn refresh_highlighted_line_cache(&mut self, line_idx: usize) -> Option<()> { 180 + let needs_refresh = self 181 + .highlighted_line_cache 182 + .as_ref() 183 + .map(|(cached_idx, _)| *cached_idx != line_idx) 184 + .unwrap_or(true); 185 + if needs_refresh { 186 + let line = self.lines.get(line_idx)?; 187 + self.highlighted_line_cache = Some((line_idx, crate::markdown::highlight_line(line))); 188 + } 189 + Some(()) 190 + } 191 + 192 + pub(crate) fn refresh_toc_cache(&mut self) { 193 + let hide_single_h1 = should_hide_single_h1(&self.toc); 194 + let promote_h2_root = should_promote_h2_when_no_h1(&self.toc); 195 + let active_idx = self.active_toc_index(); 196 + if self.toc_active_idx == active_idx && !self.toc_display_lines.is_empty() { 197 + return; 198 + } 199 + 200 + self.toc_active_idx = active_idx; 201 + let mut top_level_index = 0usize; 202 + self.toc_display_lines = self 203 + .toc 204 + .iter() 205 + .enumerate() 206 + .filter(|(_, entry)| !(hide_single_h1 && entry.level == 1)) 207 + .map(|(idx, entry)| { 208 + let display_level = toc_display_level(entry.level, hide_single_h1, promote_h2_root); 209 + let line = build_toc_line_with_index( 210 + entry, 211 + display_level, 212 + (display_level == 1).then_some(top_level_index), 213 + active_idx == Some(idx), 214 + ); 215 + if display_level == 1 { 216 + top_level_index += 1; 217 + } 218 + line 219 + }) 220 + .collect(); 221 + } 222 + 223 + pub(crate) fn refresh_status_cache(&mut self, pct: u16) { 224 + let cache_key = StatusCacheKey { 225 + pct, 226 + search_mode: self.search_mode, 227 + search_draft_hash: hash_str(&self.search_draft), 228 + search_query_hash: hash_str(&self.search_query), 229 + search_draft_len: self.search_draft.len(), 230 + search_query_len: self.search_query.len(), 231 + search_match_count: self.search_matches.len(), 232 + search_idx: self.search_idx, 233 + watch: self.watch, 234 + flash_active: self 235 + .reload_flash 236 + .map(|t| t.elapsed() < Duration::from_millis(1500)) 237 + .unwrap_or(false), 238 + }; 239 + 240 + if self.status_cache_key.as_ref() == Some(&cache_key) { 241 + return; 242 + } 243 + 244 + self.status_line = Line::from(build_status_bar(self, pct)); 245 + self.status_cache_key = Some(cache_key); 246 + } 247 + 248 + pub(crate) fn refresh_static_caches(&mut self) { 249 + self.toc_active_idx = None; 250 + self.toc_display_lines.clear(); 251 + self.refresh_toc_cache(); 252 + self.status_cache_key = None; 253 + } 254 + 255 + pub(crate) fn scroll_down(&mut self, n: usize) { 256 + self.scroll = (self.scroll + n).min(self.total().saturating_sub(1)); 257 + } 258 + 259 + pub(crate) fn scroll_up(&mut self, n: usize) { 260 + self.scroll = self.scroll.saturating_sub(n); 261 + } 262 + 263 + pub(crate) fn jump_to_toc(&mut self, idx: usize) { 264 + if let Some(e) = self.toc.get(idx) { 265 + self.scroll = e.line; 266 + } 267 + } 268 + 269 + pub(crate) fn run_search(&mut self) { 270 + let q = self.search_query.to_lowercase(); 271 + if q.is_empty() { 272 + return; 273 + } 274 + let search_matches = { 275 + let folded_lines = self.folded_plain_lines(); 276 + folded_lines 277 + .iter() 278 + .enumerate() 279 + .filter(|(_, line)| line.contains(&q)) 280 + .map(|(i, _)| i) 281 + .collect() 282 + }; 283 + self.search_matches = search_matches; 284 + self.search_idx = 0; 285 + if let Some(&f) = self.search_matches.first() { 286 + self.scroll = f; 287 + } 288 + } 289 + 290 + pub(crate) fn begin_search(&mut self) { 291 + self.search_mode = true; 292 + self.search_draft = self.search_query.clone(); 293 + crate::runtime::debug_log( 294 + self.debug_input, 295 + &format!( 296 + "begin_search query={:?} draft={:?} matches={} idx={}", 297 + self.search_query, 298 + self.search_draft, 299 + self.search_matches.len(), 300 + self.search_idx 301 + ), 302 + ); 303 + } 304 + 305 + pub(crate) fn reset_search_state(&mut self) { 306 + self.search_draft.clear(); 307 + self.search_query.clear(); 308 + self.search_matches.clear(); 309 + self.search_idx = 0; 310 + } 311 + 312 + pub(crate) fn cancel_search(&mut self) { 313 + self.search_mode = false; 314 + self.reset_search_state(); 315 + crate::runtime::debug_log(self.debug_input, "cancel_search cleared query and matches"); 316 + } 317 + 318 + pub(crate) fn confirm_search(&mut self) { 319 + self.search_mode = false; 320 + let draft = std::mem::take(&mut self.search_draft); 321 + self.search_query = draft; 322 + if self.search_query.is_empty() { 323 + self.reset_search_state(); 324 + crate::runtime::debug_log( 325 + self.debug_input, 326 + "confirm_search empty query -> cleared matches", 327 + ); 328 + return; 329 + } 330 + self.run_search(); 331 + crate::runtime::debug_log( 332 + self.debug_input, 333 + &format!( 334 + "confirm_search query={:?} matches={} idx={} scroll={}", 335 + self.search_query, 336 + self.search_matches.len(), 337 + self.search_idx, 338 + self.scroll 339 + ), 340 + ); 341 + } 342 + 343 + pub(crate) fn clear_active_search(&mut self) { 344 + self.search_mode = false; 345 + self.reset_search_state(); 346 + crate::runtime::debug_log( 347 + self.debug_input, 348 + "clear_active_search cleared query and matches", 349 + ); 350 + } 351 + 352 + pub(crate) fn has_active_search(&self) -> bool { 353 + !self.search_query.is_empty() || !self.search_matches.is_empty() 354 + } 355 + 356 + pub(crate) fn next_match(&mut self) { 357 + if self.search_matches.is_empty() { 358 + return; 359 + } 360 + self.search_idx = (self.search_idx + 1) % self.search_matches.len(); 361 + self.scroll = self.search_matches[self.search_idx]; 362 + } 363 + 364 + pub(crate) fn prev_match(&mut self) { 365 + if self.search_matches.is_empty() { 366 + return; 367 + } 368 + if self.search_idx == 0 { 369 + self.search_idx = self.search_matches.len() - 1; 370 + } else { 371 + self.search_idx -= 1; 372 + } 373 + self.scroll = self.search_matches[self.search_idx]; 374 + } 375 + 376 + pub(crate) fn scroll_percent(&self, vh: usize) -> u16 { 377 + if self.total() <= vh { 378 + return 100; 379 + } 380 + ((self.scroll * 100) / (self.total() - vh).max(1)) as u16 381 + } 382 + 383 + pub(crate) fn check_modified(&mut self) -> Option<FileChange> { 384 + const HASH_FALLBACK_INTERVAL: Duration = Duration::from_secs(2); 385 + 386 + let path = self.filepath.as_ref()?; 387 + let state = read_file_state(path)?; 388 + match self.last_file_state { 389 + Some(prev) if state.modified != prev.modified || state.len != prev.len => { 390 + Some(FileChange::Metadata(state)) 391 + } 392 + Some(_) => { 393 + let should_hash = self 394 + .last_hash_check 395 + .map(|checked_at| checked_at.elapsed() >= HASH_FALLBACK_INTERVAL) 396 + .unwrap_or(true); 397 + if !should_hash { 398 + return None; 399 + } 400 + self.last_hash_check = Some(Instant::now()); 401 + let current_hash = hash_file_contents(path).ok()?; 402 + (current_hash != self.last_content_hash).then_some(FileChange::Content(state)) 403 + } 404 + None => Some(FileChange::Metadata(state)), 405 + } 406 + } 407 + 408 + pub(crate) fn reload(&mut self, ss: &SyntaxSet, theme: &Theme) -> bool { 409 + let path = match &self.filepath { 410 + Some(p) => p, 411 + None => return false, 412 + }; 413 + let src = match std::fs::read_to_string(path) { 414 + Ok(s) => s, 415 + Err(_) => return false, 416 + }; 417 + let file_state = read_file_state(path); 418 + let content_hash = hash_str(&src); 419 + 420 + let old_total = self.total(); 421 + let (new_lines, new_toc) = parse_markdown(&src, ss, theme); 422 + let new_total = new_lines.len(); 423 + 424 + if old_total > 0 { 425 + self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize; 426 + self.scroll = self.scroll.min(new_total.saturating_sub(1)); 427 + } 428 + 429 + self.replace_content(new_lines, new_toc); 430 + self.last_file_state = file_state; 431 + self.last_content_hash = content_hash; 432 + self.last_hash_check = Some(Instant::now()); 433 + self.reload_flash = Some(Instant::now()); 434 + 435 + if !self.search_query.is_empty() && !self.search_mode { 436 + self.run_search(); 437 + } 438 + true 439 + } 440 + } 441 + 442 + pub(crate) fn should_hide_single_h1(toc: &[TocEntry]) -> bool { 443 + let h1_count = toc.iter().filter(|entry| entry.level == 1).count(); 444 + let has_h2 = toc.iter().any(|entry| entry.level == 2); 445 + h1_count == 1 && has_h2 446 + } 447 + 448 + pub(crate) fn should_promote_h2_when_no_h1(toc: &[TocEntry]) -> bool { 449 + !toc.iter().any(|entry| entry.level == 1) && toc.iter().any(|entry| entry.level == 2) 450 + } 451 + 452 + pub(crate) fn toc_display_level(level: u8, hide_single_h1: bool, promote_h2_root: bool) -> u8 { 453 + if hide_single_h1 || promote_h2_root { 454 + match level { 455 + 2 => 1, 456 + 3 => 2, 457 + _ => level, 458 + } 459 + } else { 460 + level 461 + } 462 + } 463 + 464 + pub(crate) fn normalize_toc(mut toc: Vec<TocEntry>) -> Vec<TocEntry> { 465 + if should_hide_single_h1(&toc) || should_promote_h2_when_no_h1(&toc) { 466 + toc.retain(|entry| matches!(entry.level, 1..=3)); 467 + } else { 468 + toc.retain(|entry| matches!(entry.level, 1..=2)); 469 + } 470 + toc 471 + }
+55
src/cli.rs
··· 1 + use anyhow::Result; 2 + 3 + #[derive(Debug, Default, PartialEq, Eq)] 4 + pub(crate) struct CliOptions { 5 + pub(crate) watch: bool, 6 + pub(crate) debug_input: bool, 7 + pub(crate) print_help: bool, 8 + pub(crate) print_version: bool, 9 + pub(crate) file_arg: Option<String>, 10 + } 11 + 12 + pub(crate) fn usage_text() -> &'static str { 13 + "Usage: leaf [--watch] <file.md>\n echo '# Hello' | leaf" 14 + } 15 + 16 + pub(crate) fn version_text() -> &'static str { 17 + concat!("leaf ", env!("CARGO_PKG_VERSION")) 18 + } 19 + 20 + pub(crate) fn print_usage() { 21 + println!("{}", usage_text()); 22 + } 23 + 24 + pub(crate) fn print_version() { 25 + println!("{}", version_text()); 26 + } 27 + 28 + pub(crate) fn parse_cli(args: &[String]) -> Result<CliOptions> { 29 + let mut options = CliOptions::default(); 30 + let mut positional_only = false; 31 + 32 + for arg in args.iter().skip(1) { 33 + if positional_only { 34 + if options.file_arg.is_none() { 35 + options.file_arg = Some(arg.clone()); 36 + } else { 37 + anyhow::bail!("Too many file arguments"); 38 + } 39 + continue; 40 + } 41 + 42 + match arg.as_str() { 43 + "--watch" | "-w" => options.watch = true, 44 + "--debug-input" => options.debug_input = true, 45 + "--help" | "-h" => options.print_help = true, 46 + "--version" | "-V" => options.print_version = true, 47 + "--" => positional_only = true, 48 + _ if arg.starts_with('-') => anyhow::bail!("Unknown flag: {arg}"), 49 + _ if options.file_arg.is_none() => options.file_arg = Some(arg.clone()), 50 + _ => anyhow::bail!("Too many file arguments"), 51 + } 52 + } 53 + 54 + Ok(options) 55 + }
+109
src/main.rs
··· 1 + use anyhow::{Context, Result}; 2 + use ratatui::{backend::CrosstermBackend, Terminal}; 3 + use std::{fs::OpenOptions, io, io::Read, io::Write, path::PathBuf}; 4 + use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 5 + 6 + mod app; 7 + mod cli; 8 + mod markdown; 9 + mod render; 10 + mod runtime; 11 + mod terminal; 12 + #[cfg(test)] 13 + mod tests; 14 + 15 + use app::App; 16 + use cli::{parse_cli, print_usage, print_version, CliOptions}; 17 + use markdown::{hash_str, parse_markdown, read_file_state}; 18 + use runtime::run; 19 + use terminal::{finish_with_restore, TerminalSession}; 20 + 21 + #[cfg(test)] 22 + pub(crate) use app::{ 23 + normalize_toc, should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry, 24 + }; 25 + #[cfg(test)] 26 + pub(crate) use markdown::{display_width, line_plain_text}; 27 + #[cfg(test)] 28 + pub(crate) use runtime::should_handle_key; 29 + 30 + fn main() -> Result<()> { 31 + let args: Vec<String> = std::env::args().collect(); 32 + let options = parse_cli(&args)?; 33 + 34 + if options.print_help { 35 + print_usage(); 36 + return Ok(()); 37 + } 38 + if options.print_version { 39 + print_version(); 40 + return Ok(()); 41 + } 42 + let CliOptions { 43 + watch, 44 + debug_input, 45 + file_arg, 46 + .. 47 + } = options; 48 + 49 + if debug_input { 50 + let mut file = OpenOptions::new() 51 + .create(true) 52 + .write(true) 53 + .truncate(true) 54 + .open("leaf-debug.log") 55 + .context("Cannot create leaf-debug.log")?; 56 + writeln!(file, "leaf debug input log").ok(); 57 + } 58 + 59 + let (src, filename, filepath) = if let Some(f) = file_arg { 60 + let path = PathBuf::from(&f); 61 + let content = std::fs::read_to_string(&path) 62 + .with_context(|| format!("Cannot read: {}", path.display()))?; 63 + let name = path 64 + .file_name() 65 + .map(|n| n.to_string_lossy().to_string()) 66 + .unwrap_or(f.clone()); 67 + (content, name, Some(path)) 68 + } else { 69 + if watch { 70 + eprintln!("Error: --watch requires a file path (stdin cannot be watched)"); 71 + std::process::exit(1); 72 + } 73 + let mut buf = String::new(); 74 + io::stdin() 75 + .read_to_string(&mut buf) 76 + .context("Cannot read stdin")?; 77 + if buf.is_empty() { 78 + print_usage(); 79 + std::process::exit(1); 80 + } 81 + (buf, "stdin".to_string(), None) 82 + }; 83 + 84 + let ss = SyntaxSet::load_defaults_newlines(); 85 + let ts = ThemeSet::load_defaults(); 86 + let theme = ts.themes["base16-ocean.dark"].clone(); 87 + 88 + let last_file_state = filepath.as_ref().and_then(read_file_state); 89 + let last_content_hash = hash_str(&src); 90 + 91 + let (lines, toc) = parse_markdown(&src, &ss, &theme); 92 + let mut app = App::new( 93 + lines, 94 + toc, 95 + filename, 96 + debug_input, 97 + watch, 98 + filepath, 99 + last_file_state, 100 + ); 101 + app.set_last_content_hash(last_content_hash); 102 + 103 + let mut stdout = io::stdout(); 104 + let mut session = TerminalSession::enter(&mut stdout)?; 105 + let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; 106 + let run_result = run(&mut terminal, &mut app, &ss, &theme); 107 + let restore_result = session.restore(&mut terminal); 108 + finish_with_restore(run_result, restore_result) 109 + }
+773
src/markdown.rs
··· 1 + use crate::app::{normalize_toc, TocEntry}; 2 + use pulldown_cmark::{ 3 + Alignment, CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd, 4 + }; 5 + use ratatui::{ 6 + style::{Color, Modifier, Style}, 7 + text::{Line, Span}, 8 + }; 9 + use std::{ 10 + hash::{Hash, Hasher}, 11 + io, 12 + path::PathBuf, 13 + }; 14 + use syntect::{ 15 + easy::HighlightLines, highlighting::Theme, parsing::SyntaxSet, util::LinesWithEndings, 16 + }; 17 + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 18 + 19 + #[derive(Clone, Copy)] 20 + enum ListKind { 21 + Unordered, 22 + Ordered(u64), 23 + } 24 + 25 + struct ItemState { 26 + marker_emitted: bool, 27 + continuation_indent: usize, 28 + } 29 + 30 + struct TableBuf { 31 + alignments: Vec<Alignment>, 32 + rows: Vec<Vec<String>>, 33 + header_count: usize, 34 + current_row: Vec<String>, 35 + current_cell: String, 36 + in_header: bool, 37 + } 38 + 39 + struct TableBorder<'a> { 40 + left: &'a str, 41 + fill: &'a str, 42 + cross: &'a str, 43 + right: &'a str, 44 + } 45 + 46 + pub(crate) fn line_plain_text(line: &Line<'_>) -> String { 47 + line.spans.iter().map(|s| s.content.as_ref()).collect() 48 + } 49 + 50 + pub(crate) fn build_plain_lines(lines: &[Line<'_>]) -> Vec<String> { 51 + lines.iter().map(line_plain_text).collect() 52 + } 53 + 54 + pub(crate) fn hash_str(text: &str) -> u64 { 55 + let mut hasher = std::collections::hash_map::DefaultHasher::new(); 56 + text.hash(&mut hasher); 57 + hasher.finish() 58 + } 59 + 60 + pub(crate) fn read_file_state(path: &PathBuf) -> Option<crate::app::FileState> { 61 + let metadata = std::fs::metadata(path).ok()?; 62 + Some(crate::app::FileState { 63 + modified: metadata.modified().ok()?, 64 + len: metadata.len(), 65 + }) 66 + } 67 + 68 + pub(crate) fn hash_file_contents(path: &PathBuf) -> io::Result<u64> { 69 + std::fs::read_to_string(path).map(|contents| hash_str(&contents)) 70 + } 71 + 72 + pub(crate) fn truncate_display_width(text: &str, max_width: usize) -> String { 73 + if display_width(text) <= max_width { 74 + return text.to_string(); 75 + } 76 + if max_width == 0 { 77 + return String::new(); 78 + } 79 + 80 + let mut out = String::new(); 81 + let mut used = 0; 82 + for ch in text.chars() { 83 + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); 84 + if used + ch_w > max_width.saturating_sub(1) { 85 + break; 86 + } 87 + out.push(ch); 88 + used += ch_w; 89 + } 90 + out.push('…'); 91 + out 92 + } 93 + 94 + pub(crate) fn display_width(text: &str) -> usize { 95 + const TAB_STOP: usize = 4; 96 + 97 + let mut width = 0; 98 + for ch in text.chars() { 99 + if ch == '\t' { 100 + width += TAB_STOP - (width % TAB_STOP); 101 + } else { 102 + width += UnicodeWidthChar::width(ch).unwrap_or(0); 103 + } 104 + } 105 + width 106 + } 107 + 108 + fn expand_tabs(text: &str, start_width: usize) -> String { 109 + const TAB_STOP: usize = 4; 110 + 111 + let mut out = String::new(); 112 + let mut width = start_width; 113 + for ch in text.chars() { 114 + if ch == '\t' { 115 + let spaces = TAB_STOP - (width % TAB_STOP); 116 + out.push_str(&" ".repeat(spaces)); 117 + width += spaces; 118 + } else { 119 + out.push(ch); 120 + width += UnicodeWidthChar::width(ch).unwrap_or(0); 121 + } 122 + } 123 + out 124 + } 125 + 126 + pub(crate) fn highlight_line<'a>(line: &Line<'a>) -> Line<'a> { 127 + Line::from( 128 + line.spans 129 + .iter() 130 + .map(|span| Span::styled(span.content.clone(), span.style.bg(Color::Rgb(72, 62, 16)))) 131 + .collect::<Vec<_>>(), 132 + ) 133 + } 134 + 135 + fn strip_frontmatter(src: &str) -> &str { 136 + let Some(rest) = src.strip_prefix("---\n") else { 137 + return src; 138 + }; 139 + 140 + let mut offset = 4usize; 141 + for line in rest.split_inclusive('\n') { 142 + if line == "---\n" || line == "...\n" || line == "---" || line == "..." { 143 + return &src[offset + line.len()..]; 144 + } 145 + offset += line.len(); 146 + } 147 + 148 + src 149 + } 150 + 151 + fn syntect_to_color(c: syntect::highlighting::Color) -> Color { 152 + Color::Rgb(c.r, c.g, c.b) 153 + } 154 + 155 + fn resolve_syntax<'a>(lang: &str, ss: &'a SyntaxSet) -> &'a syntect::parsing::SyntaxReference { 156 + let raw = lang.trim(); 157 + let normalized = raw 158 + .split(|c: char| c.is_whitespace() || c == ',' || c == '{') 159 + .next() 160 + .unwrap_or("") 161 + .trim() 162 + .to_ascii_lowercase(); 163 + 164 + let aliases: &[&str] = match normalized.as_str() { 165 + "ts" | "typescript" => &[ 166 + "JavaScript", 167 + "js", 168 + "javascript", 169 + "TypeScript", 170 + "ts", 171 + "typescript", 172 + ], 173 + "tsx" => &["JSX", "jsx", "JavaScript", "js", "typescriptreact", "tsx"], 174 + "js" | "javascript" => &["JavaScript", "js", "javascript"], 175 + "jsx" => &["JSX", "jsx", "JavaScript React"], 176 + "shell" | "bash" | "sh" | "zsh" => &["Bourne Again Shell (bash)", "bash", "sh"], 177 + "yml" | "yaml" => &["YAML", "yml", "yaml"], 178 + "rs" | "rust" => &["Rust", "rs", "rust"], 179 + _ if normalized.is_empty() => &[], 180 + _ => &[], 181 + }; 182 + 183 + ss.find_syntax_by_token(raw) 184 + .or_else(|| ss.find_syntax_by_extension(raw)) 185 + .or_else(|| ss.find_syntax_by_token(&normalized)) 186 + .or_else(|| ss.find_syntax_by_extension(&normalized)) 187 + .or_else(|| { 188 + aliases.iter().find_map(|alias| { 189 + ss.find_syntax_by_token(alias) 190 + .or_else(|| ss.find_syntax_by_extension(alias)) 191 + .or_else(|| ss.find_syntax_by_name(alias)) 192 + }) 193 + }) 194 + .unwrap_or_else(|| ss.find_syntax_plain_text()) 195 + } 196 + 197 + fn highlight_code( 198 + code: &str, 199 + lang: &str, 200 + ss: &SyntaxSet, 201 + theme: &Theme, 202 + ) -> (Vec<Line<'static>>, usize) { 203 + let syntax = resolve_syntax(lang, ss); 204 + let mut hl = HighlightLines::new(syntax, theme); 205 + let gutter = Style::default().fg(Color::Rgb(40, 48, 68)); 206 + 207 + let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 208 + for line_str in LinesWithEndings::from(code) { 209 + let regions = hl.highlight_line(line_str, ss).unwrap_or_default(); 210 + let mut spans = vec![Span::raw(" "), Span::styled("│ ", gutter)]; 211 + let mut text_width: usize = 0; 212 + for (st, text) in &regions { 213 + let t = expand_tabs(text.trim_end_matches('\n'), text_width); 214 + if t.is_empty() { 215 + continue; 216 + } 217 + text_width += display_width(&t); 218 + let mut rs = Style::default().fg(syntect_to_color(st.foreground)); 219 + if st 220 + .font_style 221 + .contains(syntect::highlighting::FontStyle::BOLD) 222 + { 223 + rs = rs.add_modifier(Modifier::BOLD); 224 + } 225 + if st 226 + .font_style 227 + .contains(syntect::highlighting::FontStyle::ITALIC) 228 + { 229 + rs = rs.add_modifier(Modifier::ITALIC); 230 + } 231 + if st 232 + .font_style 233 + .contains(syntect::highlighting::FontStyle::UNDERLINE) 234 + { 235 + rs = rs.add_modifier(Modifier::UNDERLINED); 236 + } 237 + spans.push(Span::styled(t, rs)); 238 + } 239 + raw.push((spans, text_width)); 240 + } 241 + 242 + let label = if lang.is_empty() { "text" } else { lang }; 243 + let max_text = raw.iter().map(|(_, w)| *w).max().unwrap_or(0); 244 + let min_inner = (UnicodeWidthStr::width(label) + 3).max(44); 245 + let inner_width = (max_text + 2).max(min_inner); 246 + 247 + let mut out = Vec::new(); 248 + for (mut spans, text_width) in raw { 249 + let pad = inner_width.saturating_sub(text_width + 1); 250 + spans.push(Span::raw(" ".repeat(pad))); 251 + spans.push(Span::styled("│", gutter)); 252 + out.push(Line::from(spans)); 253 + } 254 + (out, inner_width) 255 + } 256 + 257 + fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 258 + if in_bq { 259 + vec![Span::styled( 260 + " ▏ ", 261 + Style::default().fg(Color::Rgb(75, 80, 148)), 262 + )] 263 + } else { 264 + vec![Span::raw(" ")] 265 + } 266 + } 267 + 268 + fn list_item_prefix( 269 + in_bq: bool, 270 + list_stack: &[ListKind], 271 + item_stack: &mut [ItemState], 272 + ) -> Vec<Span<'static>> { 273 + let mut prefix = block_prefix(in_bq); 274 + let Some(item) = item_stack.last_mut() else { 275 + return prefix; 276 + }; 277 + 278 + if item.marker_emitted { 279 + prefix.push(Span::raw(" ".repeat(item.continuation_indent))); 280 + return prefix; 281 + } 282 + 283 + let depth = list_stack.len(); 284 + prefix.push(Span::raw(" ".repeat(depth.saturating_sub(1)))); 285 + 286 + let marker = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 287 + ListKind::Unordered => match depth { 288 + 1 => "• ".to_string(), 289 + 2 => "◦ ".to_string(), 290 + _ => "▸ ".to_string(), 291 + }, 292 + ListKind::Ordered(n) => format!("{n}. "), 293 + }; 294 + item.continuation_indent = " ".repeat(depth.saturating_sub(1)).len() + display_width(&marker); 295 + item.marker_emitted = true; 296 + 297 + let marker_style = match list_stack.last().copied().unwrap_or(ListKind::Unordered) { 298 + ListKind::Unordered => match depth { 299 + 1 => Style::default().fg(Color::Rgb(95, 200, 148)), 300 + 2 => Style::default().fg(Color::Rgb(138, 155, 200)), 301 + _ => Style::default().fg(Color::Rgb(168, 168, 185)), 302 + }, 303 + ListKind::Ordered(_) => Style::default().fg(Color::Rgb(95, 200, 148)), 304 + }; 305 + prefix.push(Span::styled(marker, marker_style)); 306 + prefix 307 + } 308 + 309 + impl TableBuf { 310 + fn new(alignments: Vec<Alignment>) -> Self { 311 + Self { 312 + alignments, 313 + rows: vec![], 314 + header_count: 0, 315 + current_row: vec![], 316 + current_cell: String::new(), 317 + in_header: false, 318 + } 319 + } 320 + fn push_text(&mut self, t: &str) { 321 + self.current_cell.push_str(t); 322 + } 323 + fn end_cell(&mut self) { 324 + let cell = std::mem::take(&mut self.current_cell).trim().to_string(); 325 + self.current_row.push(cell); 326 + } 327 + fn end_row(&mut self) { 328 + let row = std::mem::take(&mut self.current_row); 329 + if !row.is_empty() { 330 + self.rows.push(row); 331 + } 332 + } 333 + fn end_header(&mut self) { 334 + self.end_row(); 335 + self.header_count = self.rows.len(); 336 + self.in_header = false; 337 + } 338 + 339 + fn render(&self) -> Vec<Line<'static>> { 340 + if self.rows.is_empty() { 341 + return vec![]; 342 + } 343 + let col_count = self.rows.iter().map(|r| r.len()).max().unwrap_or(0); 344 + if col_count == 0 { 345 + return vec![]; 346 + } 347 + 348 + let mut col_widths: Vec<usize> = vec![1; col_count]; 349 + for row in &self.rows { 350 + for (ci, cell) in row.iter().enumerate() { 351 + if ci < col_count { 352 + col_widths[ci] = col_widths[ci].max(display_width(cell)); 353 + } 354 + } 355 + } 356 + 357 + let border = Style::default().fg(Color::Rgb(65, 75, 108)); 358 + let sep = Style::default().fg(Color::Rgb(55, 65, 95)); 359 + let header = Style::default() 360 + .fg(Color::Rgb(140, 190, 255)) 361 + .add_modifier(Modifier::BOLD); 362 + let cell = Style::default().fg(Color::Rgb(205, 208, 218)); 363 + let ind = " "; 364 + 365 + let mut out: Vec<Line<'static>> = vec![Line::from("")]; 366 + out.push(self.hline( 367 + ind, 368 + TableBorder { 369 + left: "╭", 370 + fill: "─", 371 + cross: "┬", 372 + right: "╮", 373 + }, 374 + &col_widths, 375 + border, 376 + )); 377 + 378 + for (ri, row) in self.rows.iter().enumerate() { 379 + let is_hdr = ri < self.header_count; 380 + let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; 381 + for (ci, width) in col_widths.iter().copied().enumerate().take(col_count) { 382 + let txt = row.get(ci).map(|s| s.as_str()).unwrap_or(""); 383 + let align = self.alignments.get(ci).copied().unwrap_or(Alignment::None); 384 + let pad = align_cell(txt, width, align); 385 + let st = if is_hdr { header } else { cell }; 386 + spans.push(Span::raw(" ")); 387 + spans.push(Span::styled(pad, st)); 388 + spans.push(Span::raw(" ")); 389 + spans.push(Span::styled("│", border)); 390 + } 391 + out.push(Line::from(spans)); 392 + 393 + if is_hdr && ri == self.header_count - 1 { 394 + out.push(self.hline( 395 + ind, 396 + TableBorder { 397 + left: "╞", 398 + fill: "═", 399 + cross: "╪", 400 + right: "╡", 401 + }, 402 + &col_widths, 403 + sep, 404 + )); 405 + } else if !is_hdr && ri < self.rows.len() - 1 { 406 + out.push(self.hline( 407 + ind, 408 + TableBorder { 409 + left: "├", 410 + fill: "─", 411 + cross: "┼", 412 + right: "┤", 413 + }, 414 + &col_widths, 415 + border, 416 + )); 417 + } 418 + } 419 + 420 + out.push(self.hline( 421 + ind, 422 + TableBorder { 423 + left: "╰", 424 + fill: "─", 425 + cross: "┴", 426 + right: "╯", 427 + }, 428 + &col_widths, 429 + border, 430 + )); 431 + out.push(Line::from("")); 432 + out 433 + } 434 + 435 + fn hline( 436 + &self, 437 + indent: &str, 438 + border: TableBorder<'_>, 439 + col_widths: &[usize], 440 + style: Style, 441 + ) -> Line<'static> { 442 + let mut spans = vec![ 443 + Span::raw(indent.to_string()), 444 + Span::styled(border.left.to_string(), style), 445 + ]; 446 + for (ci, &w) in col_widths.iter().enumerate() { 447 + spans.push(Span::styled(border.fill.repeat(w + 2), style)); 448 + if ci < col_widths.len() - 1 { 449 + spans.push(Span::styled(border.cross.to_string(), style)); 450 + } 451 + } 452 + spans.push(Span::styled(border.right.to_string(), style)); 453 + Line::from(spans) 454 + } 455 + } 456 + 457 + fn align_cell(text: &str, width: usize, align: Alignment) -> String { 458 + let text = expand_tabs(text, 0); 459 + let len = display_width(&text); 460 + if len >= width { 461 + return text; 462 + } 463 + let pad = width - len; 464 + match align { 465 + Alignment::Right => format!("{}{}", " ".repeat(pad), text), 466 + Alignment::Center => { 467 + let l = pad / 2; 468 + format!("{}{}{}", " ".repeat(l), text, " ".repeat(pad - l)) 469 + } 470 + _ => format!("{}{}", text, " ".repeat(pad)), 471 + } 472 + } 473 + 474 + pub(crate) fn parse_markdown( 475 + src: &str, 476 + ss: &SyntaxSet, 477 + theme: &Theme, 478 + ) -> (Vec<Line<'static>>, Vec<TocEntry>) { 479 + let src = strip_frontmatter(src); 480 + let mut lines: Vec<Line<'static>> = Vec::new(); 481 + let mut toc: Vec<TocEntry> = Vec::new(); 482 + 483 + let mut spans: Vec<Span<'static>> = Vec::new(); 484 + let mut in_heading: Option<u8> = None; 485 + let mut in_code = false; 486 + let mut code_lang = String::new(); 487 + let mut code_buf = String::new(); 488 + let mut blockquote_depth = 0usize; 489 + let mut in_strong = false; 490 + let mut in_em = false; 491 + let mut in_strike = false; 492 + let mut in_link = false; 493 + let mut list_stack: Vec<ListKind> = Vec::new(); 494 + let mut item_stack: Vec<ItemState> = Vec::new(); 495 + let mut table: Option<TableBuf> = None; 496 + 497 + macro_rules! flush { 498 + ($prefix:expr) => {{ 499 + if !spans.is_empty() { 500 + let mut all: Vec<Span<'static>> = $prefix; 501 + all.append(&mut spans); 502 + lines.push(Line::from(all)); 503 + } 504 + }}; 505 + } 506 + 507 + for ev in Parser::new_ext(src, Options::all()) { 508 + if let Some(ref mut tb) = table { 509 + match &ev { 510 + MdEvent::Text(t) => { 511 + tb.push_text(t.as_ref()); 512 + continue; 513 + } 514 + MdEvent::Code(t) => { 515 + tb.push_text(t.as_ref()); 516 + continue; 517 + } 518 + MdEvent::Start(Tag::TableCell) | MdEvent::End(TagEnd::TableCell) => { 519 + if matches!(&ev, MdEvent::End(_)) { 520 + tb.end_cell(); 521 + } 522 + continue; 523 + } 524 + MdEvent::Start(Tag::TableRow) | MdEvent::End(TagEnd::TableRow) => { 525 + if matches!(&ev, MdEvent::End(_)) { 526 + tb.end_row(); 527 + } 528 + continue; 529 + } 530 + MdEvent::Start(Tag::TableHead) | MdEvent::End(TagEnd::TableHead) => { 531 + if matches!(&ev, MdEvent::End(_)) { 532 + tb.end_header(); 533 + } else { 534 + tb.in_header = true; 535 + } 536 + continue; 537 + } 538 + MdEvent::Start(Tag::Strong) 539 + | MdEvent::End(TagEnd::Strong) 540 + | MdEvent::Start(Tag::Emphasis) 541 + | MdEvent::End(TagEnd::Emphasis) 542 + | MdEvent::Start(Tag::Link { .. }) 543 + | MdEvent::End(TagEnd::Link) => { 544 + continue; 545 + } 546 + MdEvent::End(TagEnd::Table) => { 547 + lines.extend(tb.render()); 548 + table = None; 549 + continue; 550 + } 551 + _ => continue, 552 + } 553 + } 554 + 555 + match ev { 556 + MdEvent::Start(Tag::Table(aligns)) => { 557 + table = Some(TableBuf::new(aligns.clone())); 558 + } 559 + MdEvent::Start(Tag::Heading { level, .. }) => { 560 + in_heading = Some(match level { 561 + HeadingLevel::H1 => 1, 562 + HeadingLevel::H2 => 2, 563 + HeadingLevel::H3 => 3, 564 + _ => 4, 565 + }); 566 + lines.push(Line::from("")); 567 + } 568 + MdEvent::End(TagEnd::Heading(_)) => { 569 + let lvl = in_heading.unwrap_or(1); 570 + let (color, marker): (Color, &str) = match lvl { 571 + 1 => (Color::Rgb(140, 190, 255), "█ "), 572 + 2 => (Color::Rgb(120, 210, 170), "▌ "), 573 + 3 => (Color::Rgb(210, 180, 120), "▎ "), 574 + _ => (Color::Rgb(180, 180, 190), " "), 575 + }; 576 + let style = Style::default().fg(color).add_modifier(Modifier::BOLD); 577 + let title: String = spans.iter().map(|s| s.content.as_ref()).collect(); 578 + toc.push(TocEntry { 579 + level: lvl, 580 + title: title.clone(), 581 + line: lines.len(), 582 + }); 583 + let mut all = vec![ 584 + Span::raw(" "), 585 + Span::styled( 586 + marker.to_string(), 587 + Style::default().fg(Color::Rgb(55, 75, 115)), 588 + ), 589 + ]; 590 + all.extend(spans.drain(..).map(|s| Span::styled(s.content, style))); 591 + lines.push(Line::from(all)); 592 + if lvl == 1 { 593 + lines.push(Line::from(Span::styled( 594 + format!(" {}", "─".repeat((display_width(&title) + 4).min(68))), 595 + Style::default().fg(Color::Rgb(40, 50, 75)), 596 + ))); 597 + } 598 + lines.push(Line::from("")); 599 + in_heading = None; 600 + } 601 + MdEvent::Start(Tag::Paragraph) => {} 602 + MdEvent::End(TagEnd::Paragraph) => { 603 + let prefix = if item_stack.is_empty() { 604 + block_prefix(blockquote_depth > 0) 605 + } else { 606 + list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack) 607 + }; 608 + flush!(prefix); 609 + lines.push(Line::from("")); 610 + } 611 + MdEvent::Start(Tag::CodeBlock(kind)) => { 612 + in_code = true; 613 + code_buf.clear(); 614 + code_lang = match kind { 615 + CodeBlockKind::Fenced(l) => l.to_string(), 616 + CodeBlockKind::Indented => String::new(), 617 + }; 618 + } 619 + MdEvent::End(TagEnd::CodeBlock) => { 620 + in_code = false; 621 + let ld = if code_lang.is_empty() { 622 + "text".to_string() 623 + } else { 624 + code_lang.clone() 625 + }; 626 + let (code_lines, inner_width) = highlight_code(&code_buf, &code_lang, ss, theme); 627 + let header_width = UnicodeWidthStr::width(ld.as_str()) + 3; 628 + let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 629 + lines.push(Line::from(vec![ 630 + Span::raw(" "), 631 + Span::styled( 632 + "╭─ ".to_string(), 633 + Style::default().fg(Color::Rgb(40, 48, 68)), 634 + ), 635 + Span::styled( 636 + format!("{} ", ld), 637 + Style::default().fg(Color::Rgb(95, 110, 145)), 638 + ), 639 + Span::styled( 640 + format!("{}╮", top_bar), 641 + Style::default().fg(Color::Rgb(40, 48, 68)), 642 + ), 643 + ])); 644 + lines.extend(code_lines); 645 + lines.push(Line::from(Span::styled( 646 + format!(" ╰{}╯", "─".repeat(inner_width)), 647 + Style::default().fg(Color::Rgb(40, 48, 68)), 648 + ))); 649 + lines.push(Line::from("")); 650 + code_lang.clear(); 651 + code_buf.clear(); 652 + } 653 + MdEvent::Code(text) => { 654 + spans.push(Span::styled( 655 + format!(" {} ", text.as_ref()), 656 + Style::default() 657 + .fg(Color::Rgb(235, 155, 115)) 658 + .bg(Color::Rgb(40, 30, 28)), 659 + )); 660 + } 661 + MdEvent::Start(Tag::BlockQuote(_)) => { 662 + blockquote_depth += 1; 663 + } 664 + MdEvent::End(TagEnd::BlockQuote(_)) => { 665 + flush!(vec![Span::styled( 666 + " ▏ ", 667 + Style::default().fg(Color::Rgb(75, 80, 148)) 668 + )]); 669 + blockquote_depth = blockquote_depth.saturating_sub(1); 670 + lines.push(Line::from("")); 671 + } 672 + MdEvent::Start(Tag::List(start)) => { 673 + list_stack.push(match start { 674 + Some(n) => ListKind::Ordered(n), 675 + None => ListKind::Unordered, 676 + }); 677 + } 678 + MdEvent::End(TagEnd::List(_)) => { 679 + list_stack.pop(); 680 + if list_stack.is_empty() { 681 + lines.push(Line::from("")); 682 + } 683 + } 684 + MdEvent::Start(Tag::Item) => { 685 + item_stack.push(ItemState { 686 + marker_emitted: false, 687 + continuation_indent: 0, 688 + }); 689 + } 690 + MdEvent::End(TagEnd::Item) => { 691 + if !spans.is_empty() { 692 + let mut all = 693 + list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack); 694 + all.append(&mut spans); 695 + lines.push(Line::from(all)); 696 + } 697 + item_stack.pop(); 698 + if let Some(ListKind::Ordered(next)) = list_stack.last_mut() { 699 + *next += 1; 700 + } 701 + } 702 + MdEvent::Rule => { 703 + lines.push(Line::from("")); 704 + lines.push(Line::from(Span::styled( 705 + format!(" {}", "─".repeat(62)), 706 + Style::default().fg(Color::Rgb(48, 56, 76)), 707 + ))); 708 + lines.push(Line::from("")); 709 + } 710 + MdEvent::Start(Tag::Strong) => in_strong = true, 711 + MdEvent::End(TagEnd::Strong) => in_strong = false, 712 + MdEvent::Start(Tag::Emphasis) => in_em = true, 713 + MdEvent::End(TagEnd::Emphasis) => in_em = false, 714 + MdEvent::Start(Tag::Strikethrough) => in_strike = true, 715 + MdEvent::End(TagEnd::Strikethrough) => in_strike = false, 716 + MdEvent::Start(Tag::Link { .. }) => { 717 + in_link = true; 718 + spans.push(Span::styled( 719 + "⌗", 720 + Style::default().fg(Color::Rgb(85, 148, 235)), 721 + )); 722 + } 723 + MdEvent::End(TagEnd::Link) => in_link = false, 724 + MdEvent::Text(text) => { 725 + if in_code { 726 + code_buf.push_str(text.as_ref()); 727 + } else { 728 + let content = text.to_string(); 729 + let mut style = if blockquote_depth > 0 { 730 + Style::default() 731 + .fg(Color::Rgb(148, 148, 195)) 732 + .add_modifier(Modifier::ITALIC) 733 + } else if in_link { 734 + Style::default().fg(Color::Rgb(88, 152, 238)) 735 + } else { 736 + Style::default().fg(Color::Rgb(208, 210, 218)) 737 + }; 738 + if in_strong { 739 + style = style 740 + .fg(Color::Rgb(245, 245, 255)) 741 + .add_modifier(Modifier::BOLD); 742 + } 743 + if in_em { 744 + style = style.add_modifier(Modifier::ITALIC); 745 + } 746 + if in_strike { 747 + style = style.add_modifier(Modifier::CROSSED_OUT); 748 + } 749 + spans.push(Span::styled(content, style)); 750 + } 751 + } 752 + MdEvent::SoftBreak | MdEvent::HardBreak => { 753 + if !in_code { 754 + let prefix = if item_stack.is_empty() { 755 + block_prefix(blockquote_depth > 0) 756 + } else { 757 + list_item_prefix(blockquote_depth > 0, &list_stack, &mut item_stack) 758 + }; 759 + flush!(prefix); 760 + } 761 + } 762 + _ => {} 763 + } 764 + } 765 + 766 + if !spans.is_empty() { 767 + lines.push(Line::from(spans)); 768 + } 769 + for _ in 0..5 { 770 + lines.push(Line::from("")); 771 + } 772 + (lines, normalize_toc(toc)) 773 + }
+367
src/render.rs
··· 1 + use crate::app::App; 2 + use ratatui::{ 3 + layout::{Constraint, Direction, Layout, Rect}, 4 + style::{Color, Modifier, Style}, 5 + text::{Line, Span}, 6 + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, 7 + Frame, 8 + }; 9 + 10 + pub(crate) fn ui(f: &mut Frame, app: &mut App) { 11 + let area = f.area(); 12 + let root = Layout::default() 13 + .direction(Direction::Vertical) 14 + .constraints([Constraint::Min(0), Constraint::Length(1)]) 15 + .split(area); 16 + 17 + let (toc_area, content_area): (Option<Rect>, Rect) = if app.toc_visible && !app.toc.is_empty() { 18 + let cols = Layout::default() 19 + .direction(Direction::Horizontal) 20 + .constraints([Constraint::Length(30), Constraint::Min(0)]) 21 + .split(root[0]); 22 + (Some(cols[0]), cols[1]) 23 + } else { 24 + (None, root[0]) 25 + }; 26 + 27 + if let Some(ta) = toc_area { 28 + app.refresh_toc_cache(); 29 + let toc_chunks = Layout::default() 30 + .direction(Direction::Vertical) 31 + .constraints([Constraint::Length(3), Constraint::Min(0)]) 32 + .split(ta); 33 + 34 + f.render_widget( 35 + Paragraph::new("") 36 + .style(Style::default().bg(Color::Rgb(18, 18, 22))) 37 + .block( 38 + Block::default() 39 + .borders(Borders::RIGHT | Borders::BOTTOM) 40 + .border_style(Style::default().fg(Color::Rgb(52, 52, 58))) 41 + .style(Style::default().bg(Color::Rgb(18, 18, 22))), 42 + ), 43 + toc_chunks[0], 44 + ); 45 + f.render_widget( 46 + Paragraph::new(app.toc_display_lines.clone()) 47 + .style(Style::default().bg(Color::Rgb(18, 18, 22))) 48 + .block( 49 + Block::default() 50 + .borders(Borders::RIGHT) 51 + .border_style(Style::default().fg(Color::Rgb(52, 52, 58))) 52 + .style(Style::default().bg(Color::Rgb(18, 18, 22))), 53 + ), 54 + toc_chunks[1], 55 + ); 56 + f.render_widget( 57 + Paragraph::new(vec![app.toc_header_line.clone()]) 58 + .style(Style::default().bg(Color::Rgb(18, 18, 22))), 59 + Rect { 60 + x: toc_chunks[0].x, 61 + y: toc_chunks[0].y.saturating_add(1), 62 + width: toc_chunks[0].width.saturating_sub(1), 63 + height: 1, 64 + }, 65 + ); 66 + } 67 + 68 + let vh = content_area.height as usize; 69 + let scroll = app.scroll; 70 + let active_highlight_line = app.active_highlight_line(); 71 + if let Some(line_idx) = active_highlight_line { 72 + let _ = app.refresh_highlighted_line_cache(line_idx); 73 + } 74 + let render_lines = &app.lines; 75 + let visible_end = (scroll + vh).min(render_lines.len()); 76 + let mut visible_lines = render_lines[scroll..visible_end].to_vec(); 77 + 78 + if let Some(line_idx) = active_highlight_line { 79 + if (scroll..visible_end).contains(&line_idx) { 80 + if let Some((_, highlighted_line)) = &app.highlighted_line_cache { 81 + visible_lines[line_idx - scroll] = highlighted_line.clone(); 82 + } 83 + } 84 + } 85 + 86 + f.render_widget( 87 + Paragraph::new(visible_lines).style(Style::default().bg(Color::Rgb(18, 20, 28))), 88 + content_area, 89 + ); 90 + 91 + let mut ss_state = ScrollbarState::new(app.total()).position(app.scroll); 92 + f.render_stateful_widget( 93 + Scrollbar::new(ScrollbarOrientation::VerticalRight) 94 + .begin_symbol(None) 95 + .end_symbol(None) 96 + .track_symbol(Some("│")) 97 + .thumb_symbol("█"), 98 + content_area, 99 + &mut ss_state, 100 + ); 101 + 102 + let pct = app.scroll_percent(vh); 103 + let bar_bg = status_bar_bg(); 104 + app.refresh_status_cache(pct); 105 + 106 + f.render_widget( 107 + Paragraph::new(vec![app.status_line.clone()]).style(Style::default().bg(bar_bg)), 108 + root[1], 109 + ); 110 + } 111 + 112 + pub(crate) fn status_bar_bg() -> Color { 113 + Color::Rgb(18, 20, 32) 114 + } 115 + 116 + pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 117 + Style::default().fg(Color::Rgb(116, 126, 156)).bg(bar_bg) 118 + } 119 + 120 + pub(crate) fn join_span_sections( 121 + sections: Vec<Vec<Span<'static>>>, 122 + separator: Span<'static>, 123 + ) -> Vec<Span<'static>> { 124 + let mut joined = Vec::new(); 125 + for (idx, section) in sections.into_iter().enumerate() { 126 + if idx > 0 { 127 + joined.push(separator.clone()); 128 + } 129 + joined.extend(section); 130 + } 131 + joined 132 + } 133 + 134 + pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 135 + vec![Span::styled( 136 + " leaf ", 137 + Style::default() 138 + .fg(Color::Rgb(16, 18, 26)) 139 + .bg(Color::Rgb(105, 178, 218)) 140 + .add_modifier(Modifier::BOLD), 141 + )] 142 + } 143 + 144 + pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 145 + vec![Span::styled( 146 + format!(" {} ", filename), 147 + Style::default() 148 + .fg(Color::Rgb(162, 192, 222)) 149 + .bg(Color::Rgb(24, 28, 44)), 150 + )] 151 + } 152 + 153 + pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 154 + if !app.watch { 155 + return None; 156 + } 157 + 158 + let flash_active = app 159 + .reload_flash 160 + .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 161 + .unwrap_or(false); 162 + let span = if flash_active { 163 + Span::styled( 164 + " ⟳ reloaded ", 165 + Style::default() 166 + .fg(Color::Rgb(16, 18, 26)) 167 + .bg(Color::Rgb(95, 200, 148)) 168 + .add_modifier(Modifier::BOLD), 169 + ) 170 + } else { 171 + Span::styled( 172 + " ⟳ watch ", 173 + Style::default() 174 + .fg(Color::Rgb(95, 200, 148)) 175 + .bg(Color::Rgb(18, 30, 24)), 176 + ) 177 + }; 178 + Some(vec![span]) 179 + } 180 + 181 + pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 182 + if app.search_mode { 183 + return Some(vec![Span::styled( 184 + format!(" /{}", app.search_draft), 185 + Style::default() 186 + .fg(Color::Rgb(240, 210, 95)) 187 + .bg(Color::Rgb(26, 28, 42)), 188 + )]); 189 + } 190 + 191 + if app.search_query.is_empty() { 192 + return None; 193 + } 194 + 195 + let span = if app.search_matches.is_empty() { 196 + Span::styled( 197 + format!(" ✗ {} ", app.search_query), 198 + Style::default() 199 + .fg(Color::Rgb(218, 95, 95)) 200 + .bg(Color::Rgb(26, 28, 42)), 201 + ) 202 + } else { 203 + Span::styled( 204 + format!(" {}/{} ", app.search_idx + 1, app.search_matches.len()), 205 + Style::default() 206 + .fg(Color::Rgb(115, 208, 148)) 207 + .bg(Color::Rgb(26, 28, 42)), 208 + ) 209 + }; 210 + Some(vec![span]) 211 + } 212 + 213 + pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 214 + if app.search_mode { 215 + &["enter confirm", "esc cancel"] 216 + } else if app.has_active_search() { 217 + &[ 218 + "enter next", 219 + "n/N next/prev", 220 + "/ search", 221 + "esc clear", 222 + "q quit", 223 + ] 224 + } else { 225 + &[ 226 + "j/k scroll", 227 + "g/G top/bot", 228 + "t toc", 229 + "/ search", 230 + "n/N next/prev", 231 + "q quit", 232 + ] 233 + } 234 + } 235 + 236 + pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 237 + let separator = Span::styled(" · ", status_separator_style(bar_bg)); 238 + let sections = status_hint_segments(app) 239 + .iter() 240 + .map(|segment| { 241 + vec![Span::styled( 242 + (*segment).to_string(), 243 + Style::default().fg(Color::Rgb(58, 68, 98)).bg(bar_bg), 244 + )] 245 + }) 246 + .collect(); 247 + join_span_sections(sections, separator) 248 + } 249 + 250 + pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 251 + vec![Span::styled( 252 + format!("{:>3}% ", pct), 253 + Style::default().fg(Color::Rgb(105, 178, 218)).bg(bar_bg), 254 + )] 255 + } 256 + 257 + pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 258 + let bar_bg = status_bar_bg(); 259 + let outer_separator = Span::raw(" "); 260 + 261 + let mut left_section = status_brand_section(); 262 + left_section.extend(status_filename_section(&app.filename)); 263 + 264 + if let Some(section) = status_search_section(app) { 265 + left_section.extend(section); 266 + } 267 + 268 + if let Some(section) = status_watch_section(app) { 269 + left_section.extend(section); 270 + } 271 + 272 + let sections = vec![ 273 + left_section, 274 + status_shortcuts_section(app, bar_bg), 275 + status_percent_section(pct, bar_bg), 276 + ]; 277 + 278 + join_span_sections(sections, outer_separator) 279 + } 280 + 281 + pub(crate) fn toc_header_line() -> Line<'static> { 282 + Line::from(vec![Span::styled( 283 + " TABLE OF CONTENTS", 284 + Style::default() 285 + .fg(Color::Rgb(88, 88, 96)) 286 + .bg(Color::Rgb(18, 18, 22)) 287 + .add_modifier(Modifier::BOLD), 288 + )]) 289 + } 290 + 291 + pub(crate) fn build_toc_line_with_index( 292 + entry: &crate::app::TocEntry, 293 + display_level: u8, 294 + top_level_index: Option<usize>, 295 + active: bool, 296 + ) -> Line<'static> { 297 + let active_bg = Color::Rgb(42, 40, 46); 298 + let inactive_bg = Color::Rgb(18, 18, 22); 299 + 300 + match display_level { 301 + 1 => { 302 + let index = top_level_index.unwrap_or(0) + 1; 303 + let title = crate::markdown::truncate_display_width(&entry.title, 18); 304 + let bg = if active { active_bg } else { inactive_bg }; 305 + Line::from(vec![ 306 + Span::styled( 307 + if active { "▎" } else { " " }, 308 + Style::default().fg(Color::Rgb(123, 109, 255)).bg(bg), 309 + ), 310 + Span::styled(" ", Style::default().bg(bg)), 311 + Span::styled( 312 + format!("{index:02}"), 313 + Style::default() 314 + .fg(if active { 315 + Color::Rgb(123, 109, 255) 316 + } else { 317 + Color::Rgb(60, 60, 66) 318 + }) 319 + .bg(bg) 320 + .add_modifier(Modifier::BOLD), 321 + ), 322 + Span::styled(" ", Style::default().bg(bg)), 323 + Span::styled( 324 + title, 325 + Style::default() 326 + .fg(if active { 327 + Color::Rgb(224, 224, 228) 328 + } else { 329 + Color::Rgb(136, 136, 142) 330 + }) 331 + .bg(bg) 332 + .add_modifier(Modifier::BOLD), 333 + ), 334 + ]) 335 + } 336 + _ => Line::from(vec![ 337 + Span::styled( 338 + if active { "▎" } else { " " }, 339 + Style::default().fg(Color::Rgb(123, 109, 255)), 340 + ), 341 + Span::raw(" "), 342 + Span::styled( 343 + "•", 344 + Style::default().fg(if active { 345 + Color::Rgb(123, 109, 255) 346 + } else { 347 + Color::Rgb(62, 62, 68) 348 + }), 349 + ), 350 + Span::raw(" "), 351 + Span::styled( 352 + crate::markdown::truncate_display_width(&entry.title, 18), 353 + Style::default() 354 + .fg(if active { 355 + Color::Rgb(224, 224, 228) 356 + } else { 357 + Color::Rgb(102, 102, 108) 358 + }) 359 + .add_modifier(if active { 360 + Modifier::BOLD 361 + } else { 362 + Modifier::empty() 363 + }), 364 + ), 365 + ]), 366 + } 367 + }
+179
src/runtime.rs
··· 1 + use crate::{ 2 + app::{App, FileChange}, 3 + render::ui, 4 + }; 5 + use anyhow::Result; 6 + use crossterm::event::{self, poll, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; 7 + use ratatui::{backend::CrosstermBackend, Terminal}; 8 + use std::{fs::OpenOptions, io, io::Write, time::Duration}; 9 + use syntect::{highlighting::Theme, parsing::SyntaxSet}; 10 + 11 + pub(crate) fn should_handle_key(kind: KeyEventKind) -> bool { 12 + !matches!(kind, KeyEventKind::Release) 13 + } 14 + 15 + pub(crate) fn debug_log(enabled: bool, message: &str) { 16 + if !enabled { 17 + return; 18 + } 19 + if let Ok(mut file) = OpenOptions::new() 20 + .create(true) 21 + .append(true) 22 + .open("leaf-debug.log") 23 + { 24 + let _ = writeln!(file, "{message}"); 25 + } 26 + } 27 + 28 + pub(crate) fn run( 29 + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 30 + app: &mut App, 31 + ss: &SyntaxSet, 32 + theme: &Theme, 33 + ) -> Result<()> { 34 + const WATCH_INTERVAL: Duration = Duration::from_millis(250); 35 + const FLASH_DURATION: Duration = Duration::from_millis(1500); 36 + const MOUSE_SCROLL_STEP: usize = 3; 37 + let mut needs_redraw = true; 38 + 39 + loop { 40 + if needs_redraw { 41 + terminal.draw(|f| ui(f, app))?; 42 + needs_redraw = false; 43 + } 44 + 45 + let poll_timeout = if app.watch { 46 + let flash_timeout = app.reload_flash.and_then(|started| { 47 + let elapsed = started.elapsed(); 48 + (elapsed < FLASH_DURATION).then_some(FLASH_DURATION - elapsed) 49 + }); 50 + flash_timeout 51 + .map(|remaining| remaining.min(WATCH_INTERVAL)) 52 + .unwrap_or(WATCH_INTERVAL) 53 + } else { 54 + Duration::MAX 55 + }; 56 + 57 + let event_available = if app.watch { poll(poll_timeout)? } else { true }; 58 + 59 + if event_available { 60 + match event::read()? { 61 + Event::Key(key) => { 62 + debug_log( 63 + app.debug_input, 64 + &format!( 65 + "key_event kind={:?} code={:?} modifiers={:?} search_mode={} query={:?} draft={:?} matches={} idx={}", 66 + key.kind, 67 + key.code, 68 + key.modifiers, 69 + app.search_mode, 70 + app.search_query, 71 + app.search_draft, 72 + app.search_matches.len(), 73 + app.search_idx 74 + ), 75 + ); 76 + if !should_handle_key(key.kind) { 77 + continue; 78 + } 79 + let mut state_changed = true; 80 + if app.search_mode { 81 + match key.code { 82 + KeyCode::Esc => app.cancel_search(), 83 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 84 + app.cancel_search(); 85 + } 86 + KeyCode::Enter => app.confirm_search(), 87 + KeyCode::Backspace => { 88 + app.search_draft.pop(); 89 + } 90 + KeyCode::Char(c) => { 91 + app.search_draft.push(c); 92 + } 93 + _ => state_changed = false, 94 + } 95 + } else { 96 + match key.code { 97 + KeyCode::Esc if app.has_active_search() => app.clear_active_search(), 98 + KeyCode::Enter if app.has_active_search() => app.next_match(), 99 + KeyCode::Char('q') => break, 100 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 101 + if app.has_active_search() { 102 + app.clear_active_search(); 103 + } else { 104 + break; 105 + } 106 + } 107 + KeyCode::Char('j') | KeyCode::Down => app.scroll_down(1), 108 + KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1), 109 + KeyCode::Char('d') | KeyCode::PageDown => app.scroll_down(20), 110 + KeyCode::Char('u') | KeyCode::PageUp => app.scroll_up(20), 111 + KeyCode::Char('g') | KeyCode::Home => { 112 + app.scroll = 0; 113 + } 114 + KeyCode::Char('G') | KeyCode::End => { 115 + app.scroll = app.total().saturating_sub(1); 116 + } 117 + KeyCode::Char('t') => { 118 + app.toc_visible = !app.toc_visible; 119 + } 120 + KeyCode::Char('r') if app.watch => { 121 + app.last_file_state = None; 122 + app.reload(ss, theme); 123 + } 124 + KeyCode::Char('/') => app.begin_search(), 125 + KeyCode::Char('n') => app.next_match(), 126 + KeyCode::Char('N') => app.prev_match(), 127 + KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { 128 + if let Some(n) = c.to_digit(10) { 129 + app.jump_to_toc(n as usize - 1); 130 + } 131 + } 132 + _ => state_changed = false, 133 + } 134 + } 135 + if state_changed { 136 + needs_redraw = true; 137 + } 138 + } 139 + Event::Mouse(mouse) => { 140 + let state_changed = match mouse.kind { 141 + MouseEventKind::ScrollUp => { 142 + app.scroll_up(MOUSE_SCROLL_STEP); 143 + true 144 + } 145 + MouseEventKind::ScrollDown => { 146 + app.scroll_down(MOUSE_SCROLL_STEP); 147 + true 148 + } 149 + _ => false, 150 + }; 151 + if state_changed { 152 + needs_redraw = true; 153 + } 154 + } 155 + Event::Resize(_, _) => needs_redraw = true, 156 + _ => {} 157 + } 158 + } 159 + 160 + if app.watch { 161 + if let Some(change) = app.check_modified() { 162 + std::thread::sleep(Duration::from_millis(50)); 163 + if app.reload(ss, theme) { 164 + app.last_file_state = Some(match change { 165 + FileChange::Metadata(state) | FileChange::Content(state) => state, 166 + }); 167 + needs_redraw = true; 168 + } 169 + } 170 + if let Some(t) = app.reload_flash { 171 + if t.elapsed() >= FLASH_DURATION { 172 + app.reload_flash = None; 173 + needs_redraw = true; 174 + } 175 + } 176 + } 177 + } 178 + Ok(()) 179 + }
+117
src/terminal.rs
··· 1 + use anyhow::Result; 2 + use crossterm::{ 3 + event::{DisableMouseCapture, EnableMouseCapture}, 4 + execute, 5 + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 6 + }; 7 + use ratatui::{backend::CrosstermBackend, Terminal}; 8 + use std::io; 9 + 10 + pub(crate) struct TerminalSession { 11 + raw_enabled: bool, 12 + screen_enabled: bool, 13 + } 14 + 15 + pub(crate) fn cleanup_terminal_state<F, G>( 16 + screen_enabled: &mut bool, 17 + raw_enabled: &mut bool, 18 + mut leave_screen: F, 19 + mut disable_raw: G, 20 + ) -> Result<()> 21 + where 22 + F: FnMut() -> Result<()>, 23 + G: FnMut() -> Result<()>, 24 + { 25 + let mut error = None; 26 + 27 + if *screen_enabled { 28 + if let Err(err) = leave_screen() { 29 + error = Some(err); 30 + } 31 + *screen_enabled = false; 32 + } 33 + 34 + if *raw_enabled { 35 + if let Err(err) = disable_raw() { 36 + if error.is_none() { 37 + error = Some(err); 38 + } 39 + } 40 + *raw_enabled = false; 41 + } 42 + 43 + if let Some(err) = error { 44 + Err(err) 45 + } else { 46 + Ok(()) 47 + } 48 + } 49 + 50 + impl TerminalSession { 51 + pub(crate) fn enter(stdout: &mut io::Stdout) -> Result<Self> { 52 + enable_raw_mode()?; 53 + let mut session = Self { 54 + raw_enabled: true, 55 + screen_enabled: false, 56 + }; 57 + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 58 + session.screen_enabled = true; 59 + Ok(session) 60 + } 61 + 62 + pub(crate) fn restore( 63 + &mut self, 64 + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 65 + ) -> Result<()> { 66 + cleanup_terminal_state( 67 + &mut self.screen_enabled, 68 + &mut self.raw_enabled, 69 + || { 70 + execute!( 71 + terminal.backend_mut(), 72 + LeaveAlternateScreen, 73 + DisableMouseCapture 74 + )?; 75 + Ok(()) 76 + }, 77 + || { 78 + disable_raw_mode()?; 79 + Ok(()) 80 + }, 81 + )?; 82 + terminal.show_cursor()?; 83 + Ok(()) 84 + } 85 + } 86 + 87 + impl Drop for TerminalSession { 88 + fn drop(&mut self) { 89 + let _ = cleanup_terminal_state( 90 + &mut self.screen_enabled, 91 + &mut self.raw_enabled, 92 + || { 93 + let mut stdout = io::stdout(); 94 + execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; 95 + Ok(()) 96 + }, 97 + || { 98 + disable_raw_mode()?; 99 + Ok(()) 100 + }, 101 + ); 102 + } 103 + } 104 + 105 + pub(crate) fn finish_with_restore( 106 + run_result: Result<()>, 107 + restore_result: Result<()>, 108 + ) -> Result<()> { 109 + match (run_result, restore_result) { 110 + (Err(run_err), Err(restore_err)) => { 111 + Err(run_err.context(format!("terminal restore also failed: {restore_err}"))) 112 + } 113 + (Err(run_err), Ok(())) => Err(run_err), 114 + (Ok(()), Err(restore_err)) => Err(restore_err), 115 + (Ok(()), Ok(())) => Ok(()), 116 + } 117 + }
+451
src/tests.rs
··· 1 + use crate::*; 2 + use crossterm::event::KeyEventKind; 3 + use ratatui::backend::TestBackend; 4 + use ratatui::{text::Line, widgets::Paragraph, Terminal}; 5 + use syntect::{ 6 + highlighting::{Theme, ThemeSet}, 7 + parsing::SyntaxSet, 8 + }; 9 + 10 + fn test_assets() -> (SyntaxSet, Theme) { 11 + let ss = SyntaxSet::load_defaults_newlines(); 12 + let ts = ThemeSet::load_defaults(); 13 + let theme = ts.themes["base16-ocean.dark"].clone(); 14 + (ss, theme) 15 + } 16 + 17 + fn render_buffer(lines: &[Line<'static>]) -> ratatui::buffer::Buffer { 18 + let width = lines 19 + .iter() 20 + .map(|line| line.width()) 21 + .max() 22 + .unwrap_or(1) 23 + .max(1) as u16; 24 + let height = lines.len().max(1) as u16; 25 + let backend = TestBackend::new(width, height); 26 + let mut terminal = Terminal::new(backend).unwrap(); 27 + terminal 28 + .draw(|f| { 29 + f.render_widget(Paragraph::new(lines.to_vec()), f.area()); 30 + }) 31 + .unwrap(); 32 + terminal.backend().buffer().clone() 33 + } 34 + 35 + fn find_symbol(buffer: &ratatui::buffer::Buffer, symbol: &str) -> Option<(u16, u16)> { 36 + for y in 0..buffer.area.height { 37 + for x in 0..buffer.area.width { 38 + if buffer 39 + .cell((x, y)) 40 + .is_some_and(|cell| cell.symbol() == symbol) 41 + { 42 + return Some((x, y)); 43 + } 44 + } 45 + } 46 + None 47 + } 48 + 49 + fn line_symbols(buffer: &ratatui::buffer::Buffer, y: u16) -> String { 50 + (0..buffer.area.width) 51 + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) 52 + .collect() 53 + } 54 + 55 + fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { 56 + lines 57 + .iter() 58 + .map(line_plain_text) 59 + .filter(|line| !line.is_empty()) 60 + .collect() 61 + } 62 + 63 + #[test] 64 + fn search_matches_across_span_boundaries() { 65 + let (ss, theme) = test_assets(); 66 + let (lines, toc) = parse_markdown("hello **world**", &ss, &theme); 67 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 68 + 69 + app.search_query = "hello world".to_string(); 70 + app.run_search(); 71 + 72 + assert_eq!(app.search_matches.len(), 1); 73 + assert!(line_plain_text(&app.lines[app.search_matches[0]]).contains("hello world")); 74 + } 75 + 76 + #[test] 77 + fn key_release_events_are_ignored() { 78 + assert!(should_handle_key(KeyEventKind::Press)); 79 + assert!(should_handle_key(KeyEventKind::Repeat)); 80 + assert!(!should_handle_key(KeyEventKind::Release)); 81 + } 82 + 83 + #[test] 84 + fn cancelling_search_clears_query_and_matches() { 85 + let (ss, theme) = test_assets(); 86 + let (lines, toc) = parse_markdown("alpha\nbeta\nalpha beta\n", &ss, &theme); 87 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 88 + 89 + app.search_query = "alpha".to_string(); 90 + app.run_search(); 91 + 92 + app.begin_search(); 93 + app.search_draft.push_str(" gamma"); 94 + app.cancel_search(); 95 + 96 + assert!(!app.search_mode); 97 + assert!(app.search_draft.is_empty()); 98 + assert!(app.search_query.is_empty()); 99 + assert!(app.search_matches.is_empty()); 100 + assert_eq!(app.search_idx, 0); 101 + } 102 + 103 + #[test] 104 + fn confirm_search_uses_draft_and_updates_matches() { 105 + let (ss, theme) = test_assets(); 106 + let (lines, toc) = parse_markdown("alpha\nbeta\nbeta\n", &ss, &theme); 107 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 108 + 109 + app.begin_search(); 110 + app.search_draft = "beta".to_string(); 111 + app.confirm_search(); 112 + 113 + assert!(!app.search_mode); 114 + assert!(app.search_draft.is_empty()); 115 + assert_eq!(app.search_query, "beta"); 116 + assert_eq!(app.search_matches.len(), 2); 117 + } 118 + 119 + #[test] 120 + fn confirm_search_with_new_query_restarts_from_first_match() { 121 + let (ss, theme) = test_assets(); 122 + let (lines, toc) = parse_markdown("alpha\nbeta\nbeta again\n", &ss, &theme); 123 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 124 + 125 + app.search_query = "alpha".to_string(); 126 + app.run_search(); 127 + 128 + app.begin_search(); 129 + app.search_draft = "beta".to_string(); 130 + app.confirm_search(); 131 + 132 + assert_eq!(app.search_query, "beta"); 133 + assert_eq!(app.search_idx, 0); 134 + assert_eq!(app.scroll, app.search_matches[0]); 135 + assert_eq!(app.search_matches.len(), 2); 136 + } 137 + 138 + #[test] 139 + fn enter_in_normal_mode_advances_active_search() { 140 + let (ss, theme) = test_assets(); 141 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\nalpha again\n", &ss, &theme); 142 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 143 + 144 + app.search_query = "alpha".to_string(); 145 + app.run_search(); 146 + let second_match = app.search_matches[1]; 147 + 148 + app.next_match(); 149 + 150 + assert_eq!(app.search_idx, 1); 151 + assert_eq!(app.scroll, second_match); 152 + } 153 + 154 + #[test] 155 + fn ctrl_c_cancels_search_prompt_and_clears_active_query() { 156 + let (ss, theme) = test_assets(); 157 + let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 158 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 159 + 160 + app.search_query = "alpha".to_string(); 161 + app.run_search(); 162 + 163 + app.begin_search(); 164 + app.search_draft.push('z'); 165 + app.cancel_search(); 166 + 167 + assert!(!app.search_mode); 168 + assert!(app.search_query.is_empty()); 169 + assert!(app.search_matches.is_empty()); 170 + assert_eq!(app.search_idx, 0); 171 + } 172 + 173 + #[test] 174 + fn esc_clears_active_search_from_normal_mode() { 175 + let (ss, theme) = test_assets(); 176 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 177 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 178 + 179 + app.search_query = "alpha".to_string(); 180 + app.run_search(); 181 + app.clear_active_search(); 182 + 183 + assert!(!app.search_mode); 184 + assert!(app.search_draft.is_empty()); 185 + assert!(app.search_query.is_empty()); 186 + assert!(app.search_matches.is_empty()); 187 + assert_eq!(app.search_idx, 0); 188 + } 189 + 190 + #[test] 191 + fn ctrl_c_clears_active_search_before_exit() { 192 + let (ss, theme) = test_assets(); 193 + let (lines, toc) = parse_markdown("alpha\nbeta alpha\n", &ss, &theme); 194 + let mut app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 195 + 196 + app.search_query = "alpha".to_string(); 197 + app.run_search(); 198 + app.clear_active_search(); 199 + 200 + assert!(!app.has_active_search()); 201 + assert!(app.search_query.is_empty()); 202 + assert!(app.search_matches.is_empty()); 203 + } 204 + 205 + #[test] 206 + fn active_highlight_line_is_none_without_search_matches() { 207 + let (ss, theme) = test_assets(); 208 + let (lines, toc) = parse_markdown("alpha\nbeta\n", &ss, &theme); 209 + let app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 210 + 211 + assert_eq!(app.active_highlight_line(), None); 212 + } 213 + 214 + #[test] 215 + fn code_block_box_renders_right_border_in_one_column() { 216 + let (ss, theme) = test_assets(); 217 + let md = "```ts\nconst city = \"東京\";\n\tconsole.log(city)\n```"; 218 + let (lines, _) = parse_markdown(md, &ss, &theme); 219 + let buffer = render_buffer(&lines); 220 + 221 + let (right_x, start_y) = find_symbol(&buffer, "╮").unwrap(); 222 + let (_, end_y) = find_symbol(&buffer, "╯").unwrap(); 223 + 224 + for y in start_y + 1..end_y { 225 + assert_eq!( 226 + buffer.cell((right_x, y)).unwrap().symbol(), 227 + "│", 228 + "missing code block right border at row {y}" 229 + ); 230 + } 231 + } 232 + 233 + #[test] 234 + fn table_render_right_border_stays_aligned() { 235 + let (ss, theme) = test_assets(); 236 + let md = "| Name | Value |\n| --- | --- |\n| 東京 | 12 |\n| tab\tcell | ok |"; 237 + let (lines, _) = parse_markdown(md, &ss, &theme); 238 + let buffer = render_buffer(&lines); 239 + 240 + let (right_x, start_y) = find_symbol(&buffer, "╮").unwrap(); 241 + let (_, end_y) = find_symbol(&buffer, "╯").unwrap(); 242 + 243 + for y in start_y + 1..end_y { 244 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 245 + assert!( 246 + matches!(symbol, "│" | "┤" | "╡"), 247 + "unexpected table edge symbol {symbol:?} at row {y}" 248 + ); 249 + } 250 + } 251 + 252 + #[test] 253 + fn h1_underline_matches_display_width_for_wide_titles() { 254 + let (ss, theme) = test_assets(); 255 + let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 256 + let title_y = lines 257 + .iter() 258 + .position(|line| line_plain_text(line).contains("東京")) 259 + .unwrap() as u16; 260 + let underline_y = title_y + 1; 261 + let buffer = render_buffer(&lines); 262 + 263 + let underline = line_symbols(&buffer, underline_y); 264 + let underline_count = underline.chars().filter(|&ch| ch == '─').count(); 265 + assert_eq!(underline_count, display_width("東京") + 4); 266 + } 267 + 268 + #[test] 269 + fn loose_list_items_keep_their_markers() { 270 + let (ss, theme) = test_assets(); 271 + let (lines, _) = parse_markdown("- first\n\n- second\n", &ss, &theme); 272 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 273 + 274 + assert!(rendered.iter().any(|line| line.contains("• first"))); 275 + assert!(rendered.iter().any(|line| line.contains("• second"))); 276 + } 277 + 278 + #[test] 279 + fn ordered_lists_render_numeric_markers() { 280 + let (ss, theme) = test_assets(); 281 + let (lines, _) = parse_markdown("3. third\n4. fourth\n", &ss, &theme); 282 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 283 + 284 + assert!(rendered.iter().any(|line| line.contains("3. third"))); 285 + assert!(rendered.iter().any(|line| line.contains("4. fourth"))); 286 + } 287 + 288 + #[test] 289 + fn multiline_list_items_keep_marker_only_on_first_line() { 290 + let (ss, theme) = test_assets(); 291 + let (lines, _) = parse_markdown("- first line\n second line\n", &ss, &theme); 292 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 293 + 294 + let first = rendered 295 + .iter() 296 + .find(|line| line.contains("first line")) 297 + .unwrap(); 298 + let second = rendered 299 + .iter() 300 + .find(|line| line.contains("second line")) 301 + .unwrap(); 302 + 303 + assert!(first.contains("• first line")); 304 + assert!(!second.contains('•')); 305 + assert!(second.starts_with(" ")); 306 + } 307 + 308 + #[test] 309 + fn ordered_lists_preserve_non_default_start_numbers() { 310 + let (ss, theme) = test_assets(); 311 + let (lines, _) = parse_markdown("7. seven\n8. eight\n", &ss, &theme); 312 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 313 + 314 + assert!(rendered.iter().any(|line| line.contains("7. seven"))); 315 + assert!(rendered.iter().any(|line| line.contains("8. eight"))); 316 + } 317 + 318 + #[test] 319 + fn loose_list_items_render_expected_lines() { 320 + let (ss, theme) = test_assets(); 321 + let src = "- first loose item\n\n- second loose item after a blank line\n\n- third loose item\n\n continuation paragraph\n"; 322 + let (lines, _) = parse_markdown(src, &ss, &theme); 323 + let rendered = rendered_non_empty_lines(&lines); 324 + 325 + assert_eq!( 326 + rendered, 327 + vec![ 328 + " • first loose item", 329 + " • second loose item after a blank line", 330 + " • third loose item", 331 + " continuation paragraph", 332 + ] 333 + ); 334 + } 335 + 336 + #[test] 337 + fn ordered_loose_lists_render_expected_lines() { 338 + let (ss, theme) = test_assets(); 339 + let src = "7. seventh item\n\n8. eighth item\n\n continuation paragraph\n"; 340 + let (lines, _) = parse_markdown(src, &ss, &theme); 341 + let rendered = rendered_non_empty_lines(&lines); 342 + 343 + assert_eq!( 344 + rendered, 345 + vec![ 346 + " 7. seventh item", 347 + " 8. eighth item", 348 + " continuation paragraph", 349 + ] 350 + ); 351 + } 352 + 353 + #[test] 354 + fn ordered_lists_render_expected_lines() { 355 + let (ss, theme) = test_assets(); 356 + let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 357 + let rendered = rendered_non_empty_lines(&lines); 358 + 359 + assert_eq!(rendered, vec![" 3. third item", " 4. fourth item"]); 360 + } 361 + 362 + #[test] 363 + fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { 364 + let (ss, theme) = test_assets(); 365 + let src = "> outer\n> > inner\n> outer again\n"; 366 + let (lines, _) = parse_markdown(src, &ss, &theme); 367 + let rendered = rendered_non_empty_lines(&lines); 368 + 369 + assert!(rendered.iter().any(|line| line == " ▏ outer")); 370 + assert!(rendered.iter().any(|line| line == " ▏ inner")); 371 + assert!(rendered.iter().any(|line| line == " ▏ outer again")); 372 + } 373 + 374 + #[test] 375 + fn toc_only_includes_first_two_heading_levels() { 376 + let (ss, theme) = test_assets(); 377 + let (_, toc) = parse_markdown("# One\n## Two\n### Three\n#### Four\n", &ss, &theme); 378 + 379 + assert_eq!(toc.len(), 3); 380 + assert_eq!(toc[0].level, 1); 381 + assert_eq!(toc[1].level, 2); 382 + assert_eq!(toc[2].level, 3); 383 + } 384 + 385 + #[test] 386 + fn frontmatter_is_ignored_in_preview_and_toc() { 387 + let (ss, theme) = test_assets(); 388 + let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; 389 + let (lines, toc) = parse_markdown(src, &ss, &theme); 390 + let rendered = rendered_non_empty_lines(&lines); 391 + 392 + assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); 393 + assert!(rendered.iter().any(|line| line.contains("Visible"))); 394 + assert_eq!(toc.len(), 1); 395 + assert_eq!(toc[0].title, "Visible"); 396 + } 397 + 398 + #[test] 399 + fn toc_hides_single_h1_when_h2_entries_exist() { 400 + let toc = vec![ 401 + TocEntry { 402 + level: 1, 403 + title: "Doc Title".to_string(), 404 + line: 0, 405 + }, 406 + TocEntry { 407 + level: 2, 408 + title: "Install".to_string(), 409 + line: 10, 410 + }, 411 + ]; 412 + 413 + assert!(should_hide_single_h1(&toc)); 414 + assert_eq!(toc_display_level(2, true, false), 1); 415 + assert_eq!(toc_display_level(3, true, false), 2); 416 + } 417 + 418 + #[test] 419 + fn toc_keeps_single_h1_when_no_h2_entries_exist() { 420 + let toc = vec![TocEntry { 421 + level: 1, 422 + title: "Doc Title".to_string(), 423 + line: 0, 424 + }]; 425 + 426 + assert!(!should_hide_single_h1(&toc)); 427 + } 428 + 429 + #[test] 430 + fn toc_promotes_h2_when_document_has_no_h1() { 431 + let toc = vec![ 432 + TocEntry { 433 + level: 2, 434 + title: "Build & install".to_string(), 435 + line: 0, 436 + }, 437 + TocEntry { 438 + level: 3, 439 + title: "Android".to_string(), 440 + line: 4, 441 + }, 442 + ]; 443 + 444 + assert!(should_promote_h2_when_no_h1(&toc)); 445 + assert_eq!(toc_display_level(2, false, true), 1); 446 + assert_eq!(toc_display_level(3, false, true), 2); 447 + let normalized = normalize_toc(toc); 448 + assert_eq!(normalized.len(), 2); 449 + assert_eq!(normalized[0].level, 2); 450 + assert_eq!(normalized[1].level, 3); 451 + }