├── .DS_Store ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets ├── .DS_Store └── images │ ├── .DS_Store │ └── feedr.png └── src ├── app.rs ├── feed.rs ├── main.rs ├── tui.rs └── ui.rs /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahdotsh/feedr/56ab43629e3b14ddda6406e85e983eb07bf979fa/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /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 = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "android-tzdata" 22 | version = "0.1.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 25 | 26 | [[package]] 27 | name = "android_system_properties" 28 | version = "0.1.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 31 | dependencies = [ 32 | "libc", 33 | ] 34 | 35 | [[package]] 36 | name = "anyhow" 37 | version = "1.0.97" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 40 | 41 | [[package]] 42 | name = "atom_syndication" 43 | version = "0.12.7" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" 46 | dependencies = [ 47 | "chrono", 48 | "derive_builder", 49 | "diligent-date-parser", 50 | "never", 51 | "quick-xml", 52 | ] 53 | 54 | [[package]] 55 | name = "autocfg" 56 | version = "1.4.0" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 59 | 60 | [[package]] 61 | name = "backtrace" 62 | version = "0.3.74" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 65 | dependencies = [ 66 | "addr2line", 67 | "cfg-if", 68 | "libc", 69 | "miniz_oxide", 70 | "object", 71 | "rustc-demangle", 72 | "windows-targets 0.52.6", 73 | ] 74 | 75 | [[package]] 76 | name = "base64" 77 | version = "0.21.7" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 80 | 81 | [[package]] 82 | name = "bitflags" 83 | version = "1.3.2" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 86 | 87 | [[package]] 88 | name = "bitflags" 89 | version = "2.9.0" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 92 | 93 | [[package]] 94 | name = "bumpalo" 95 | version = "3.17.0" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 98 | 99 | [[package]] 100 | name = "bytes" 101 | version = "1.10.1" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 104 | 105 | [[package]] 106 | name = "cassowary" 107 | version = "0.3.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 110 | 111 | [[package]] 112 | name = "cc" 113 | version = "1.2.17" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" 116 | dependencies = [ 117 | "shlex", 118 | ] 119 | 120 | [[package]] 121 | name = "cfg-if" 122 | version = "1.0.0" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 125 | 126 | [[package]] 127 | name = "chrono" 128 | version = "0.4.40" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" 131 | dependencies = [ 132 | "android-tzdata", 133 | "iana-time-zone", 134 | "js-sys", 135 | "num-traits", 136 | "wasm-bindgen", 137 | "windows-link", 138 | ] 139 | 140 | [[package]] 141 | name = "core-foundation" 142 | version = "0.9.4" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 145 | dependencies = [ 146 | "core-foundation-sys", 147 | "libc", 148 | ] 149 | 150 | [[package]] 151 | name = "core-foundation-sys" 152 | version = "0.8.7" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 155 | 156 | [[package]] 157 | name = "crossterm" 158 | version = "0.27.0" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 161 | dependencies = [ 162 | "bitflags 2.9.0", 163 | "crossterm_winapi", 164 | "libc", 165 | "mio 0.8.11", 166 | "parking_lot", 167 | "signal-hook", 168 | "signal-hook-mio", 169 | "winapi", 170 | ] 171 | 172 | [[package]] 173 | name = "crossterm_winapi" 174 | version = "0.9.1" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 177 | dependencies = [ 178 | "winapi", 179 | ] 180 | 181 | [[package]] 182 | name = "darling" 183 | version = "0.20.11" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 186 | dependencies = [ 187 | "darling_core", 188 | "darling_macro", 189 | ] 190 | 191 | [[package]] 192 | name = "darling_core" 193 | version = "0.20.11" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 196 | dependencies = [ 197 | "fnv", 198 | "ident_case", 199 | "proc-macro2", 200 | "quote", 201 | "strsim", 202 | "syn 2.0.100", 203 | ] 204 | 205 | [[package]] 206 | name = "darling_macro" 207 | version = "0.20.11" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 210 | dependencies = [ 211 | "darling_core", 212 | "quote", 213 | "syn 2.0.100", 214 | ] 215 | 216 | [[package]] 217 | name = "derive_builder" 218 | version = "0.20.2" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 221 | dependencies = [ 222 | "derive_builder_macro", 223 | ] 224 | 225 | [[package]] 226 | name = "derive_builder_core" 227 | version = "0.20.2" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 230 | dependencies = [ 231 | "darling", 232 | "proc-macro2", 233 | "quote", 234 | "syn 2.0.100", 235 | ] 236 | 237 | [[package]] 238 | name = "derive_builder_macro" 239 | version = "0.20.2" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 242 | dependencies = [ 243 | "derive_builder_core", 244 | "syn 2.0.100", 245 | ] 246 | 247 | [[package]] 248 | name = "diligent-date-parser" 249 | version = "0.1.5" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" 252 | dependencies = [ 253 | "chrono", 254 | ] 255 | 256 | [[package]] 257 | name = "dirs" 258 | version = "5.0.1" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 261 | dependencies = [ 262 | "dirs-sys", 263 | ] 264 | 265 | [[package]] 266 | name = "dirs-sys" 267 | version = "0.4.1" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 270 | dependencies = [ 271 | "libc", 272 | "option-ext", 273 | "redox_users", 274 | "windows-sys 0.48.0", 275 | ] 276 | 277 | [[package]] 278 | name = "displaydoc" 279 | version = "0.2.5" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 282 | dependencies = [ 283 | "proc-macro2", 284 | "quote", 285 | "syn 2.0.100", 286 | ] 287 | 288 | [[package]] 289 | name = "either" 290 | version = "1.15.0" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 293 | 294 | [[package]] 295 | name = "encoding_rs" 296 | version = "0.8.35" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 299 | dependencies = [ 300 | "cfg-if", 301 | ] 302 | 303 | [[package]] 304 | name = "equivalent" 305 | version = "1.0.2" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 308 | 309 | [[package]] 310 | name = "errno" 311 | version = "0.3.10" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 314 | dependencies = [ 315 | "libc", 316 | "windows-sys 0.59.0", 317 | ] 318 | 319 | [[package]] 320 | name = "fastrand" 321 | version = "2.3.0" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 324 | 325 | [[package]] 326 | name = "feedr" 327 | version = "0.1.0" 328 | dependencies = [ 329 | "anyhow", 330 | "chrono", 331 | "crossterm", 332 | "dirs", 333 | "html2text", 334 | "open", 335 | "ratatui", 336 | "reqwest", 337 | "rss", 338 | "serde", 339 | "serde_json", 340 | "unicode-width", 341 | "uuid", 342 | ] 343 | 344 | [[package]] 345 | name = "fnv" 346 | version = "1.0.7" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 349 | 350 | [[package]] 351 | name = "foreign-types" 352 | version = "0.3.2" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 355 | dependencies = [ 356 | "foreign-types-shared", 357 | ] 358 | 359 | [[package]] 360 | name = "foreign-types-shared" 361 | version = "0.1.1" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 364 | 365 | [[package]] 366 | name = "form_urlencoded" 367 | version = "1.2.1" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 370 | dependencies = [ 371 | "percent-encoding", 372 | ] 373 | 374 | [[package]] 375 | name = "futf" 376 | version = "0.1.5" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" 379 | dependencies = [ 380 | "mac", 381 | "new_debug_unreachable", 382 | ] 383 | 384 | [[package]] 385 | name = "futures-channel" 386 | version = "0.3.31" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 389 | dependencies = [ 390 | "futures-core", 391 | ] 392 | 393 | [[package]] 394 | name = "futures-core" 395 | version = "0.3.31" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 398 | 399 | [[package]] 400 | name = "futures-io" 401 | version = "0.3.31" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 404 | 405 | [[package]] 406 | name = "futures-sink" 407 | version = "0.3.31" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 410 | 411 | [[package]] 412 | name = "futures-task" 413 | version = "0.3.31" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 416 | 417 | [[package]] 418 | name = "futures-util" 419 | version = "0.3.31" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 422 | dependencies = [ 423 | "futures-core", 424 | "futures-io", 425 | "futures-task", 426 | "memchr", 427 | "pin-project-lite", 428 | "pin-utils", 429 | "slab", 430 | ] 431 | 432 | [[package]] 433 | name = "getrandom" 434 | version = "0.2.15" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 437 | dependencies = [ 438 | "cfg-if", 439 | "libc", 440 | "wasi 0.11.0+wasi-snapshot-preview1", 441 | ] 442 | 443 | [[package]] 444 | name = "getrandom" 445 | version = "0.3.2" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 448 | dependencies = [ 449 | "cfg-if", 450 | "libc", 451 | "r-efi", 452 | "wasi 0.14.2+wasi-0.2.4", 453 | ] 454 | 455 | [[package]] 456 | name = "gimli" 457 | version = "0.31.1" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 460 | 461 | [[package]] 462 | name = "h2" 463 | version = "0.3.26" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" 466 | dependencies = [ 467 | "bytes", 468 | "fnv", 469 | "futures-core", 470 | "futures-sink", 471 | "futures-util", 472 | "http", 473 | "indexmap", 474 | "slab", 475 | "tokio", 476 | "tokio-util", 477 | "tracing", 478 | ] 479 | 480 | [[package]] 481 | name = "hashbrown" 482 | version = "0.15.2" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 485 | 486 | [[package]] 487 | name = "heck" 488 | version = "0.4.1" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 491 | 492 | [[package]] 493 | name = "html2text" 494 | version = "0.6.0" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "74cda84f06c1cc83476f79ae8e2e892b626bdadafcb227baec54c918cadc18a0" 497 | dependencies = [ 498 | "html5ever", 499 | "markup5ever", 500 | "tendril", 501 | "unicode-width", 502 | "xml5ever", 503 | ] 504 | 505 | [[package]] 506 | name = "html5ever" 507 | version = "0.26.0" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" 510 | dependencies = [ 511 | "log", 512 | "mac", 513 | "markup5ever", 514 | "proc-macro2", 515 | "quote", 516 | "syn 1.0.109", 517 | ] 518 | 519 | [[package]] 520 | name = "http" 521 | version = "0.2.12" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" 524 | dependencies = [ 525 | "bytes", 526 | "fnv", 527 | "itoa", 528 | ] 529 | 530 | [[package]] 531 | name = "http-body" 532 | version = "0.4.6" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" 535 | dependencies = [ 536 | "bytes", 537 | "http", 538 | "pin-project-lite", 539 | ] 540 | 541 | [[package]] 542 | name = "httparse" 543 | version = "1.10.1" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 546 | 547 | [[package]] 548 | name = "httpdate" 549 | version = "1.0.3" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 552 | 553 | [[package]] 554 | name = "hyper" 555 | version = "0.14.32" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" 558 | dependencies = [ 559 | "bytes", 560 | "futures-channel", 561 | "futures-core", 562 | "futures-util", 563 | "h2", 564 | "http", 565 | "http-body", 566 | "httparse", 567 | "httpdate", 568 | "itoa", 569 | "pin-project-lite", 570 | "socket2", 571 | "tokio", 572 | "tower-service", 573 | "tracing", 574 | "want", 575 | ] 576 | 577 | [[package]] 578 | name = "hyper-tls" 579 | version = "0.5.0" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" 582 | dependencies = [ 583 | "bytes", 584 | "hyper", 585 | "native-tls", 586 | "tokio", 587 | "tokio-native-tls", 588 | ] 589 | 590 | [[package]] 591 | name = "iana-time-zone" 592 | version = "0.1.63" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 595 | dependencies = [ 596 | "android_system_properties", 597 | "core-foundation-sys", 598 | "iana-time-zone-haiku", 599 | "js-sys", 600 | "log", 601 | "wasm-bindgen", 602 | "windows-core", 603 | ] 604 | 605 | [[package]] 606 | name = "iana-time-zone-haiku" 607 | version = "0.1.2" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 610 | dependencies = [ 611 | "cc", 612 | ] 613 | 614 | [[package]] 615 | name = "icu_collections" 616 | version = "1.5.0" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 619 | dependencies = [ 620 | "displaydoc", 621 | "yoke", 622 | "zerofrom", 623 | "zerovec", 624 | ] 625 | 626 | [[package]] 627 | name = "icu_locid" 628 | version = "1.5.0" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 631 | dependencies = [ 632 | "displaydoc", 633 | "litemap", 634 | "tinystr", 635 | "writeable", 636 | "zerovec", 637 | ] 638 | 639 | [[package]] 640 | name = "icu_locid_transform" 641 | version = "1.5.0" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 644 | dependencies = [ 645 | "displaydoc", 646 | "icu_locid", 647 | "icu_locid_transform_data", 648 | "icu_provider", 649 | "tinystr", 650 | "zerovec", 651 | ] 652 | 653 | [[package]] 654 | name = "icu_locid_transform_data" 655 | version = "1.5.1" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" 658 | 659 | [[package]] 660 | name = "icu_normalizer" 661 | version = "1.5.0" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 664 | dependencies = [ 665 | "displaydoc", 666 | "icu_collections", 667 | "icu_normalizer_data", 668 | "icu_properties", 669 | "icu_provider", 670 | "smallvec", 671 | "utf16_iter", 672 | "utf8_iter", 673 | "write16", 674 | "zerovec", 675 | ] 676 | 677 | [[package]] 678 | name = "icu_normalizer_data" 679 | version = "1.5.1" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" 682 | 683 | [[package]] 684 | name = "icu_properties" 685 | version = "1.5.1" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 688 | dependencies = [ 689 | "displaydoc", 690 | "icu_collections", 691 | "icu_locid_transform", 692 | "icu_properties_data", 693 | "icu_provider", 694 | "tinystr", 695 | "zerovec", 696 | ] 697 | 698 | [[package]] 699 | name = "icu_properties_data" 700 | version = "1.5.1" 701 | source = "registry+https://github.com/rust-lang/crates.io-index" 702 | checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" 703 | 704 | [[package]] 705 | name = "icu_provider" 706 | version = "1.5.0" 707 | source = "registry+https://github.com/rust-lang/crates.io-index" 708 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 709 | dependencies = [ 710 | "displaydoc", 711 | "icu_locid", 712 | "icu_provider_macros", 713 | "stable_deref_trait", 714 | "tinystr", 715 | "writeable", 716 | "yoke", 717 | "zerofrom", 718 | "zerovec", 719 | ] 720 | 721 | [[package]] 722 | name = "icu_provider_macros" 723 | version = "1.5.0" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 726 | dependencies = [ 727 | "proc-macro2", 728 | "quote", 729 | "syn 2.0.100", 730 | ] 731 | 732 | [[package]] 733 | name = "ident_case" 734 | version = "1.0.1" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 737 | 738 | [[package]] 739 | name = "idna" 740 | version = "1.0.3" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 743 | dependencies = [ 744 | "idna_adapter", 745 | "smallvec", 746 | "utf8_iter", 747 | ] 748 | 749 | [[package]] 750 | name = "idna_adapter" 751 | version = "1.2.0" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 754 | dependencies = [ 755 | "icu_normalizer", 756 | "icu_properties", 757 | ] 758 | 759 | [[package]] 760 | name = "indexmap" 761 | version = "2.8.0" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" 764 | dependencies = [ 765 | "equivalent", 766 | "hashbrown", 767 | ] 768 | 769 | [[package]] 770 | name = "indoc" 771 | version = "2.0.6" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 774 | 775 | [[package]] 776 | name = "ipnet" 777 | version = "2.11.0" 778 | source = "registry+https://github.com/rust-lang/crates.io-index" 779 | checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 780 | 781 | [[package]] 782 | name = "itertools" 783 | version = "0.11.0" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" 786 | dependencies = [ 787 | "either", 788 | ] 789 | 790 | [[package]] 791 | name = "itoa" 792 | version = "1.0.15" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 795 | 796 | [[package]] 797 | name = "js-sys" 798 | version = "0.3.77" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 801 | dependencies = [ 802 | "once_cell", 803 | "wasm-bindgen", 804 | ] 805 | 806 | [[package]] 807 | name = "libc" 808 | version = "0.2.171" 809 | source = "registry+https://github.com/rust-lang/crates.io-index" 810 | checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 811 | 812 | [[package]] 813 | name = "libredox" 814 | version = "0.1.3" 815 | source = "registry+https://github.com/rust-lang/crates.io-index" 816 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 817 | dependencies = [ 818 | "bitflags 2.9.0", 819 | "libc", 820 | ] 821 | 822 | [[package]] 823 | name = "linux-raw-sys" 824 | version = "0.9.3" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" 827 | 828 | [[package]] 829 | name = "litemap" 830 | version = "0.7.5" 831 | source = "registry+https://github.com/rust-lang/crates.io-index" 832 | checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 833 | 834 | [[package]] 835 | name = "lock_api" 836 | version = "0.4.12" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 839 | dependencies = [ 840 | "autocfg", 841 | "scopeguard", 842 | ] 843 | 844 | [[package]] 845 | name = "log" 846 | version = "0.4.27" 847 | source = "registry+https://github.com/rust-lang/crates.io-index" 848 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 849 | 850 | [[package]] 851 | name = "mac" 852 | version = "0.1.1" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 855 | 856 | [[package]] 857 | name = "markup5ever" 858 | version = "0.11.0" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" 861 | dependencies = [ 862 | "log", 863 | "phf", 864 | "phf_codegen", 865 | "string_cache", 866 | "string_cache_codegen", 867 | "tendril", 868 | ] 869 | 870 | [[package]] 871 | name = "memchr" 872 | version = "2.7.4" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 875 | 876 | [[package]] 877 | name = "mime" 878 | version = "0.3.17" 879 | source = "registry+https://github.com/rust-lang/crates.io-index" 880 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 881 | 882 | [[package]] 883 | name = "miniz_oxide" 884 | version = "0.8.5" 885 | source = "registry+https://github.com/rust-lang/crates.io-index" 886 | checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" 887 | dependencies = [ 888 | "adler2", 889 | ] 890 | 891 | [[package]] 892 | name = "mio" 893 | version = "0.8.11" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 896 | dependencies = [ 897 | "libc", 898 | "log", 899 | "wasi 0.11.0+wasi-snapshot-preview1", 900 | "windows-sys 0.48.0", 901 | ] 902 | 903 | [[package]] 904 | name = "mio" 905 | version = "1.0.3" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 908 | dependencies = [ 909 | "libc", 910 | "wasi 0.11.0+wasi-snapshot-preview1", 911 | "windows-sys 0.52.0", 912 | ] 913 | 914 | [[package]] 915 | name = "native-tls" 916 | version = "0.2.14" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 919 | dependencies = [ 920 | "libc", 921 | "log", 922 | "openssl", 923 | "openssl-probe", 924 | "openssl-sys", 925 | "schannel", 926 | "security-framework", 927 | "security-framework-sys", 928 | "tempfile", 929 | ] 930 | 931 | [[package]] 932 | name = "never" 933 | version = "0.1.0" 934 | source = "registry+https://github.com/rust-lang/crates.io-index" 935 | checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" 936 | 937 | [[package]] 938 | name = "new_debug_unreachable" 939 | version = "1.0.6" 940 | source = "registry+https://github.com/rust-lang/crates.io-index" 941 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 942 | 943 | [[package]] 944 | name = "num-traits" 945 | version = "0.2.19" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 948 | dependencies = [ 949 | "autocfg", 950 | ] 951 | 952 | [[package]] 953 | name = "object" 954 | version = "0.36.7" 955 | source = "registry+https://github.com/rust-lang/crates.io-index" 956 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 957 | dependencies = [ 958 | "memchr", 959 | ] 960 | 961 | [[package]] 962 | name = "once_cell" 963 | version = "1.21.3" 964 | source = "registry+https://github.com/rust-lang/crates.io-index" 965 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 966 | 967 | [[package]] 968 | name = "open" 969 | version = "3.2.0" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" 972 | dependencies = [ 973 | "pathdiff", 974 | "windows-sys 0.42.0", 975 | ] 976 | 977 | [[package]] 978 | name = "openssl" 979 | version = "0.10.71" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" 982 | dependencies = [ 983 | "bitflags 2.9.0", 984 | "cfg-if", 985 | "foreign-types", 986 | "libc", 987 | "once_cell", 988 | "openssl-macros", 989 | "openssl-sys", 990 | ] 991 | 992 | [[package]] 993 | name = "openssl-macros" 994 | version = "0.1.1" 995 | source = "registry+https://github.com/rust-lang/crates.io-index" 996 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 997 | dependencies = [ 998 | "proc-macro2", 999 | "quote", 1000 | "syn 2.0.100", 1001 | ] 1002 | 1003 | [[package]] 1004 | name = "openssl-probe" 1005 | version = "0.1.6" 1006 | source = "registry+https://github.com/rust-lang/crates.io-index" 1007 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1008 | 1009 | [[package]] 1010 | name = "openssl-sys" 1011 | version = "0.9.106" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" 1014 | dependencies = [ 1015 | "cc", 1016 | "libc", 1017 | "pkg-config", 1018 | "vcpkg", 1019 | ] 1020 | 1021 | [[package]] 1022 | name = "option-ext" 1023 | version = "0.2.0" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1026 | 1027 | [[package]] 1028 | name = "parking_lot" 1029 | version = "0.12.3" 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" 1031 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 1032 | dependencies = [ 1033 | "lock_api", 1034 | "parking_lot_core", 1035 | ] 1036 | 1037 | [[package]] 1038 | name = "parking_lot_core" 1039 | version = "0.9.10" 1040 | source = "registry+https://github.com/rust-lang/crates.io-index" 1041 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 1042 | dependencies = [ 1043 | "cfg-if", 1044 | "libc", 1045 | "redox_syscall", 1046 | "smallvec", 1047 | "windows-targets 0.52.6", 1048 | ] 1049 | 1050 | [[package]] 1051 | name = "paste" 1052 | version = "1.0.15" 1053 | source = "registry+https://github.com/rust-lang/crates.io-index" 1054 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 1055 | 1056 | [[package]] 1057 | name = "pathdiff" 1058 | version = "0.2.3" 1059 | source = "registry+https://github.com/rust-lang/crates.io-index" 1060 | checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" 1061 | 1062 | [[package]] 1063 | name = "percent-encoding" 1064 | version = "2.3.1" 1065 | source = "registry+https://github.com/rust-lang/crates.io-index" 1066 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1067 | 1068 | [[package]] 1069 | name = "phf" 1070 | version = "0.10.1" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" 1073 | dependencies = [ 1074 | "phf_shared 0.10.0", 1075 | ] 1076 | 1077 | [[package]] 1078 | name = "phf_codegen" 1079 | version = "0.10.0" 1080 | source = "registry+https://github.com/rust-lang/crates.io-index" 1081 | checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" 1082 | dependencies = [ 1083 | "phf_generator 0.10.0", 1084 | "phf_shared 0.10.0", 1085 | ] 1086 | 1087 | [[package]] 1088 | name = "phf_generator" 1089 | version = "0.10.0" 1090 | source = "registry+https://github.com/rust-lang/crates.io-index" 1091 | checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" 1092 | dependencies = [ 1093 | "phf_shared 0.10.0", 1094 | "rand", 1095 | ] 1096 | 1097 | [[package]] 1098 | name = "phf_generator" 1099 | version = "0.11.3" 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" 1101 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 1102 | dependencies = [ 1103 | "phf_shared 0.11.3", 1104 | "rand", 1105 | ] 1106 | 1107 | [[package]] 1108 | name = "phf_shared" 1109 | version = "0.10.0" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" 1112 | dependencies = [ 1113 | "siphasher 0.3.11", 1114 | ] 1115 | 1116 | [[package]] 1117 | name = "phf_shared" 1118 | version = "0.11.3" 1119 | source = "registry+https://github.com/rust-lang/crates.io-index" 1120 | checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 1121 | dependencies = [ 1122 | "siphasher 1.0.1", 1123 | ] 1124 | 1125 | [[package]] 1126 | name = "pin-project-lite" 1127 | version = "0.2.16" 1128 | source = "registry+https://github.com/rust-lang/crates.io-index" 1129 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1130 | 1131 | [[package]] 1132 | name = "pin-utils" 1133 | version = "0.1.0" 1134 | source = "registry+https://github.com/rust-lang/crates.io-index" 1135 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1136 | 1137 | [[package]] 1138 | name = "pkg-config" 1139 | version = "0.3.32" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1142 | 1143 | [[package]] 1144 | name = "ppv-lite86" 1145 | version = "0.2.21" 1146 | source = "registry+https://github.com/rust-lang/crates.io-index" 1147 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1148 | dependencies = [ 1149 | "zerocopy", 1150 | ] 1151 | 1152 | [[package]] 1153 | name = "precomputed-hash" 1154 | version = "0.1.1" 1155 | source = "registry+https://github.com/rust-lang/crates.io-index" 1156 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 1157 | 1158 | [[package]] 1159 | name = "proc-macro2" 1160 | version = "1.0.94" 1161 | source = "registry+https://github.com/rust-lang/crates.io-index" 1162 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 1163 | dependencies = [ 1164 | "unicode-ident", 1165 | ] 1166 | 1167 | [[package]] 1168 | name = "quick-xml" 1169 | version = "0.37.4" 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" 1171 | checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" 1172 | dependencies = [ 1173 | "encoding_rs", 1174 | "memchr", 1175 | ] 1176 | 1177 | [[package]] 1178 | name = "quote" 1179 | version = "1.0.40" 1180 | source = "registry+https://github.com/rust-lang/crates.io-index" 1181 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 1182 | dependencies = [ 1183 | "proc-macro2", 1184 | ] 1185 | 1186 | [[package]] 1187 | name = "r-efi" 1188 | version = "5.2.0" 1189 | source = "registry+https://github.com/rust-lang/crates.io-index" 1190 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1191 | 1192 | [[package]] 1193 | name = "rand" 1194 | version = "0.8.5" 1195 | source = "registry+https://github.com/rust-lang/crates.io-index" 1196 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1197 | dependencies = [ 1198 | "libc", 1199 | "rand_chacha", 1200 | "rand_core", 1201 | ] 1202 | 1203 | [[package]] 1204 | name = "rand_chacha" 1205 | version = "0.3.1" 1206 | source = "registry+https://github.com/rust-lang/crates.io-index" 1207 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1208 | dependencies = [ 1209 | "ppv-lite86", 1210 | "rand_core", 1211 | ] 1212 | 1213 | [[package]] 1214 | name = "rand_core" 1215 | version = "0.6.4" 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" 1217 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1218 | dependencies = [ 1219 | "getrandom 0.2.15", 1220 | ] 1221 | 1222 | [[package]] 1223 | name = "ratatui" 1224 | version = "0.23.0" 1225 | source = "registry+https://github.com/rust-lang/crates.io-index" 1226 | checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad" 1227 | dependencies = [ 1228 | "bitflags 2.9.0", 1229 | "cassowary", 1230 | "crossterm", 1231 | "indoc", 1232 | "itertools", 1233 | "paste", 1234 | "strum", 1235 | "unicode-segmentation", 1236 | "unicode-width", 1237 | ] 1238 | 1239 | [[package]] 1240 | name = "redox_syscall" 1241 | version = "0.5.10" 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" 1243 | checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" 1244 | dependencies = [ 1245 | "bitflags 2.9.0", 1246 | ] 1247 | 1248 | [[package]] 1249 | name = "redox_users" 1250 | version = "0.4.6" 1251 | source = "registry+https://github.com/rust-lang/crates.io-index" 1252 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 1253 | dependencies = [ 1254 | "getrandom 0.2.15", 1255 | "libredox", 1256 | "thiserror", 1257 | ] 1258 | 1259 | [[package]] 1260 | name = "reqwest" 1261 | version = "0.11.27" 1262 | source = "registry+https://github.com/rust-lang/crates.io-index" 1263 | checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" 1264 | dependencies = [ 1265 | "base64", 1266 | "bytes", 1267 | "encoding_rs", 1268 | "futures-core", 1269 | "futures-util", 1270 | "h2", 1271 | "http", 1272 | "http-body", 1273 | "hyper", 1274 | "hyper-tls", 1275 | "ipnet", 1276 | "js-sys", 1277 | "log", 1278 | "mime", 1279 | "native-tls", 1280 | "once_cell", 1281 | "percent-encoding", 1282 | "pin-project-lite", 1283 | "rustls-pemfile", 1284 | "serde", 1285 | "serde_json", 1286 | "serde_urlencoded", 1287 | "sync_wrapper", 1288 | "system-configuration", 1289 | "tokio", 1290 | "tokio-native-tls", 1291 | "tower-service", 1292 | "url", 1293 | "wasm-bindgen", 1294 | "wasm-bindgen-futures", 1295 | "web-sys", 1296 | "winreg", 1297 | ] 1298 | 1299 | [[package]] 1300 | name = "rss" 1301 | version = "2.0.12" 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" 1303 | checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" 1304 | dependencies = [ 1305 | "atom_syndication", 1306 | "derive_builder", 1307 | "never", 1308 | "quick-xml", 1309 | ] 1310 | 1311 | [[package]] 1312 | name = "rustc-demangle" 1313 | version = "0.1.24" 1314 | source = "registry+https://github.com/rust-lang/crates.io-index" 1315 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 1316 | 1317 | [[package]] 1318 | name = "rustix" 1319 | version = "1.0.5" 1320 | source = "registry+https://github.com/rust-lang/crates.io-index" 1321 | checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" 1322 | dependencies = [ 1323 | "bitflags 2.9.0", 1324 | "errno", 1325 | "libc", 1326 | "linux-raw-sys", 1327 | "windows-sys 0.59.0", 1328 | ] 1329 | 1330 | [[package]] 1331 | name = "rustls-pemfile" 1332 | version = "1.0.4" 1333 | source = "registry+https://github.com/rust-lang/crates.io-index" 1334 | checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" 1335 | dependencies = [ 1336 | "base64", 1337 | ] 1338 | 1339 | [[package]] 1340 | name = "rustversion" 1341 | version = "1.0.20" 1342 | source = "registry+https://github.com/rust-lang/crates.io-index" 1343 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1344 | 1345 | [[package]] 1346 | name = "ryu" 1347 | version = "1.0.20" 1348 | source = "registry+https://github.com/rust-lang/crates.io-index" 1349 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1350 | 1351 | [[package]] 1352 | name = "schannel" 1353 | version = "0.1.27" 1354 | source = "registry+https://github.com/rust-lang/crates.io-index" 1355 | checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 1356 | dependencies = [ 1357 | "windows-sys 0.59.0", 1358 | ] 1359 | 1360 | [[package]] 1361 | name = "scopeguard" 1362 | version = "1.2.0" 1363 | source = "registry+https://github.com/rust-lang/crates.io-index" 1364 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1365 | 1366 | [[package]] 1367 | name = "security-framework" 1368 | version = "2.11.1" 1369 | source = "registry+https://github.com/rust-lang/crates.io-index" 1370 | checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 1371 | dependencies = [ 1372 | "bitflags 2.9.0", 1373 | "core-foundation", 1374 | "core-foundation-sys", 1375 | "libc", 1376 | "security-framework-sys", 1377 | ] 1378 | 1379 | [[package]] 1380 | name = "security-framework-sys" 1381 | version = "2.14.0" 1382 | source = "registry+https://github.com/rust-lang/crates.io-index" 1383 | checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 1384 | dependencies = [ 1385 | "core-foundation-sys", 1386 | "libc", 1387 | ] 1388 | 1389 | [[package]] 1390 | name = "serde" 1391 | version = "1.0.219" 1392 | source = "registry+https://github.com/rust-lang/crates.io-index" 1393 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1394 | dependencies = [ 1395 | "serde_derive", 1396 | ] 1397 | 1398 | [[package]] 1399 | name = "serde_derive" 1400 | version = "1.0.219" 1401 | source = "registry+https://github.com/rust-lang/crates.io-index" 1402 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1403 | dependencies = [ 1404 | "proc-macro2", 1405 | "quote", 1406 | "syn 2.0.100", 1407 | ] 1408 | 1409 | [[package]] 1410 | name = "serde_json" 1411 | version = "1.0.140" 1412 | source = "registry+https://github.com/rust-lang/crates.io-index" 1413 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1414 | dependencies = [ 1415 | "itoa", 1416 | "memchr", 1417 | "ryu", 1418 | "serde", 1419 | ] 1420 | 1421 | [[package]] 1422 | name = "serde_urlencoded" 1423 | version = "0.7.1" 1424 | source = "registry+https://github.com/rust-lang/crates.io-index" 1425 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1426 | dependencies = [ 1427 | "form_urlencoded", 1428 | "itoa", 1429 | "ryu", 1430 | "serde", 1431 | ] 1432 | 1433 | [[package]] 1434 | name = "shlex" 1435 | version = "1.3.0" 1436 | source = "registry+https://github.com/rust-lang/crates.io-index" 1437 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1438 | 1439 | [[package]] 1440 | name = "signal-hook" 1441 | version = "0.3.17" 1442 | source = "registry+https://github.com/rust-lang/crates.io-index" 1443 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 1444 | dependencies = [ 1445 | "libc", 1446 | "signal-hook-registry", 1447 | ] 1448 | 1449 | [[package]] 1450 | name = "signal-hook-mio" 1451 | version = "0.2.4" 1452 | source = "registry+https://github.com/rust-lang/crates.io-index" 1453 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 1454 | dependencies = [ 1455 | "libc", 1456 | "mio 0.8.11", 1457 | "signal-hook", 1458 | ] 1459 | 1460 | [[package]] 1461 | name = "signal-hook-registry" 1462 | version = "1.4.2" 1463 | source = "registry+https://github.com/rust-lang/crates.io-index" 1464 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 1465 | dependencies = [ 1466 | "libc", 1467 | ] 1468 | 1469 | [[package]] 1470 | name = "siphasher" 1471 | version = "0.3.11" 1472 | source = "registry+https://github.com/rust-lang/crates.io-index" 1473 | checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" 1474 | 1475 | [[package]] 1476 | name = "siphasher" 1477 | version = "1.0.1" 1478 | source = "registry+https://github.com/rust-lang/crates.io-index" 1479 | checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 1480 | 1481 | [[package]] 1482 | name = "slab" 1483 | version = "0.4.9" 1484 | source = "registry+https://github.com/rust-lang/crates.io-index" 1485 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 1486 | dependencies = [ 1487 | "autocfg", 1488 | ] 1489 | 1490 | [[package]] 1491 | name = "smallvec" 1492 | version = "1.14.0" 1493 | source = "registry+https://github.com/rust-lang/crates.io-index" 1494 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 1495 | 1496 | [[package]] 1497 | name = "socket2" 1498 | version = "0.5.9" 1499 | source = "registry+https://github.com/rust-lang/crates.io-index" 1500 | checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" 1501 | dependencies = [ 1502 | "libc", 1503 | "windows-sys 0.52.0", 1504 | ] 1505 | 1506 | [[package]] 1507 | name = "stable_deref_trait" 1508 | version = "1.2.0" 1509 | source = "registry+https://github.com/rust-lang/crates.io-index" 1510 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 1511 | 1512 | [[package]] 1513 | name = "string_cache" 1514 | version = "0.8.9" 1515 | source = "registry+https://github.com/rust-lang/crates.io-index" 1516 | checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" 1517 | dependencies = [ 1518 | "new_debug_unreachable", 1519 | "parking_lot", 1520 | "phf_shared 0.11.3", 1521 | "precomputed-hash", 1522 | "serde", 1523 | ] 1524 | 1525 | [[package]] 1526 | name = "string_cache_codegen" 1527 | version = "0.5.4" 1528 | source = "registry+https://github.com/rust-lang/crates.io-index" 1529 | checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" 1530 | dependencies = [ 1531 | "phf_generator 0.11.3", 1532 | "phf_shared 0.11.3", 1533 | "proc-macro2", 1534 | "quote", 1535 | ] 1536 | 1537 | [[package]] 1538 | name = "strsim" 1539 | version = "0.11.1" 1540 | source = "registry+https://github.com/rust-lang/crates.io-index" 1541 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1542 | 1543 | [[package]] 1544 | name = "strum" 1545 | version = "0.25.0" 1546 | source = "registry+https://github.com/rust-lang/crates.io-index" 1547 | checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" 1548 | dependencies = [ 1549 | "strum_macros", 1550 | ] 1551 | 1552 | [[package]] 1553 | name = "strum_macros" 1554 | version = "0.25.3" 1555 | source = "registry+https://github.com/rust-lang/crates.io-index" 1556 | checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" 1557 | dependencies = [ 1558 | "heck", 1559 | "proc-macro2", 1560 | "quote", 1561 | "rustversion", 1562 | "syn 2.0.100", 1563 | ] 1564 | 1565 | [[package]] 1566 | name = "syn" 1567 | version = "1.0.109" 1568 | source = "registry+https://github.com/rust-lang/crates.io-index" 1569 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 1570 | dependencies = [ 1571 | "proc-macro2", 1572 | "quote", 1573 | "unicode-ident", 1574 | ] 1575 | 1576 | [[package]] 1577 | name = "syn" 1578 | version = "2.0.100" 1579 | source = "registry+https://github.com/rust-lang/crates.io-index" 1580 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 1581 | dependencies = [ 1582 | "proc-macro2", 1583 | "quote", 1584 | "unicode-ident", 1585 | ] 1586 | 1587 | [[package]] 1588 | name = "sync_wrapper" 1589 | version = "0.1.2" 1590 | source = "registry+https://github.com/rust-lang/crates.io-index" 1591 | checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" 1592 | 1593 | [[package]] 1594 | name = "synstructure" 1595 | version = "0.13.1" 1596 | source = "registry+https://github.com/rust-lang/crates.io-index" 1597 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 1598 | dependencies = [ 1599 | "proc-macro2", 1600 | "quote", 1601 | "syn 2.0.100", 1602 | ] 1603 | 1604 | [[package]] 1605 | name = "system-configuration" 1606 | version = "0.5.1" 1607 | source = "registry+https://github.com/rust-lang/crates.io-index" 1608 | checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" 1609 | dependencies = [ 1610 | "bitflags 1.3.2", 1611 | "core-foundation", 1612 | "system-configuration-sys", 1613 | ] 1614 | 1615 | [[package]] 1616 | name = "system-configuration-sys" 1617 | version = "0.5.0" 1618 | source = "registry+https://github.com/rust-lang/crates.io-index" 1619 | checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" 1620 | dependencies = [ 1621 | "core-foundation-sys", 1622 | "libc", 1623 | ] 1624 | 1625 | [[package]] 1626 | name = "tempfile" 1627 | version = "3.19.1" 1628 | source = "registry+https://github.com/rust-lang/crates.io-index" 1629 | checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" 1630 | dependencies = [ 1631 | "fastrand", 1632 | "getrandom 0.3.2", 1633 | "once_cell", 1634 | "rustix", 1635 | "windows-sys 0.59.0", 1636 | ] 1637 | 1638 | [[package]] 1639 | name = "tendril" 1640 | version = "0.4.3" 1641 | source = "registry+https://github.com/rust-lang/crates.io-index" 1642 | checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" 1643 | dependencies = [ 1644 | "futf", 1645 | "mac", 1646 | "utf-8", 1647 | ] 1648 | 1649 | [[package]] 1650 | name = "thiserror" 1651 | version = "1.0.69" 1652 | source = "registry+https://github.com/rust-lang/crates.io-index" 1653 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1654 | dependencies = [ 1655 | "thiserror-impl", 1656 | ] 1657 | 1658 | [[package]] 1659 | name = "thiserror-impl" 1660 | version = "1.0.69" 1661 | source = "registry+https://github.com/rust-lang/crates.io-index" 1662 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1663 | dependencies = [ 1664 | "proc-macro2", 1665 | "quote", 1666 | "syn 2.0.100", 1667 | ] 1668 | 1669 | [[package]] 1670 | name = "tinystr" 1671 | version = "0.7.6" 1672 | source = "registry+https://github.com/rust-lang/crates.io-index" 1673 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 1674 | dependencies = [ 1675 | "displaydoc", 1676 | "zerovec", 1677 | ] 1678 | 1679 | [[package]] 1680 | name = "tokio" 1681 | version = "1.44.1" 1682 | source = "registry+https://github.com/rust-lang/crates.io-index" 1683 | checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" 1684 | dependencies = [ 1685 | "backtrace", 1686 | "bytes", 1687 | "libc", 1688 | "mio 1.0.3", 1689 | "pin-project-lite", 1690 | "socket2", 1691 | "windows-sys 0.52.0", 1692 | ] 1693 | 1694 | [[package]] 1695 | name = "tokio-native-tls" 1696 | version = "0.3.1" 1697 | source = "registry+https://github.com/rust-lang/crates.io-index" 1698 | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 1699 | dependencies = [ 1700 | "native-tls", 1701 | "tokio", 1702 | ] 1703 | 1704 | [[package]] 1705 | name = "tokio-util" 1706 | version = "0.7.14" 1707 | source = "registry+https://github.com/rust-lang/crates.io-index" 1708 | checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" 1709 | dependencies = [ 1710 | "bytes", 1711 | "futures-core", 1712 | "futures-sink", 1713 | "pin-project-lite", 1714 | "tokio", 1715 | ] 1716 | 1717 | [[package]] 1718 | name = "tower-service" 1719 | version = "0.3.3" 1720 | source = "registry+https://github.com/rust-lang/crates.io-index" 1721 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1722 | 1723 | [[package]] 1724 | name = "tracing" 1725 | version = "0.1.41" 1726 | source = "registry+https://github.com/rust-lang/crates.io-index" 1727 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 1728 | dependencies = [ 1729 | "pin-project-lite", 1730 | "tracing-core", 1731 | ] 1732 | 1733 | [[package]] 1734 | name = "tracing-core" 1735 | version = "0.1.33" 1736 | source = "registry+https://github.com/rust-lang/crates.io-index" 1737 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 1738 | dependencies = [ 1739 | "once_cell", 1740 | ] 1741 | 1742 | [[package]] 1743 | name = "try-lock" 1744 | version = "0.2.5" 1745 | source = "registry+https://github.com/rust-lang/crates.io-index" 1746 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1747 | 1748 | [[package]] 1749 | name = "unicode-ident" 1750 | version = "1.0.18" 1751 | source = "registry+https://github.com/rust-lang/crates.io-index" 1752 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1753 | 1754 | [[package]] 1755 | name = "unicode-segmentation" 1756 | version = "1.12.0" 1757 | source = "registry+https://github.com/rust-lang/crates.io-index" 1758 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1759 | 1760 | [[package]] 1761 | name = "unicode-width" 1762 | version = "0.1.14" 1763 | source = "registry+https://github.com/rust-lang/crates.io-index" 1764 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1765 | 1766 | [[package]] 1767 | name = "url" 1768 | version = "2.5.4" 1769 | source = "registry+https://github.com/rust-lang/crates.io-index" 1770 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 1771 | dependencies = [ 1772 | "form_urlencoded", 1773 | "idna", 1774 | "percent-encoding", 1775 | ] 1776 | 1777 | [[package]] 1778 | name = "utf-8" 1779 | version = "0.7.6" 1780 | source = "registry+https://github.com/rust-lang/crates.io-index" 1781 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 1782 | 1783 | [[package]] 1784 | name = "utf16_iter" 1785 | version = "1.0.5" 1786 | source = "registry+https://github.com/rust-lang/crates.io-index" 1787 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 1788 | 1789 | [[package]] 1790 | name = "utf8_iter" 1791 | version = "1.0.4" 1792 | source = "registry+https://github.com/rust-lang/crates.io-index" 1793 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1794 | 1795 | [[package]] 1796 | name = "uuid" 1797 | version = "1.16.0" 1798 | source = "registry+https://github.com/rust-lang/crates.io-index" 1799 | checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" 1800 | dependencies = [ 1801 | "getrandom 0.3.2", 1802 | "serde", 1803 | ] 1804 | 1805 | [[package]] 1806 | name = "vcpkg" 1807 | version = "0.2.15" 1808 | source = "registry+https://github.com/rust-lang/crates.io-index" 1809 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1810 | 1811 | [[package]] 1812 | name = "want" 1813 | version = "0.3.1" 1814 | source = "registry+https://github.com/rust-lang/crates.io-index" 1815 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1816 | dependencies = [ 1817 | "try-lock", 1818 | ] 1819 | 1820 | [[package]] 1821 | name = "wasi" 1822 | version = "0.11.0+wasi-snapshot-preview1" 1823 | source = "registry+https://github.com/rust-lang/crates.io-index" 1824 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1825 | 1826 | [[package]] 1827 | name = "wasi" 1828 | version = "0.14.2+wasi-0.2.4" 1829 | source = "registry+https://github.com/rust-lang/crates.io-index" 1830 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1831 | dependencies = [ 1832 | "wit-bindgen-rt", 1833 | ] 1834 | 1835 | [[package]] 1836 | name = "wasm-bindgen" 1837 | version = "0.2.100" 1838 | source = "registry+https://github.com/rust-lang/crates.io-index" 1839 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1840 | dependencies = [ 1841 | "cfg-if", 1842 | "once_cell", 1843 | "rustversion", 1844 | "wasm-bindgen-macro", 1845 | ] 1846 | 1847 | [[package]] 1848 | name = "wasm-bindgen-backend" 1849 | version = "0.2.100" 1850 | source = "registry+https://github.com/rust-lang/crates.io-index" 1851 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1852 | dependencies = [ 1853 | "bumpalo", 1854 | "log", 1855 | "proc-macro2", 1856 | "quote", 1857 | "syn 2.0.100", 1858 | "wasm-bindgen-shared", 1859 | ] 1860 | 1861 | [[package]] 1862 | name = "wasm-bindgen-futures" 1863 | version = "0.4.50" 1864 | source = "registry+https://github.com/rust-lang/crates.io-index" 1865 | checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 1866 | dependencies = [ 1867 | "cfg-if", 1868 | "js-sys", 1869 | "once_cell", 1870 | "wasm-bindgen", 1871 | "web-sys", 1872 | ] 1873 | 1874 | [[package]] 1875 | name = "wasm-bindgen-macro" 1876 | version = "0.2.100" 1877 | source = "registry+https://github.com/rust-lang/crates.io-index" 1878 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1879 | dependencies = [ 1880 | "quote", 1881 | "wasm-bindgen-macro-support", 1882 | ] 1883 | 1884 | [[package]] 1885 | name = "wasm-bindgen-macro-support" 1886 | version = "0.2.100" 1887 | source = "registry+https://github.com/rust-lang/crates.io-index" 1888 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1889 | dependencies = [ 1890 | "proc-macro2", 1891 | "quote", 1892 | "syn 2.0.100", 1893 | "wasm-bindgen-backend", 1894 | "wasm-bindgen-shared", 1895 | ] 1896 | 1897 | [[package]] 1898 | name = "wasm-bindgen-shared" 1899 | version = "0.2.100" 1900 | source = "registry+https://github.com/rust-lang/crates.io-index" 1901 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1902 | dependencies = [ 1903 | "unicode-ident", 1904 | ] 1905 | 1906 | [[package]] 1907 | name = "web-sys" 1908 | version = "0.3.77" 1909 | source = "registry+https://github.com/rust-lang/crates.io-index" 1910 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 1911 | dependencies = [ 1912 | "js-sys", 1913 | "wasm-bindgen", 1914 | ] 1915 | 1916 | [[package]] 1917 | name = "winapi" 1918 | version = "0.3.9" 1919 | source = "registry+https://github.com/rust-lang/crates.io-index" 1920 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1921 | dependencies = [ 1922 | "winapi-i686-pc-windows-gnu", 1923 | "winapi-x86_64-pc-windows-gnu", 1924 | ] 1925 | 1926 | [[package]] 1927 | name = "winapi-i686-pc-windows-gnu" 1928 | version = "0.4.0" 1929 | source = "registry+https://github.com/rust-lang/crates.io-index" 1930 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1931 | 1932 | [[package]] 1933 | name = "winapi-x86_64-pc-windows-gnu" 1934 | version = "0.4.0" 1935 | source = "registry+https://github.com/rust-lang/crates.io-index" 1936 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1937 | 1938 | [[package]] 1939 | name = "windows-core" 1940 | version = "0.61.0" 1941 | source = "registry+https://github.com/rust-lang/crates.io-index" 1942 | checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" 1943 | dependencies = [ 1944 | "windows-implement", 1945 | "windows-interface", 1946 | "windows-link", 1947 | "windows-result", 1948 | "windows-strings", 1949 | ] 1950 | 1951 | [[package]] 1952 | name = "windows-implement" 1953 | version = "0.60.0" 1954 | source = "registry+https://github.com/rust-lang/crates.io-index" 1955 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 1956 | dependencies = [ 1957 | "proc-macro2", 1958 | "quote", 1959 | "syn 2.0.100", 1960 | ] 1961 | 1962 | [[package]] 1963 | name = "windows-interface" 1964 | version = "0.59.1" 1965 | source = "registry+https://github.com/rust-lang/crates.io-index" 1966 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 1967 | dependencies = [ 1968 | "proc-macro2", 1969 | "quote", 1970 | "syn 2.0.100", 1971 | ] 1972 | 1973 | [[package]] 1974 | name = "windows-link" 1975 | version = "0.1.1" 1976 | source = "registry+https://github.com/rust-lang/crates.io-index" 1977 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 1978 | 1979 | [[package]] 1980 | name = "windows-result" 1981 | version = "0.3.2" 1982 | source = "registry+https://github.com/rust-lang/crates.io-index" 1983 | checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" 1984 | dependencies = [ 1985 | "windows-link", 1986 | ] 1987 | 1988 | [[package]] 1989 | name = "windows-strings" 1990 | version = "0.4.0" 1991 | source = "registry+https://github.com/rust-lang/crates.io-index" 1992 | checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" 1993 | dependencies = [ 1994 | "windows-link", 1995 | ] 1996 | 1997 | [[package]] 1998 | name = "windows-sys" 1999 | version = "0.42.0" 2000 | source = "registry+https://github.com/rust-lang/crates.io-index" 2001 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 2002 | dependencies = [ 2003 | "windows_aarch64_gnullvm 0.42.2", 2004 | "windows_aarch64_msvc 0.42.2", 2005 | "windows_i686_gnu 0.42.2", 2006 | "windows_i686_msvc 0.42.2", 2007 | "windows_x86_64_gnu 0.42.2", 2008 | "windows_x86_64_gnullvm 0.42.2", 2009 | "windows_x86_64_msvc 0.42.2", 2010 | ] 2011 | 2012 | [[package]] 2013 | name = "windows-sys" 2014 | version = "0.48.0" 2015 | source = "registry+https://github.com/rust-lang/crates.io-index" 2016 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 2017 | dependencies = [ 2018 | "windows-targets 0.48.5", 2019 | ] 2020 | 2021 | [[package]] 2022 | name = "windows-sys" 2023 | version = "0.52.0" 2024 | source = "registry+https://github.com/rust-lang/crates.io-index" 2025 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 2026 | dependencies = [ 2027 | "windows-targets 0.52.6", 2028 | ] 2029 | 2030 | [[package]] 2031 | name = "windows-sys" 2032 | version = "0.59.0" 2033 | source = "registry+https://github.com/rust-lang/crates.io-index" 2034 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2035 | dependencies = [ 2036 | "windows-targets 0.52.6", 2037 | ] 2038 | 2039 | [[package]] 2040 | name = "windows-targets" 2041 | version = "0.48.5" 2042 | source = "registry+https://github.com/rust-lang/crates.io-index" 2043 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 2044 | dependencies = [ 2045 | "windows_aarch64_gnullvm 0.48.5", 2046 | "windows_aarch64_msvc 0.48.5", 2047 | "windows_i686_gnu 0.48.5", 2048 | "windows_i686_msvc 0.48.5", 2049 | "windows_x86_64_gnu 0.48.5", 2050 | "windows_x86_64_gnullvm 0.48.5", 2051 | "windows_x86_64_msvc 0.48.5", 2052 | ] 2053 | 2054 | [[package]] 2055 | name = "windows-targets" 2056 | version = "0.52.6" 2057 | source = "registry+https://github.com/rust-lang/crates.io-index" 2058 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 2059 | dependencies = [ 2060 | "windows_aarch64_gnullvm 0.52.6", 2061 | "windows_aarch64_msvc 0.52.6", 2062 | "windows_i686_gnu 0.52.6", 2063 | "windows_i686_gnullvm", 2064 | "windows_i686_msvc 0.52.6", 2065 | "windows_x86_64_gnu 0.52.6", 2066 | "windows_x86_64_gnullvm 0.52.6", 2067 | "windows_x86_64_msvc 0.52.6", 2068 | ] 2069 | 2070 | [[package]] 2071 | name = "windows_aarch64_gnullvm" 2072 | version = "0.42.2" 2073 | source = "registry+https://github.com/rust-lang/crates.io-index" 2074 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 2075 | 2076 | [[package]] 2077 | name = "windows_aarch64_gnullvm" 2078 | version = "0.48.5" 2079 | source = "registry+https://github.com/rust-lang/crates.io-index" 2080 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 2081 | 2082 | [[package]] 2083 | name = "windows_aarch64_gnullvm" 2084 | version = "0.52.6" 2085 | source = "registry+https://github.com/rust-lang/crates.io-index" 2086 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 2087 | 2088 | [[package]] 2089 | name = "windows_aarch64_msvc" 2090 | version = "0.42.2" 2091 | source = "registry+https://github.com/rust-lang/crates.io-index" 2092 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 2093 | 2094 | [[package]] 2095 | name = "windows_aarch64_msvc" 2096 | version = "0.48.5" 2097 | source = "registry+https://github.com/rust-lang/crates.io-index" 2098 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 2099 | 2100 | [[package]] 2101 | name = "windows_aarch64_msvc" 2102 | version = "0.52.6" 2103 | source = "registry+https://github.com/rust-lang/crates.io-index" 2104 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 2105 | 2106 | [[package]] 2107 | name = "windows_i686_gnu" 2108 | version = "0.42.2" 2109 | source = "registry+https://github.com/rust-lang/crates.io-index" 2110 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 2111 | 2112 | [[package]] 2113 | name = "windows_i686_gnu" 2114 | version = "0.48.5" 2115 | source = "registry+https://github.com/rust-lang/crates.io-index" 2116 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 2117 | 2118 | [[package]] 2119 | name = "windows_i686_gnu" 2120 | version = "0.52.6" 2121 | source = "registry+https://github.com/rust-lang/crates.io-index" 2122 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2123 | 2124 | [[package]] 2125 | name = "windows_i686_gnullvm" 2126 | version = "0.52.6" 2127 | source = "registry+https://github.com/rust-lang/crates.io-index" 2128 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2129 | 2130 | [[package]] 2131 | name = "windows_i686_msvc" 2132 | version = "0.42.2" 2133 | source = "registry+https://github.com/rust-lang/crates.io-index" 2134 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 2135 | 2136 | [[package]] 2137 | name = "windows_i686_msvc" 2138 | version = "0.48.5" 2139 | source = "registry+https://github.com/rust-lang/crates.io-index" 2140 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 2141 | 2142 | [[package]] 2143 | name = "windows_i686_msvc" 2144 | version = "0.52.6" 2145 | source = "registry+https://github.com/rust-lang/crates.io-index" 2146 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 2147 | 2148 | [[package]] 2149 | name = "windows_x86_64_gnu" 2150 | version = "0.42.2" 2151 | source = "registry+https://github.com/rust-lang/crates.io-index" 2152 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 2153 | 2154 | [[package]] 2155 | name = "windows_x86_64_gnu" 2156 | version = "0.48.5" 2157 | source = "registry+https://github.com/rust-lang/crates.io-index" 2158 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 2159 | 2160 | [[package]] 2161 | name = "windows_x86_64_gnu" 2162 | version = "0.52.6" 2163 | source = "registry+https://github.com/rust-lang/crates.io-index" 2164 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2165 | 2166 | [[package]] 2167 | name = "windows_x86_64_gnullvm" 2168 | version = "0.42.2" 2169 | source = "registry+https://github.com/rust-lang/crates.io-index" 2170 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 2171 | 2172 | [[package]] 2173 | name = "windows_x86_64_gnullvm" 2174 | version = "0.48.5" 2175 | source = "registry+https://github.com/rust-lang/crates.io-index" 2176 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 2177 | 2178 | [[package]] 2179 | name = "windows_x86_64_gnullvm" 2180 | version = "0.52.6" 2181 | source = "registry+https://github.com/rust-lang/crates.io-index" 2182 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2183 | 2184 | [[package]] 2185 | name = "windows_x86_64_msvc" 2186 | version = "0.42.2" 2187 | source = "registry+https://github.com/rust-lang/crates.io-index" 2188 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 2189 | 2190 | [[package]] 2191 | name = "windows_x86_64_msvc" 2192 | version = "0.48.5" 2193 | source = "registry+https://github.com/rust-lang/crates.io-index" 2194 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 2195 | 2196 | [[package]] 2197 | name = "windows_x86_64_msvc" 2198 | version = "0.52.6" 2199 | source = "registry+https://github.com/rust-lang/crates.io-index" 2200 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2201 | 2202 | [[package]] 2203 | name = "winreg" 2204 | version = "0.50.0" 2205 | source = "registry+https://github.com/rust-lang/crates.io-index" 2206 | checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 2207 | dependencies = [ 2208 | "cfg-if", 2209 | "windows-sys 0.48.0", 2210 | ] 2211 | 2212 | [[package]] 2213 | name = "wit-bindgen-rt" 2214 | version = "0.39.0" 2215 | source = "registry+https://github.com/rust-lang/crates.io-index" 2216 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 2217 | dependencies = [ 2218 | "bitflags 2.9.0", 2219 | ] 2220 | 2221 | [[package]] 2222 | name = "write16" 2223 | version = "1.0.0" 2224 | source = "registry+https://github.com/rust-lang/crates.io-index" 2225 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 2226 | 2227 | [[package]] 2228 | name = "writeable" 2229 | version = "0.5.5" 2230 | source = "registry+https://github.com/rust-lang/crates.io-index" 2231 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 2232 | 2233 | [[package]] 2234 | name = "xml5ever" 2235 | version = "0.17.0" 2236 | source = "registry+https://github.com/rust-lang/crates.io-index" 2237 | checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" 2238 | dependencies = [ 2239 | "log", 2240 | "mac", 2241 | "markup5ever", 2242 | ] 2243 | 2244 | [[package]] 2245 | name = "yoke" 2246 | version = "0.7.5" 2247 | source = "registry+https://github.com/rust-lang/crates.io-index" 2248 | checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 2249 | dependencies = [ 2250 | "serde", 2251 | "stable_deref_trait", 2252 | "yoke-derive", 2253 | "zerofrom", 2254 | ] 2255 | 2256 | [[package]] 2257 | name = "yoke-derive" 2258 | version = "0.7.5" 2259 | source = "registry+https://github.com/rust-lang/crates.io-index" 2260 | checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 2261 | dependencies = [ 2262 | "proc-macro2", 2263 | "quote", 2264 | "syn 2.0.100", 2265 | "synstructure", 2266 | ] 2267 | 2268 | [[package]] 2269 | name = "zerocopy" 2270 | version = "0.8.24" 2271 | source = "registry+https://github.com/rust-lang/crates.io-index" 2272 | checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 2273 | dependencies = [ 2274 | "zerocopy-derive", 2275 | ] 2276 | 2277 | [[package]] 2278 | name = "zerocopy-derive" 2279 | version = "0.8.24" 2280 | source = "registry+https://github.com/rust-lang/crates.io-index" 2281 | checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 2282 | dependencies = [ 2283 | "proc-macro2", 2284 | "quote", 2285 | "syn 2.0.100", 2286 | ] 2287 | 2288 | [[package]] 2289 | name = "zerofrom" 2290 | version = "0.1.6" 2291 | source = "registry+https://github.com/rust-lang/crates.io-index" 2292 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2293 | dependencies = [ 2294 | "zerofrom-derive", 2295 | ] 2296 | 2297 | [[package]] 2298 | name = "zerofrom-derive" 2299 | version = "0.1.6" 2300 | source = "registry+https://github.com/rust-lang/crates.io-index" 2301 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2302 | dependencies = [ 2303 | "proc-macro2", 2304 | "quote", 2305 | "syn 2.0.100", 2306 | "synstructure", 2307 | ] 2308 | 2309 | [[package]] 2310 | name = "zerovec" 2311 | version = "0.10.4" 2312 | source = "registry+https://github.com/rust-lang/crates.io-index" 2313 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 2314 | dependencies = [ 2315 | "yoke", 2316 | "zerofrom", 2317 | "zerovec-derive", 2318 | ] 2319 | 2320 | [[package]] 2321 | name = "zerovec-derive" 2322 | version = "0.10.3" 2323 | source = "registry+https://github.com/rust-lang/crates.io-index" 2324 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 2325 | dependencies = [ 2326 | "proc-macro2", 2327 | "quote", 2328 | "syn 2.0.100", 2329 | ] 2330 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "feedr" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Feedr is a feature-rich terminal-based RSS feed reader written in Rust." 6 | documentation = "https://github.com/bahdotsh/feedr" 7 | homepage = "https://github.com/bahdotsh/feedr" 8 | repository = "https://github.com/bahdotsh/feedr" 9 | keywords = ["rss", "feed-reader", "terminal", "tui", "cli"] 10 | categories = ["command-line-utilities", "text-processing", "web-programming"] 11 | license = "MIT" 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | ratatui = "0.23" 16 | crossterm = "0.27" 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | reqwest = { version = "0.11", features = ["blocking"] } 20 | rss = "2.0" 21 | dirs = "5.0" 22 | html2text = "0.6" 23 | open = "3.2" 24 | chrono = "0.4" 25 | unicode-width = "0.1.11" 26 | uuid = { version = "1.4", features = ["v4", "serde"] } 27 | 28 | [profile.release] 29 | codegen-units = 1 30 | lto = true 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Gokul 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feedr - Terminal RSS Feed Reader 📰 2 | 3 | Feedr is a feature-rich terminal-based RSS feed reader written in Rust. It provides a clean, intuitive TUI interface for managing and reading RSS feeds with elegant visuals and smooth keyboard navigation. 4 | 5 | ![Feedr Terminal RSS Reader](assets/images/feedr.png) 6 | 7 | ## ✨ Features 8 | 9 | - **Dashboard View**: See the latest articles across all your feeds 10 | - **Feed Management**: Subscribe to and organize multiple RSS feeds 11 | - **Rich Content Display**: Beautiful formatting of articles with HTML-to-text conversion 12 | - **Smart Search**: Quickly find content across all your feeds 13 | - **Browser Integration**: Open articles in your default browser 14 | 15 | ## 🚀 Installation 16 | 17 | ### Prerequisites 18 | 19 | - Rust and Cargo (install from [https://rustup.rs/](https://rustup.rs/)) 20 | 21 | ### Using Cargo Install (Recommended) 22 | ```bash 23 | cargo install feedr 24 | ``` 25 | 26 | ### Build from Source 27 | ```bash 28 | git clone https://github.com/bahdotsh/feedr.git 29 | cd feedr 30 | cargo build --release 31 | ``` 32 | 33 | The binary will be available at `target/release/feedr`. 34 | 35 | ## 🎮 Usage 36 | 37 | Run the application: 38 | 39 | ```bash 40 | feedr 41 | ``` 42 | 43 | ### Quick Start 44 | 1. When you open Feedr for the first time, press `a` to add a feed 45 | 2. Enter a valid RSS feed URL (e.g., `https://news.ycombinator.com/rss`) 46 | 3. Use arrow keys to navigate and `Enter` to view items 47 | 4. Press `o` to open the current article in your browser 48 | 49 | ### Keyboard Controls 50 | 51 | #### General Navigation 52 | | Key | Action | 53 | |-----|--------| 54 | | `Tab` | Cycle between views | 55 | | `q` | Quit application | 56 | | `r` | Refresh all feeds | 57 | | `/` | Search mode | 58 | 59 | #### Dashboard View 60 | | Key | Action | 61 | |-----|--------| 62 | | `f` | Go to feeds list | 63 | | `a` | Add a new feed | 64 | | `↑/↓` | Navigate items | 65 | | `Enter` | View selected item | 66 | | `o` | Open link in browser | 67 | 68 | #### Feed List View 69 | | Key | Action | 70 | |-----|--------| 71 | | `h` / `Esc` | Go to dashboard | 72 | | `a` | Add a new feed | 73 | | `d` | Delete selected feed | 74 | | `↑/↓` | Navigate feeds | 75 | | `Enter` | View feed items | 76 | 77 | #### Feed Items View 78 | | Key | Action | 79 | |-----|--------| 80 | | `h` / `Esc` | Back to feeds list | 81 | | `Home` | Go to dashboard | 82 | | `↑/↓` | Navigate items | 83 | | `Enter` | View item details | 84 | | `o` | Open item in browser | 85 | 86 | #### Item Detail View 87 | | Key | Action | 88 | |-----|--------| 89 | | `h` / `Esc` | Back to feed items | 90 | | `Home` | Go to dashboard | 91 | | `o` | Open item in browser | 92 | 93 | ## 🧩 Dependencies 94 | 95 | - **[ratatui](https://github.com/ratatui-org/ratatui)**: Terminal UI framework 96 | - **[crossterm](https://github.com/crossterm-rs/crossterm)**: Terminal manipulation 97 | - **[reqwest](https://github.com/seanmonstar/reqwest)**: HTTP client 98 | - **[rss](https://github.com/rust-syndication/rss)**: RSS parsing 99 | - **[html2text](https://github.com/servo/html5ever)**: HTML to text conversion 100 | - **[chrono](https://github.com/chronotope/chrono)**: Date and time handling 101 | - **[serde](https://github.com/serde-rs/serde)**: Serialization/deserialization 102 | 103 | ## 📜 License 104 | 105 | MIT 106 | 107 | ## 🤝 Contributing 108 | 109 | Contributions are welcome! Please feel free to submit a Pull Request. 110 | 111 | 1. Fork the repository 112 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 113 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 114 | 4. Push to the branch (`git push origin feature/amazing-feature`) 115 | 5. Open a Pull Request 116 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahdotsh/feedr/56ab43629e3b14ddda6406e85e983eb07bf979fa/assets/.DS_Store -------------------------------------------------------------------------------- /assets/images/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahdotsh/feedr/56ab43629e3b14ddda6406e85e983eb07bf979fa/assets/images/.DS_Store -------------------------------------------------------------------------------- /assets/images/feedr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahdotsh/feedr/56ab43629e3b14ddda6406e85e983eb07bf979fa/assets/images/feedr.png -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::feed::{Feed, FeedCategory, FeedItem}; 2 | use crate::ui::extract_domain; 3 | use anyhow::Result; 4 | use chrono::{DateTime, Utc}; 5 | use serde::{Deserialize, Serialize}; 6 | use std::fs; 7 | use std::path::Path; 8 | use std::collections::HashMap; 9 | 10 | #[derive(Clone, Debug, Default)] 11 | pub struct FilterOptions { 12 | pub category: Option, // Filter by feed category 13 | pub age: Option, // Filter by content age 14 | pub has_author: Option, // Filter for items with/without author 15 | pub read_status: Option, // Filter for read/unread items 16 | pub min_length: Option, // Filter by content length 17 | } 18 | 19 | #[derive(Clone, Debug, PartialEq)] 20 | pub enum TimeFilter { 21 | Today, 22 | ThisWeek, 23 | ThisMonth, 24 | Older, 25 | } 26 | 27 | impl FilterOptions { 28 | pub fn new() -> Self { 29 | Self::default() 30 | } 31 | 32 | pub fn is_active(&self) -> bool { 33 | self.category.is_some() 34 | || self.age.is_some() 35 | || self.has_author.is_some() 36 | || self.read_status.is_some() 37 | || self.min_length.is_some() 38 | } 39 | 40 | pub fn reset(&mut self) { 41 | *self = Self::default(); 42 | } 43 | } 44 | 45 | #[derive(Clone, Debug, PartialEq)] 46 | pub enum InputMode { 47 | Normal, 48 | InsertUrl, 49 | SearchMode, 50 | FilterMode, 51 | CategoryMode, // For category management 52 | CategoryNameInput, // For creating/renaming categories 53 | } 54 | 55 | #[derive(Clone, Debug, PartialEq)] 56 | pub enum View { 57 | Dashboard, 58 | FeedList, 59 | FeedItems, 60 | FeedItemDetail, 61 | CategoryManagement, 62 | } 63 | 64 | #[derive(Clone, Debug)] 65 | pub struct App { 66 | pub feeds: Vec, 67 | pub bookmarks: Vec, 68 | pub categories: Vec, 69 | pub selected_category: Option, 70 | pub input: String, 71 | pub input_mode: InputMode, 72 | pub selected_feed: Option, 73 | pub selected_item: Option, 74 | pub view: View, 75 | pub error: Option, 76 | pub search_query: String, 77 | pub is_searching: bool, 78 | pub filtered_items: Vec<(usize, usize)>, // (feed_idx, item_idx) for search results 79 | pub dashboard_items: Vec<(usize, usize)>, // (feed_idx, item_idx) for dashboard 80 | pub is_loading: bool, // Flag to indicate loading/refreshing state 81 | pub loading_indicator: usize, // For animated loading indicator 82 | pub filter_options: FilterOptions, 83 | pub filter_mode: bool, // Whether we're in filter selection mode 84 | pub read_items: Vec, // Track read item IDs 85 | pub filtered_dashboard_items: Vec<(usize, usize)>, // Filtered items for dashboard 86 | pub category_action: Option, // For category management 87 | } 88 | 89 | #[derive(Clone, Debug)] 90 | pub enum CategoryAction { 91 | Create, 92 | Rename(usize), 93 | AddFeedToCategory(String), // Feed URL to add 94 | } 95 | 96 | #[derive(Serialize, Deserialize)] 97 | struct SavedData { 98 | bookmarks: Vec, 99 | categories: Vec, 100 | read_items: Vec, 101 | } 102 | 103 | impl App { 104 | pub fn new() -> Self { 105 | let saved_data = Self::load_saved_data().unwrap_or_else(|_| SavedData { 106 | bookmarks: vec![], 107 | categories: vec![], 108 | read_items: vec![], 109 | }); 110 | 111 | let mut app = Self { 112 | feeds: Vec::new(), 113 | bookmarks: saved_data.bookmarks, 114 | categories: saved_data.categories, 115 | selected_category: None, 116 | input: String::new(), 117 | input_mode: InputMode::Normal, 118 | selected_feed: None, 119 | selected_item: None, 120 | view: View::Dashboard, 121 | error: None, 122 | search_query: String::new(), 123 | is_searching: false, 124 | filtered_items: Vec::new(), 125 | dashboard_items: Vec::new(), 126 | is_loading: false, 127 | loading_indicator: 0, 128 | filter_options: FilterOptions::new(), 129 | filter_mode: false, 130 | read_items: saved_data.read_items, 131 | filtered_dashboard_items: Vec::new(), 132 | category_action: None, 133 | }; 134 | 135 | // Load bookmarked feeds 136 | app.load_bookmarked_feeds(); 137 | app.update_dashboard(); 138 | 139 | app 140 | } 141 | 142 | pub fn load_bookmarked_feeds(&mut self) { 143 | self.feeds.clear(); 144 | for url in &self.bookmarks { 145 | match Feed::from_url(url) { 146 | Ok(feed) => self.feeds.push(feed), 147 | Err(_) => { /* Skip failed feeds */ } 148 | } 149 | } 150 | } 151 | 152 | fn load_saved_data() -> Result { 153 | let path = Self::data_path(); 154 | if !path.exists() { 155 | return Ok(SavedData { 156 | bookmarks: Vec::new(), 157 | categories: Vec::new(), 158 | read_items: Vec::new(), 159 | }); 160 | } 161 | 162 | let data = fs::read_to_string(path)?; 163 | let saved_data: SavedData = serde_json::from_str(&data)?; 164 | Ok(saved_data) 165 | } 166 | 167 | fn save_data(&self) -> Result<()> { 168 | let path = Self::data_path(); 169 | if let Some(dir) = path.parent() { 170 | fs::create_dir_all(dir)?; 171 | } 172 | 173 | let saved_data = SavedData { 174 | bookmarks: self.bookmarks.clone(), 175 | categories: self.categories.clone(), 176 | read_items: self.read_items.clone(), 177 | }; 178 | 179 | let json = serde_json::to_string(&saved_data)?; 180 | fs::write(path, json)?; 181 | Ok(()) 182 | } 183 | 184 | fn data_path() -> std::path::PathBuf { 185 | let mut path = dirs::data_dir().unwrap_or_else(|| Path::new(".").to_path_buf()); 186 | path.push("feedr"); 187 | path.push("feedr_data.json"); 188 | path 189 | } 190 | 191 | pub fn apply_filters(&mut self) { 192 | // First update the dashboard items normally 193 | if !self.filter_options.is_active() { 194 | // No filters active, so filtered items are the same as dashboard items 195 | self.filtered_dashboard_items = self.dashboard_items.clone(); 196 | return; 197 | } 198 | 199 | self.filtered_dashboard_items = self 200 | .dashboard_items 201 | .iter() 202 | .filter(|&&(feed_idx, item_idx)| self.item_matches_filter(feed_idx, item_idx)) 203 | .cloned() 204 | .collect(); 205 | } 206 | 207 | fn item_matches_filter(&self, feed_idx: usize, item_idx: usize) -> bool { 208 | let feed = match self.feeds.get(feed_idx) { 209 | Some(f) => f, 210 | None => return false, 211 | }; 212 | 213 | let item = match feed.items.get(item_idx) { 214 | Some(i) => i, 215 | None => return false, 216 | }; 217 | 218 | // Check category filter 219 | if let Some(category) = &self.filter_options.category { 220 | // Use feed URL to infer category 221 | let feed_domain = extract_domain(&feed.url); 222 | if !feed_domain.contains(category) { 223 | return false; 224 | } 225 | } 226 | 227 | // Check age filter 228 | if let Some(age_filter) = &self.filter_options.age { 229 | if let Some(date_str) = &item.pub_date { 230 | if let Ok(date) = DateTime::parse_from_rfc2822(date_str) { 231 | let now = Utc::now(); 232 | let duration = now.signed_duration_since(date.with_timezone(&Utc)); 233 | 234 | match age_filter { 235 | TimeFilter::Today => { 236 | if duration.num_hours() > 24 { 237 | return false; 238 | } 239 | } 240 | TimeFilter::ThisWeek => { 241 | if duration.num_days() > 7 { 242 | return false; 243 | } 244 | } 245 | TimeFilter::ThisMonth => { 246 | if duration.num_days() > 30 { 247 | return false; 248 | } 249 | } 250 | TimeFilter::Older => { 251 | if duration.num_days() <= 30 { 252 | return false; 253 | } 254 | } 255 | } 256 | } else { 257 | // Can't parse date, so filter out if age filter is active 258 | return false; 259 | } 260 | } else { 261 | // No date, so filter out if age filter is active 262 | return false; 263 | } 264 | } 265 | 266 | // Check author filter 267 | if let Some(has_author) = self.filter_options.has_author { 268 | let item_has_author = 269 | item.author.is_some() && !item.author.as_ref().unwrap().is_empty(); 270 | if has_author != item_has_author { 271 | return false; 272 | } 273 | } 274 | 275 | // Check read status filter 276 | if let Some(is_read) = self.filter_options.read_status { 277 | let item_id = self.get_item_id(feed_idx, item_idx); 278 | let item_is_read = self.read_items.contains(&item_id); 279 | if is_read != item_is_read { 280 | return false; 281 | } 282 | } 283 | 284 | // Check content length filter 285 | if let Some(min_length) = self.filter_options.min_length { 286 | if let Some(desc) = &item.description { 287 | let plain_text = html2text::from_read(desc.as_bytes(), 80); 288 | if plain_text.len() < min_length { 289 | return false; 290 | } 291 | } else { 292 | // No description, so it doesn't meet length requirement 293 | return false; 294 | } 295 | } 296 | 297 | true 298 | } 299 | 300 | // Generate a unique ID for an item to track read status 301 | fn get_item_id(&self, feed_idx: usize, item_idx: usize) -> String { 302 | if let Some(feed) = self.feeds.get(feed_idx) { 303 | if let Some(item) = feed.items.get(item_idx) { 304 | if let Some(link) = &item.link { 305 | return link.clone(); 306 | } 307 | return format!("{}_{}", feed.url, item.title); 308 | } 309 | } 310 | String::new() 311 | } 312 | 313 | // Mark an item as read 314 | pub fn mark_item_as_read(&mut self, feed_idx: usize, item_idx: usize) -> Result<()> { 315 | let item_id = self.get_item_id(feed_idx, item_idx); 316 | if !item_id.is_empty() && !self.read_items.contains(&item_id) { 317 | self.read_items.push(item_id); 318 | self.save_data()?; 319 | } 320 | Ok(()) 321 | } 322 | 323 | // Check if an item is read 324 | pub fn is_item_read(&self, feed_idx: usize, item_idx: usize) -> bool { 325 | let item_id = self.get_item_id(feed_idx, item_idx); 326 | self.read_items.contains(&item_id) 327 | } 328 | 329 | pub fn update_dashboard(&mut self) { 330 | // Clear existing dashboard items 331 | self.dashboard_items.clear(); 332 | 333 | // Get all feeds and sort by most recent first 334 | let mut all_items = Vec::new(); 335 | 336 | for (feed_idx, feed) in self.feeds.iter().enumerate() { 337 | for (item_idx, item) in feed.items.iter().enumerate() { 338 | let date = item.pub_date.as_ref().and_then(|date_str| { 339 | DateTime::parse_from_rfc2822(date_str).ok() 340 | }); 341 | 342 | all_items.push((feed_idx, item_idx, date)); 343 | } 344 | } 345 | 346 | // Sort by date (most recent first) 347 | all_items.sort_by(|a, b| { 348 | match (&a.2, &b.2) { 349 | (Some(a_date), Some(b_date)) => b_date.cmp(a_date), 350 | (Some(_), None) => std::cmp::Ordering::Less, 351 | (None, Some(_)) => std::cmp::Ordering::Greater, 352 | (None, None) => std::cmp::Ordering::Equal, 353 | } 354 | }); 355 | 356 | // Add items to dashboard (limited to most recent 100 for performance) 357 | for (feed_idx, item_idx, _) in all_items.into_iter().take(100) { 358 | self.dashboard_items.push((feed_idx, item_idx)); 359 | } 360 | 361 | // Apply any active filters 362 | self.apply_filters(); 363 | } 364 | 365 | pub fn add_feed(&mut self, url: &str) -> Result<()> { 366 | let feed = Feed::from_url(url)?; 367 | self.feeds.push(feed); 368 | if !self.bookmarks.contains(&url.to_string()) { 369 | self.bookmarks.push(url.to_string()); 370 | } 371 | self.update_dashboard(); 372 | 373 | // Save data 374 | self.save_data()?; 375 | 376 | Ok(()) 377 | } 378 | 379 | pub fn remove_current_feed(&mut self) -> Result<()> { 380 | if let Some(idx) = self.selected_feed { 381 | if idx < self.feeds.len() { 382 | let url = self.feeds[idx].url.clone(); 383 | 384 | // Remove from feeds 385 | self.feeds.remove(idx); 386 | 387 | // Remove from bookmarks 388 | if let Some(pos) = self.bookmarks.iter().position(|x| x == &url) { 389 | self.bookmarks.remove(pos); 390 | } 391 | 392 | // Remove from all categories 393 | for category in &mut self.categories { 394 | category.remove_feed(&url); 395 | } 396 | 397 | // Update selected feed 398 | if !self.feeds.is_empty() { 399 | if idx >= self.feeds.len() { 400 | self.selected_feed = Some(self.feeds.len() - 1); 401 | } 402 | } else { 403 | self.selected_feed = None; 404 | self.view = View::Dashboard; 405 | } 406 | 407 | // Update dashboard 408 | self.update_dashboard(); 409 | 410 | // Save changes 411 | self.save_data()?; 412 | } 413 | } 414 | 415 | Ok(()) 416 | } 417 | 418 | pub fn current_feed(&self) -> Option<&Feed> { 419 | self.selected_feed.and_then(|idx| self.feeds.get(idx)) 420 | } 421 | 422 | pub fn current_item(&self) -> Option<&FeedItem> { 423 | self.current_feed() 424 | .and_then(|feed| self.selected_item.and_then(|idx| feed.items.get(idx))) 425 | } 426 | 427 | pub fn dashboard_item(&self, idx: usize) -> Option<(&Feed, &FeedItem)> { 428 | if idx < self.dashboard_items.len() { 429 | let (feed_idx, item_idx) = self.dashboard_items[idx]; 430 | if let Some(feed) = self.feeds.get(feed_idx) { 431 | if let Some(item) = feed.items.get(item_idx) { 432 | return Some((feed, item)); 433 | } 434 | } 435 | } 436 | None 437 | } 438 | 439 | pub fn open_current_item_in_browser(&self) -> Result<()> { 440 | if let Some(item) = self.current_item() { 441 | if let Some(link) = &item.link { 442 | open::that(link)?; 443 | } 444 | } 445 | Ok(()) 446 | } 447 | 448 | pub fn search_feeds(&mut self, query: &str) { 449 | self.search_query = query.to_lowercase(); 450 | self.is_searching = !query.is_empty(); 451 | 452 | if !self.is_searching { 453 | return; 454 | } 455 | 456 | self.filtered_items.clear(); 457 | for (feed_idx, feed) in self.feeds.iter().enumerate() { 458 | if feed.title.to_lowercase().contains(&self.search_query) { 459 | // Add all items from matching feed 460 | for item_idx in 0..feed.items.len() { 461 | self.filtered_items.push((feed_idx, item_idx)); 462 | } 463 | } else { 464 | // Check individual items 465 | for (item_idx, item) in feed.items.iter().enumerate() { 466 | if item.title.to_lowercase().contains(&self.search_query) 467 | || item 468 | .description 469 | .as_ref() 470 | .map_or(false, |d| d.to_lowercase().contains(&self.search_query)) 471 | { 472 | self.filtered_items.push((feed_idx, item_idx)); 473 | } 474 | } 475 | } 476 | } 477 | } 478 | 479 | pub fn search_item(&self, idx: usize) -> Option<(&Feed, &FeedItem)> { 480 | if idx < self.filtered_items.len() { 481 | let (feed_idx, item_idx) = self.filtered_items[idx]; 482 | if let Some(feed) = self.feeds.get(feed_idx) { 483 | if let Some(item) = feed.items.get(item_idx) { 484 | return Some((feed, item)); 485 | } 486 | } 487 | } 488 | None 489 | } 490 | 491 | pub fn refresh_feeds(&mut self) -> Result<()> { 492 | self.is_loading = true; 493 | 494 | // Instead of threading, we'll do a synchronous refresh 495 | // but track the loading state to show the animation 496 | let urls = self.bookmarks.clone(); 497 | self.feeds.clear(); 498 | 499 | for url in &urls { 500 | match Feed::from_url(url) { 501 | Ok(feed) => self.feeds.push(feed), 502 | Err(e) => self.error = Some(format!("Failed to refresh feed {}: {}", url, e)), 503 | } 504 | } 505 | 506 | self.update_dashboard(); 507 | self.is_loading = false; 508 | 509 | Ok(()) 510 | } 511 | 512 | pub fn update_loading_indicator(&mut self) { 513 | self.loading_indicator = (self.loading_indicator + 1) % 10; 514 | } 515 | 516 | pub fn get_available_categories(&self) -> Vec { 517 | // Extract potential categories from feed domains 518 | let mut categories = std::collections::HashSet::new(); 519 | 520 | for feed in &self.feeds { 521 | let domain = extract_domain(&feed.url); 522 | 523 | // Try to extract a category from the domain 524 | if domain.contains("news") || domain.contains("nytimes") || domain.contains("cnn") { 525 | categories.insert("news".to_string()); 526 | } else if domain.contains("tech") 527 | || domain.contains("wired") 528 | || domain.contains("ycombinator") 529 | { 530 | categories.insert("tech".to_string()); 531 | } else if domain.contains("science") 532 | || domain.contains("nature") 533 | || domain.contains("scientific") 534 | { 535 | categories.insert("science".to_string()); 536 | } else if domain.contains("finance") 537 | || domain.contains("money") 538 | || domain.contains("business") 539 | { 540 | categories.insert("finance".to_string()); 541 | } else if domain.contains("sport") 542 | || domain.contains("espn") 543 | || domain.contains("athletic") 544 | { 545 | categories.insert("sports".to_string()); 546 | } else { 547 | // Use the first part of the domain as a fallback category 548 | if let Some(first_part) = domain.split('.').next() { 549 | categories.insert(first_part.to_string()); 550 | } 551 | } 552 | } 553 | 554 | let mut result: Vec = categories.into_iter().collect(); 555 | result.sort(); 556 | result 557 | } 558 | 559 | pub fn get_filter_stats(&self) -> (usize, usize, usize) { 560 | let active_count = [ 561 | self.filter_options.category.is_some(), 562 | self.filter_options.age.is_some(), 563 | self.filter_options.has_author.is_some(), 564 | self.filter_options.read_status.is_some(), 565 | self.filter_options.min_length.is_some(), 566 | ] 567 | .iter() 568 | .filter(|&&x| x) 569 | .count(); 570 | 571 | let filtered_count = if self.is_searching { 572 | self.filtered_items.len() 573 | } else { 574 | self.filtered_dashboard_items.len() 575 | }; 576 | 577 | let total_count = if self.is_searching { 578 | self.filtered_items.len() 579 | } else { 580 | self.dashboard_items.len() 581 | }; 582 | 583 | (active_count, filtered_count, total_count) 584 | } 585 | 586 | pub fn get_filter_summary(&self) -> String { 587 | let mut parts = Vec::new(); 588 | 589 | if let Some(category) = &self.filter_options.category { 590 | parts.push(format!("Category: {}", category)); 591 | } 592 | 593 | if let Some(age) = &self.filter_options.age { 594 | let age_str = match age { 595 | TimeFilter::Today => "Today", 596 | TimeFilter::ThisWeek => "This Week", 597 | TimeFilter::ThisMonth => "This Month", 598 | TimeFilter::Older => "Older than a month", 599 | }; 600 | parts.push(format!("Age: {}", age_str)); 601 | } 602 | 603 | if let Some(has_author) = self.filter_options.has_author { 604 | parts.push(format!( 605 | "Author: {}", 606 | if has_author { 607 | "With author" 608 | } else { 609 | "No author" 610 | } 611 | )); 612 | } 613 | 614 | if let Some(is_read) = self.filter_options.read_status { 615 | parts.push(format!( 616 | "Status: {}", 617 | if is_read { "Read" } else { "Unread" } 618 | )); 619 | } 620 | 621 | if let Some(length) = self.filter_options.min_length { 622 | let length_str = match length { 623 | 100 => "Short", 624 | 500 => "Medium", 625 | 1000 => "Long", 626 | _ => "Custom", 627 | }; 628 | parts.push(format!("Length: {}", length_str)); 629 | } 630 | 631 | if parts.is_empty() { 632 | "No filters active".to_string() 633 | } else { 634 | parts.join(" | ") 635 | } 636 | } 637 | 638 | // Category management functions 639 | pub fn create_category(&mut self, name: &str) -> Result<()> { 640 | // Trim the name and check if it's empty 641 | let name = name.trim(); 642 | if name.is_empty() { 643 | return Err(anyhow::anyhow!("Category name cannot be empty")); 644 | } 645 | 646 | // Check if category with same name exists 647 | if self.categories.iter().any(|c| c.name == name) { 648 | return Err(anyhow::anyhow!("Category with this name already exists")); 649 | } 650 | 651 | // Create and add the new category 652 | let category = FeedCategory::new(name); 653 | self.categories.push(category); 654 | self.selected_category = Some(self.categories.len() - 1); 655 | 656 | // Save categories 657 | self.save_data()?; 658 | 659 | Ok(()) 660 | } 661 | 662 | pub fn delete_category(&mut self, idx: usize) -> Result<()> { 663 | if idx >= self.categories.len() { 664 | return Err(anyhow::anyhow!("Invalid category index")); 665 | } 666 | 667 | self.categories.remove(idx); 668 | if !self.categories.is_empty() && self.selected_category.is_some() { 669 | if self.selected_category.unwrap() >= self.categories.len() { 670 | self.selected_category = Some(self.categories.len() - 1); 671 | } 672 | } else { 673 | self.selected_category = None; 674 | } 675 | 676 | // Save categories 677 | self.save_data()?; 678 | 679 | Ok(()) 680 | } 681 | 682 | pub fn rename_category(&mut self, idx: usize, new_name: &str) -> Result<()> { 683 | let new_name = new_name.trim(); 684 | if new_name.is_empty() { 685 | return Err(anyhow::anyhow!("Category name cannot be empty")); 686 | } 687 | 688 | // Check if another category already has this name 689 | if self.categories.iter().enumerate().any(|(i, c)| i != idx && c.name == new_name) { 690 | return Err(anyhow::anyhow!("Category with this name already exists")); 691 | } 692 | 693 | if idx < self.categories.len() { 694 | self.categories[idx].rename(new_name); 695 | self.save_data()?; 696 | Ok(()) 697 | } else { 698 | Err(anyhow::anyhow!("Invalid category index")) 699 | } 700 | } 701 | 702 | pub fn assign_feed_to_category(&mut self, feed_url: &str, category_idx: usize) -> Result<()> { 703 | if category_idx >= self.categories.len() { 704 | return Err(anyhow::anyhow!("Invalid category index")); 705 | } 706 | 707 | // Add feed to the selected category 708 | self.categories[category_idx].add_feed(feed_url); 709 | 710 | // Save the updated categories 711 | self.save_data()?; 712 | 713 | Ok(()) 714 | } 715 | 716 | pub fn remove_feed_from_category(&mut self, feed_url: &str, category_idx: usize) -> Result<()> { 717 | if category_idx >= self.categories.len() { 718 | return Err(anyhow::anyhow!("Invalid category index")); 719 | } 720 | 721 | let removed = self.categories[category_idx].remove_feed(feed_url); 722 | if removed { 723 | self.save_data()?; 724 | Ok(()) 725 | } else { 726 | Err(anyhow::anyhow!("Feed not found in category")) 727 | } 728 | } 729 | 730 | pub fn toggle_category_expanded(&mut self, idx: usize) -> Result<()> { 731 | if idx < self.categories.len() { 732 | self.categories[idx].toggle_expanded(); 733 | Ok(()) 734 | } else { 735 | Err(anyhow::anyhow!("Invalid category index")) 736 | } 737 | } 738 | 739 | pub fn get_feeds_by_category(&self) -> HashMap, Vec<(usize, &Feed)>> { 740 | let mut result = HashMap::new(); 741 | 742 | // Add 'Uncategorized' group 743 | result.insert(None, Vec::new()); 744 | 745 | // Prepare category name lookup by feed URL 746 | let feed_to_category: HashMap<&str, &str> = self.categories.iter() 747 | .flat_map(|cat| cat.feeds.iter().map(move |url| (url.as_str(), cat.name.as_str()))) 748 | .collect(); 749 | 750 | // Group feeds by their category 751 | for (idx, feed) in self.feeds.iter().enumerate() { 752 | let category_name = feed_to_category.get(feed.url.as_str()).map(|&name| name.to_string()); 753 | result.entry(category_name).or_insert_with(Vec::new).push((idx, feed)); 754 | } 755 | 756 | result 757 | } 758 | 759 | // When managing categories in UI 760 | pub fn get_category_for_feed(&self, feed_url: &str) -> Option { 761 | self.categories.iter().position(|c| c.contains_feed(feed_url)) 762 | } 763 | 764 | // Helper to get all feeds in a category 765 | pub fn get_feeds_in_category(&self, category_idx: usize) -> Vec<(usize, &Feed)> { 766 | if category_idx >= self.categories.len() { 767 | return Vec::new(); 768 | } 769 | 770 | let category = &self.categories[category_idx]; 771 | self.feeds.iter().enumerate() 772 | .filter(|(_, feed)| category.contains_feed(&feed.url)) 773 | .collect() 774 | } 775 | 776 | // Get uncategorized feeds 777 | pub fn get_uncategorized_feeds(&self) -> Vec<(usize, &Feed)> { 778 | // Create a set of all feeds that are in categories 779 | let categorized_feeds: std::collections::HashSet<&str> = self.categories.iter() 780 | .flat_map(|cat| cat.feeds.iter().map(|url| url.as_str())) 781 | .collect(); 782 | 783 | // Return feeds that aren't in any category 784 | self.feeds.iter().enumerate() 785 | .filter(|(_, feed)| !categorized_feeds.contains(feed.url.as_str())) 786 | .collect() 787 | } 788 | } 789 | -------------------------------------------------------------------------------- /src/feed.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::{DateTime, Utc}; 3 | use rss::{Channel, Item}; 4 | use std::time::Duration; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashSet; 7 | use uuid::Uuid; 8 | 9 | #[derive(Clone, Debug, Serialize, Deserialize)] 10 | pub struct Feed { 11 | pub url: String, 12 | pub title: String, 13 | pub items: Vec, 14 | } 15 | 16 | #[derive(Clone, Debug, Serialize, Deserialize)] 17 | pub struct FeedItem { 18 | pub title: String, 19 | pub link: Option, 20 | pub description: Option, 21 | pub pub_date: Option, 22 | pub author: Option, 23 | pub formatted_date: Option, 24 | } 25 | 26 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 27 | pub struct FeedCategory { 28 | pub id: String, 29 | pub name: String, 30 | pub feeds: HashSet, // URLs of feeds in this category, using HashSet for faster lookup 31 | pub expanded: bool, // UI state: whether the category is expanded in the UI 32 | } 33 | 34 | impl FeedCategory { 35 | pub fn new(name: &str) -> Self { 36 | Self { 37 | id: Uuid::new_v4().to_string(), 38 | name: name.to_string(), 39 | feeds: HashSet::new(), 40 | expanded: true, 41 | } 42 | } 43 | 44 | pub fn add_feed(&mut self, url: &str) { 45 | self.feeds.insert(url.to_string()); 46 | } 47 | 48 | pub fn remove_feed(&mut self, url: &str) -> bool { 49 | self.feeds.remove(url) 50 | } 51 | 52 | pub fn contains_feed(&self, url: &str) -> bool { 53 | self.feeds.contains(url) 54 | } 55 | 56 | pub fn is_empty(&self) -> bool { 57 | self.feeds.is_empty() 58 | } 59 | 60 | pub fn feed_count(&self) -> usize { 61 | self.feeds.len() 62 | } 63 | 64 | pub fn rename(&mut self, new_name: &str) { 65 | self.name = new_name.to_string(); 66 | } 67 | 68 | pub fn toggle_expanded(&mut self) { 69 | self.expanded = !self.expanded; 70 | } 71 | } 72 | 73 | impl Feed { 74 | pub fn from_url(url: &str) -> Result { 75 | let content = reqwest::blocking::Client::new() 76 | .get(url) 77 | .timeout(Duration::from_secs(10)) 78 | .send() 79 | .context("Failed to fetch feed")? 80 | .bytes() 81 | .context("Failed to read response body")?; 82 | 83 | let channel = Channel::read_from(&content[..]).context("Failed to parse RSS feed")?; 84 | 85 | let items = channel 86 | .items() 87 | .iter() 88 | .map(|item| FeedItem::from_rss_item(item)) 89 | .collect(); 90 | 91 | Ok(Feed { 92 | url: url.to_string(), 93 | title: channel.title().to_string(), 94 | items, 95 | }) 96 | } 97 | } 98 | 99 | impl FeedItem { 100 | fn from_rss_item(item: &Item) -> Self { 101 | // Format the date for better display 102 | let formatted_date = item.pub_date().and_then(|date_str| { 103 | DateTime::parse_from_rfc2822(date_str) 104 | .ok() 105 | .map(|dt| format_date(dt.with_timezone(&Utc))) 106 | }); 107 | 108 | FeedItem { 109 | title: item.title().unwrap_or("Untitled").to_string(), 110 | // Using map for Option<&str> to Option conversion 111 | link: item.link().map(ToString::to_string), 112 | description: item.description().map(ToString::to_string), 113 | pub_date: item.pub_date().map(ToString::to_string), 114 | author: item.author().map(ToString::to_string), 115 | formatted_date, 116 | } 117 | } 118 | } 119 | 120 | fn format_date(dt: DateTime) -> String { 121 | // Calculate how long ago the item was published 122 | let now = Utc::now(); 123 | let diff = now.signed_duration_since(dt); 124 | 125 | if diff.num_minutes() < 60 { 126 | format!("{} minutes ago", diff.num_minutes()) 127 | } else if diff.num_hours() < 24 { 128 | format!("{} hours ago", diff.num_hours()) 129 | } else if diff.num_days() < 7 { 130 | format!("{} days ago", diff.num_days()) 131 | } else { 132 | // For older items, show the actual date 133 | dt.format("%B %d, %Y").to_string() 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod feed; 3 | mod tui; 4 | mod ui; 5 | 6 | use anyhow::Result; 7 | use app::App; 8 | 9 | fn main() -> Result<()> { 10 | // Initialize the application 11 | let app = App::new(); 12 | 13 | // Run the terminal UI 14 | tui::run(app)?; 15 | 16 | Ok(()) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, CategoryAction, InputMode, TimeFilter, View}; 2 | use crate::ui; 3 | use anyhow::Result; 4 | use crossterm::{ 5 | event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, 6 | execute, 7 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 8 | }; 9 | use ratatui::{ 10 | backend::{Backend, CrosstermBackend}, 11 | Terminal, 12 | }; 13 | use std::{io, time::Duration}; 14 | 15 | pub fn run(mut app: App) -> Result<()> { 16 | // Set up terminal 17 | enable_raw_mode()?; 18 | let mut stdout = io::stdout(); 19 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 20 | let backend = CrosstermBackend::new(stdout); 21 | let mut terminal = Terminal::new(backend)?; 22 | 23 | // Run the main application loop 24 | let result = run_app(&mut terminal, &mut app); 25 | 26 | // Clean up terminal 27 | disable_raw_mode()?; 28 | execute!( 29 | terminal.backend_mut(), 30 | LeaveAlternateScreen, 31 | DisableMouseCapture 32 | )?; 33 | terminal.show_cursor()?; 34 | 35 | // Handle any errors from the application 36 | if let Err(err) = result { 37 | println!("Error: {:?}", err); 38 | } 39 | 40 | Ok(()) 41 | } 42 | 43 | fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> { 44 | let mut last_tick = std::time::Instant::now(); 45 | let tick_rate = Duration::from_millis(100); // 100ms for smooth animation 46 | 47 | loop { 48 | terminal.draw(|f| ui::render(f, app))?; 49 | 50 | // If loading, use a shorter timeout for animation 51 | let timeout = if app.is_loading { 52 | tick_rate 53 | .checked_sub(last_tick.elapsed()) 54 | .unwrap_or_else(|| Duration::from_secs(0)) 55 | } else if app.error.is_some() { 56 | Duration::from_millis(3000) 57 | } else { 58 | Duration::from_millis(100) 59 | }; 60 | 61 | if event::poll(timeout)? { 62 | // Handle user input 63 | if handle_events(app)? { 64 | return Ok(()); 65 | } 66 | } else if last_tick.elapsed() >= tick_rate { 67 | // Update animation frame on tick 68 | if app.is_loading { 69 | app.update_loading_indicator(); 70 | } 71 | 72 | // Clear error after timeout 73 | if app.error.is_some() && last_tick.elapsed() >= Duration::from_millis(3000) { 74 | app.error = None; 75 | } 76 | 77 | last_tick = std::time::Instant::now(); 78 | } 79 | } 80 | } 81 | 82 | fn handle_events(app: &mut App) -> Result { 83 | if let Event::Key(key) = event::read()? { 84 | match app.input_mode { 85 | InputMode::Normal => match app.view { 86 | View::Dashboard => match key.code { 87 | KeyCode::Char('q') => return Ok(true), 88 | KeyCode::Char('f') => { 89 | app.filter_mode = true; 90 | app.input_mode = InputMode::FilterMode; 91 | } 92 | KeyCode::Char('c') => { 93 | if key.modifiers.contains(KeyModifiers::CONTROL) { 94 | // Switch to category management view 95 | app.view = View::CategoryManagement; 96 | app.selected_category = if !app.categories.is_empty() { 97 | Some(0) 98 | } else { 99 | None 100 | }; 101 | } else { 102 | // Get available categories 103 | let categories = app.get_available_categories(); 104 | 105 | if categories.is_empty() { 106 | // No categories available, toggle off if on 107 | app.filter_options.category = None; 108 | } else { 109 | // Cycle through available categories 110 | if app.filter_options.category.is_none() { 111 | // Set to first category 112 | app.filter_options.category = Some(categories[0].clone()); 113 | } else { 114 | // Find current index and move to next 115 | let current = app.filter_options.category.as_ref().unwrap(); 116 | let current_idx = categories.iter().position(|c| c == current); 117 | 118 | if let Some(idx) = current_idx { 119 | if idx < categories.len() - 1 { 120 | // Move to next category 121 | app.filter_options.category = 122 | Some(categories[idx + 1].clone()); 123 | } else { 124 | // Wrap around to None 125 | app.filter_options.category = None; 126 | } 127 | } else { 128 | // Current category not found, set to first 129 | app.filter_options.category = Some(categories[0].clone()); 130 | } 131 | } 132 | } 133 | app.apply_filters(); 134 | } 135 | } 136 | KeyCode::Tab => { 137 | // Check if shift modifier is pressed 138 | if key.modifiers.contains(event::KeyModifiers::SHIFT) { 139 | // With Shift+Tab, go from Feeds to Dashboard 140 | if matches!(app.view, View::FeedList) { 141 | app.view = View::Dashboard; 142 | } 143 | } else { 144 | // With Tab, go from Dashboard to Feeds 145 | if matches!(app.view, View::Dashboard) { 146 | app.view = View::FeedList; 147 | } 148 | } 149 | } 150 | KeyCode::Char('a') => { 151 | app.input.clear(); 152 | app.input_mode = InputMode::InsertUrl; 153 | } 154 | KeyCode::Char('r') => { 155 | // Set loading flag before starting refresh 156 | app.is_loading = true; 157 | 158 | if let Err(e) = app.refresh_feeds() { 159 | app.error = Some(format!("Failed to refresh feeds: {}", e)); 160 | } 161 | 162 | // Refresh completed 163 | app.is_loading = false; 164 | } 165 | KeyCode::Char('/') => { 166 | app.input.clear(); 167 | app.input_mode = InputMode::SearchMode; 168 | } 169 | KeyCode::Char('1') => { 170 | if app.feeds.is_empty() { 171 | // Add Hacker News RSS 172 | if let Err(e) = app.add_feed("https://news.ycombinator.com/rss") { 173 | app.error = Some(format!("Failed to add feed: {}", e)); 174 | } 175 | } 176 | } 177 | KeyCode::Char('2') => { 178 | if app.feeds.is_empty() { 179 | // Add TechCrunch RSS 180 | if let Err(e) = app.add_feed("https://feeds.feedburner.com/TechCrunch") 181 | { 182 | app.error = Some(format!("Failed to add feed: {}", e)); 183 | } 184 | } 185 | } 186 | KeyCode::Char('3') => { 187 | if app.feeds.is_empty() { 188 | // Add NYTimes RSS 189 | if let Err(e) = app.add_feed( 190 | "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", 191 | ) { 192 | app.error = Some(format!("Failed to add feed: {}", e)); 193 | } 194 | } 195 | } 196 | KeyCode::Up => { 197 | if let Some(selected) = app.selected_item { 198 | if selected > 0 { 199 | app.selected_item = Some(selected - 1); 200 | } 201 | } else if !app.dashboard_items.is_empty() { 202 | app.selected_item = Some(0); 203 | } 204 | } 205 | KeyCode::Down => { 206 | if let Some(selected) = app.selected_item { 207 | if selected < app.dashboard_items.len() - 1 { 208 | app.selected_item = Some(selected + 1); 209 | } 210 | } else if !app.dashboard_items.is_empty() { 211 | app.selected_item = Some(0); 212 | } 213 | } 214 | KeyCode::Enter => { 215 | if let Some(selected) = app.selected_item { 216 | if app.is_searching && selected < app.filtered_items.len() { 217 | let (feed_idx, item_idx) = app.filtered_items[selected]; 218 | app.selected_feed = Some(feed_idx); 219 | app.selected_item = Some(item_idx); 220 | app.view = View::FeedItemDetail; 221 | } else if selected < app.dashboard_items.len() { 222 | let (feed_idx, item_idx) = app.dashboard_items[selected]; 223 | app.selected_feed = Some(feed_idx); 224 | app.selected_item = Some(item_idx); 225 | app.view = View::FeedItemDetail; 226 | } 227 | } 228 | } 229 | KeyCode::Char('o') => { 230 | if app.selected_item.is_some() { 231 | if let Err(e) = app.open_current_item_in_browser() { 232 | app.error = Some(format!("Failed to open link: {}", e)); 233 | } 234 | } 235 | } 236 | _ => {} 237 | }, 238 | View::FeedList => match key.code { 239 | KeyCode::Char('q') => return Ok(true), 240 | KeyCode::Tab => { 241 | app.view = View::Dashboard; 242 | } 243 | KeyCode::Char('a') => { 244 | app.input.clear(); 245 | app.input_mode = InputMode::InsertUrl; 246 | } 247 | KeyCode::Char('d') => { 248 | if let Err(e) = app.remove_current_feed() { 249 | app.error = Some(format!("Failed to remove feed: {}", e)); 250 | } 251 | } 252 | KeyCode::Char('h') | KeyCode::Esc | KeyCode::Home => { 253 | app.view = View::Dashboard; 254 | app.selected_item = None; 255 | } 256 | KeyCode::Char('/') => { 257 | app.input.clear(); 258 | app.input_mode = InputMode::SearchMode; 259 | } 260 | KeyCode::Char('r') => { 261 | // Set loading flag before starting refresh 262 | app.is_loading = true; 263 | 264 | if let Err(e) = app.refresh_feeds() { 265 | app.error = Some(format!("Failed to refresh feeds: {}", e)); 266 | } 267 | 268 | // Refresh completed 269 | app.is_loading = false; 270 | } 271 | KeyCode::Up => { 272 | if let Some(selected) = app.selected_feed { 273 | if selected > 0 { 274 | app.selected_feed = Some(selected - 1); 275 | } 276 | } else if !app.feeds.is_empty() { 277 | app.selected_feed = Some(0); 278 | } 279 | } 280 | KeyCode::Down => { 281 | if let Some(selected) = app.selected_feed { 282 | if selected < app.feeds.len() - 1 { 283 | app.selected_feed = Some(selected + 1); 284 | } 285 | } else if !app.feeds.is_empty() { 286 | app.selected_feed = Some(0); 287 | } 288 | } 289 | KeyCode::Enter => { 290 | if app.selected_feed.is_some() { 291 | app.selected_item = Some(0); 292 | app.view = View::FeedItems; 293 | } 294 | } 295 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 296 | // Switch to category management view 297 | app.view = View::CategoryManagement; 298 | app.selected_category = if !app.categories.is_empty() { 299 | Some(0) 300 | } else { 301 | None 302 | }; 303 | } 304 | KeyCode::Char('c') if app.selected_feed.is_some() && 305 | !key.modifiers.contains(KeyModifiers::CONTROL) => { 306 | // Assign the selected feed to a category 307 | if let Some(feed_idx) = app.selected_feed { 308 | if feed_idx < app.feeds.len() { 309 | let feed_url = app.feeds[feed_idx].url.clone(); 310 | app.category_action = Some(CategoryAction::AddFeedToCategory(feed_url)); 311 | app.view = View::CategoryManagement; 312 | } 313 | } 314 | } 315 | _ => {} 316 | }, 317 | View::FeedItems => match key.code { 318 | KeyCode::Char('q') => return Ok(true), 319 | KeyCode::Esc | KeyCode::Char('h') | KeyCode::Backspace => { 320 | app.view = View::FeedList; 321 | app.selected_item = None; 322 | } 323 | KeyCode::Home => { 324 | app.view = View::Dashboard; 325 | app.selected_item = None; 326 | } 327 | KeyCode::Char('/') => { 328 | app.input.clear(); 329 | app.input_mode = InputMode::SearchMode; 330 | } 331 | KeyCode::Char('r') => { 332 | // Set loading flag before starting refresh 333 | app.is_loading = true; 334 | 335 | if let Err(e) = app.refresh_feeds() { 336 | app.error = Some(format!("Failed to refresh feeds: {}", e)); 337 | } 338 | 339 | // Refresh completed 340 | app.is_loading = false; 341 | } 342 | KeyCode::Up => { 343 | if let Some(selected) = app.selected_item { 344 | if selected > 0 { 345 | app.selected_item = Some(selected - 1); 346 | } 347 | } 348 | } 349 | KeyCode::Down => { 350 | if let Some(selected) = app.selected_item { 351 | let feed = app.current_feed().unwrap(); 352 | if selected < feed.items.len() - 1 { 353 | app.selected_item = Some(selected + 1); 354 | } 355 | } 356 | } 357 | KeyCode::Enter => { 358 | if app.selected_item.is_some() { 359 | app.view = View::FeedItemDetail; 360 | if let Some(feed_idx) = app.selected_feed { 361 | if let Some(item_idx) = app.selected_item { 362 | if let Err(e) = app.mark_item_as_read(feed_idx, item_idx) { 363 | app.error = 364 | Some(format!("Failed to mark item as read: {}", e)); 365 | } 366 | } 367 | } 368 | } 369 | } 370 | KeyCode::Char('o') => { 371 | if app.selected_item.is_some() { 372 | if let Err(e) = app.open_current_item_in_browser() { 373 | app.error = Some(format!("Failed to open link: {}", e)); 374 | } 375 | } 376 | } 377 | _ => {} 378 | }, 379 | View::FeedItemDetail => match key.code { 380 | KeyCode::Char('q') => return Ok(true), 381 | KeyCode::Esc | KeyCode::Char('h') | KeyCode::Backspace => { 382 | if app.is_searching { 383 | // Return to search results 384 | app.view = View::Dashboard; 385 | app.selected_item = Some(0); 386 | } else { 387 | // Return to feed items 388 | app.view = View::FeedItems; 389 | } 390 | } 391 | KeyCode::Home => { 392 | app.view = View::Dashboard; 393 | app.selected_item = None; 394 | } 395 | KeyCode::Char('r') => { 396 | // Set loading flag before starting refresh 397 | app.is_loading = true; 398 | 399 | if let Err(e) = app.refresh_feeds() { 400 | app.error = Some(format!("Failed to refresh feeds: {}", e)); 401 | } 402 | 403 | // Refresh completed 404 | app.is_loading = false; 405 | } 406 | KeyCode::Char('o') => { 407 | if let Err(e) = app.open_current_item_in_browser() { 408 | app.error = Some(format!("Failed to open link: {}", e)); 409 | } 410 | } 411 | _ => {} 412 | }, 413 | View::CategoryManagement => { 414 | match key.code { 415 | KeyCode::Esc | KeyCode::Char('q') => { 416 | // Return to previous view 417 | app.view = View::FeedList; 418 | app.category_action = None; 419 | } 420 | KeyCode::Char('n') => { 421 | // Create a new category 422 | app.input.clear(); 423 | app.category_action = Some(CategoryAction::Create); 424 | app.input_mode = InputMode::CategoryNameInput; 425 | } 426 | KeyCode::Char('e') if app.selected_category.is_some() => { 427 | // Rename the selected category 428 | if let Some(idx) = app.selected_category { 429 | if idx < app.categories.len() { 430 | app.input = app.categories[idx].name.clone(); 431 | app.category_action = Some(CategoryAction::Rename(idx)); 432 | app.input_mode = InputMode::CategoryNameInput; 433 | } 434 | } 435 | } 436 | KeyCode::Char('d') if app.selected_category.is_some() => { 437 | // Delete the selected category 438 | if let Some(idx) = app.selected_category { 439 | if let Err(e) = app.delete_category(idx) { 440 | app.error = Some(format!("Failed to delete category: {}", e)); 441 | } 442 | } 443 | } 444 | KeyCode::Enter => { 445 | // Add feed to category if that's the current action 446 | if let Some(CategoryAction::AddFeedToCategory(ref feed_url)) = app.category_action.clone() { 447 | if let Some(idx) = app.selected_category { 448 | if let Err(e) = app.assign_feed_to_category(&feed_url, idx) { 449 | app.error = Some(format!("Failed to assign feed to category: {}", e)); 450 | } else { 451 | // Success, go back to feed list 452 | app.view = View::FeedList; 453 | app.category_action = None; 454 | } 455 | } 456 | } 457 | } 458 | KeyCode::Up => { 459 | // Select previous category 460 | if let Some(selected) = app.selected_category { 461 | if selected > 0 { 462 | app.selected_category = Some(selected - 1); 463 | } 464 | } else if !app.categories.is_empty() { 465 | app.selected_category = Some(0); 466 | } 467 | } 468 | KeyCode::Down => { 469 | // Select next category 470 | if let Some(selected) = app.selected_category { 471 | if selected < app.categories.len() - 1 { 472 | app.selected_category = Some(selected + 1); 473 | } 474 | } else if !app.categories.is_empty() { 475 | app.selected_category = Some(0); 476 | } 477 | } 478 | KeyCode::Char(' ') if app.selected_category.is_some() => { 479 | // Toggle category expanded/collapsed 480 | if let Some(idx) = app.selected_category { 481 | if let Err(e) = app.toggle_category_expanded(idx) { 482 | app.error = Some(format!("Failed to toggle category: {}", e)); 483 | } 484 | } 485 | } 486 | KeyCode::Char('r') => { 487 | // Remove a feed from the selected category 488 | if let Some(CategoryAction::AddFeedToCategory(ref feed_url)) = app.category_action.clone() { 489 | if let Some(idx) = app.selected_category { 490 | if let Err(e) = app.remove_feed_from_category(&feed_url, idx) { 491 | app.error = Some(format!("Failed to remove feed from category: {}", e)); 492 | } 493 | } 494 | } 495 | } 496 | _ => {} 497 | } 498 | } 499 | }, 500 | InputMode::InsertUrl => match key.code { 501 | KeyCode::Enter => { 502 | let url = app.input.trim().to_string(); 503 | if !url.is_empty() { 504 | match app.add_feed(&url) { 505 | Ok(_) => {} 506 | Err(e) => { 507 | app.error = Some(format!("Failed to add feed: {}", e)); 508 | } 509 | } 510 | } 511 | app.input.clear(); 512 | app.input_mode = InputMode::Normal; 513 | } 514 | KeyCode::Esc => { 515 | app.input.clear(); 516 | app.input_mode = InputMode::Normal; 517 | } 518 | KeyCode::Char(c) => { 519 | app.input.push(c); 520 | } 521 | KeyCode::Backspace => { 522 | app.input.pop(); 523 | } 524 | _ => {} 525 | }, 526 | InputMode::SearchMode => match key.code { 527 | KeyCode::Enter => { 528 | let query = app.input.trim().to_string(); 529 | app.search_feeds(&query); 530 | app.selected_item = Some(0); 531 | app.view = View::Dashboard; // Show search results in dashboard 532 | app.input_mode = InputMode::Normal; 533 | } 534 | KeyCode::Esc => { 535 | app.input.clear(); 536 | app.is_searching = false; 537 | app.input_mode = InputMode::Normal; 538 | } 539 | KeyCode::Char(c) => { 540 | app.input.push(c); 541 | } 542 | KeyCode::Backspace => { 543 | app.input.pop(); 544 | } 545 | _ => {} 546 | }, 547 | InputMode::FilterMode => match key.code { 548 | KeyCode::Esc => { 549 | app.filter_mode = false; 550 | app.input_mode = InputMode::Normal; 551 | } 552 | KeyCode::Char('c') => { 553 | // Toggle category filter 554 | if app.filter_options.category.is_none() { 555 | // Cycle through available categories (tech, news, etc.) 556 | app.filter_options.category = Some("tech".to_string()); 557 | } else if app.filter_options.category.as_deref() == Some("tech") { 558 | app.filter_options.category = Some("news".to_string()); 559 | } else if app.filter_options.category.as_deref() == Some("news") { 560 | app.filter_options.category = Some("science".to_string()); 561 | } else { 562 | app.filter_options.category = None; 563 | } 564 | app.apply_filters(); 565 | } 566 | KeyCode::Char('t') => { 567 | // Cycle through time filters 568 | if app.filter_options.age.is_none() { 569 | app.filter_options.age = Some(TimeFilter::Today); 570 | } else if app.filter_options.age == Some(TimeFilter::Today) { 571 | app.filter_options.age = Some(TimeFilter::ThisWeek); 572 | } else if app.filter_options.age == Some(TimeFilter::ThisWeek) { 573 | app.filter_options.age = Some(TimeFilter::ThisMonth); 574 | } else if app.filter_options.age == Some(TimeFilter::ThisMonth) { 575 | app.filter_options.age = Some(TimeFilter::Older); 576 | } else { 577 | app.filter_options.age = None; 578 | } 579 | app.apply_filters(); 580 | } 581 | KeyCode::Char('a') => { 582 | // Toggle author filter 583 | app.filter_options.has_author = match app.filter_options.has_author { 584 | None => Some(true), 585 | Some(true) => Some(false), 586 | Some(false) => None, 587 | }; 588 | app.apply_filters(); 589 | } 590 | KeyCode::Char('r') => { 591 | // Toggle read status filter 592 | app.filter_options.read_status = match app.filter_options.read_status { 593 | None => Some(true), // Show read 594 | Some(true) => Some(false), // Show unread 595 | Some(false) => None, // Show all 596 | }; 597 | app.apply_filters(); 598 | } 599 | KeyCode::Char('l') => { 600 | // Cycle through content length filters 601 | app.filter_options.min_length = match app.filter_options.min_length { 602 | None => Some(100), // Short 603 | Some(100) => Some(500), // Medium 604 | Some(500) => Some(1000), // Long 605 | _ => None, // All 606 | }; 607 | app.apply_filters(); 608 | } 609 | KeyCode::Char('x') => { 610 | // Clear all filters 611 | app.filter_options.reset(); 612 | app.apply_filters(); 613 | } 614 | _ => {} 615 | }, 616 | InputMode::CategoryNameInput => { 617 | match key.code { 618 | KeyCode::Enter => { 619 | // Process category name input 620 | match app.category_action.clone() { 621 | Some(CategoryAction::Create) => { 622 | let input = app.input.clone(); 623 | if let Err(e) = app.create_category(&input) { 624 | app.error = Some(format!("Failed to create category: {}", e)); 625 | } 626 | } 627 | Some(CategoryAction::Rename(idx)) => { 628 | let input = app.input.clone(); 629 | if let Err(e) = app.rename_category(idx, &input) { 630 | app.error = Some(format!("Failed to rename category: {}", e)); 631 | } 632 | } 633 | _ => {} 634 | } 635 | app.input.clear(); 636 | app.input_mode = InputMode::Normal; 637 | } 638 | KeyCode::Esc => { 639 | // Cancel the operation 640 | app.input.clear(); 641 | app.input_mode = InputMode::Normal; 642 | app.category_action = None; 643 | } 644 | KeyCode::Backspace => { 645 | app.input.pop(); 646 | } 647 | KeyCode::Char(c) => { 648 | app.input.push(c); 649 | } 650 | _ => {} 651 | } 652 | }, 653 | InputMode::CategoryMode => { 654 | // Handle category mode key events 655 | match key.code { 656 | KeyCode::Esc => { 657 | app.input_mode = InputMode::Normal; 658 | } 659 | _ => {} 660 | } 661 | } 662 | } 663 | } 664 | Ok(false) 665 | } 666 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, CategoryAction, InputMode, TimeFilter, View}; 2 | use html2text::from_read; 3 | use ratatui::{ 4 | backend::Backend, 5 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 6 | style::{Color, Modifier, Style}, 7 | symbols::{self}, 8 | text::{Line, Span, Text}, 9 | widgets::{ 10 | canvas::{Canvas, Rectangle}, 11 | Block, BorderType, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Tabs, Wrap, 12 | }, 13 | Frame, 14 | }; 15 | use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 16 | 17 | // Define a refined color palette inspired by modern terminal themes 18 | const PRIMARY_COLOR: Color = Color::Cyan; // blue 19 | const SECONDARY_COLOR: Color = Color::Magenta; // purple 20 | const HIGHLIGHT_COLOR: Color = Color::Green; // mint 21 | const BACKGROUND_COLOR: Color = Color::Black; // charcoal 22 | const TEXT_COLOR: Color = Color::White; // off-white 23 | const MUTED_COLOR: Color = Color::DarkGray; // steel gray 24 | const ACCENT_COLOR: Color = Color::Yellow; // gold 25 | const ERROR_COLOR: Color = Color::Red; // rose 26 | const BORDER_COLOR: Color = Color::DarkGray; // dark gray 27 | 28 | const NORMAL_BORDER: BorderType = BorderType::Rounded; 29 | const ACTIVE_BORDER: BorderType = BorderType::Thick; 30 | 31 | pub fn render(f: &mut Frame, app: &App) { 32 | // Set background color for the entire terminal 33 | let bg_block = Block::default().style(Style::default().bg(BACKGROUND_COLOR)); 34 | f.render_widget(bg_block, f.size()); 35 | 36 | // Main layout division 37 | let chunks = Layout::default() 38 | .direction(Direction::Vertical) 39 | .margin(1) 40 | .constraints([ 41 | Constraint::Length(3), // Title/tab bar 42 | Constraint::Min(0), // Main content 43 | Constraint::Length(3), // Help bar 44 | ]) 45 | .split(f.size()); 46 | 47 | render_title_bar(f, app, chunks[0]); 48 | 49 | match app.view { 50 | View::Dashboard => render_dashboard(f, app, chunks[1]), 51 | View::FeedList => render_feed_list(f, app, chunks[1]), 52 | View::FeedItems => render_feed_items(f, app, chunks[1]), 53 | View::FeedItemDetail => render_item_detail(f, app, chunks[1]), 54 | View::CategoryManagement => render_category_management(f, app, chunks[1]), 55 | } 56 | 57 | render_help_bar(f, app, chunks[2]); 58 | 59 | // Show error if present 60 | if let Some(error) = &app.error { 61 | render_error_modal(f, error); 62 | } 63 | 64 | // Show input modal when in input modes 65 | if matches!(app.input_mode, InputMode::InsertUrl | InputMode::SearchMode) { 66 | render_input_modal(f, app); 67 | } 68 | 69 | // Show filter modal when in filter mode 70 | if app.filter_mode { 71 | render_filter_modal(f, app); 72 | } 73 | 74 | // Show category input modal when in category name input mode 75 | if app.input_mode == InputMode::CategoryNameInput { 76 | render_category_input_modal(f, app); 77 | } 78 | } 79 | 80 | fn render_title_bar(f: &mut Frame, app: &App, area: Rect) { 81 | // Create tabs for navigation 82 | let titles = vec!["Dashboard", "Feeds", "Items", "Detail", "Categories"]; 83 | let selected_tab = match app.view { 84 | View::Dashboard => 0, 85 | View::FeedList => 1, 86 | View::FeedItems => 2, 87 | View::FeedItemDetail => 3, 88 | View::CategoryManagement => 4, 89 | }; 90 | 91 | // Loading animation characters - smoother spinner 92 | let loading_symbols = ["◐", "◓", "◑", "◒"]; 93 | 94 | // Create title with loading indicator if loading 95 | let title = if app.is_loading { 96 | format!( 97 | " {} Loading... ", 98 | loading_symbols[app.loading_indicator % 4] 99 | ) 100 | } else { 101 | " 📰 Feedr ".to_string() 102 | }; 103 | 104 | // Create tab highlight effect 105 | let tabs = Tabs::new( 106 | titles 107 | .iter() 108 | .enumerate() 109 | .map(|(i, t)| { 110 | let prefix = if i == selected_tab { "▪ " } else { " " }; 111 | Line::from(vec![Span::styled( 112 | format!("{}{}", prefix, t), 113 | if i == selected_tab { 114 | Style::default() 115 | .fg(HIGHLIGHT_COLOR) 116 | .add_modifier(Modifier::BOLD) 117 | } else { 118 | Style::default().fg(MUTED_COLOR) 119 | }, 120 | )]) 121 | }) 122 | .collect(), 123 | ) 124 | .block( 125 | Block::default() 126 | .borders(Borders::ALL) 127 | .border_type(if app.is_loading { 128 | ACTIVE_BORDER 129 | } else { 130 | NORMAL_BORDER 131 | }) 132 | .border_style(Style::default().fg(PRIMARY_COLOR)) 133 | .title(title) 134 | .title_alignment(Alignment::Center) 135 | .padding(Padding::new(1, 0, 0, 0)), 136 | ) 137 | .style(Style::default().fg(MUTED_COLOR)) 138 | .select(selected_tab) 139 | .divider(symbols::line::VERTICAL); 140 | 141 | f.render_widget(tabs, area); 142 | } 143 | 144 | fn render_dashboard(f: &mut Frame, app: &App, area: Rect) { 145 | let mut title = if app.is_searching { 146 | format!(" 🔍 Search Results: '{}' ", app.search_query) 147 | } else { 148 | " 🔔 Latest Updates ".to_string() 149 | }; 150 | 151 | // Add filter indicators to title if any filters are active 152 | if app.filter_options.is_active() { 153 | title = format!("{} | 🔍 Filtered", title); 154 | } 155 | 156 | // Use the filtered items when filters are active 157 | let items_to_display = if app.is_searching { 158 | &app.filtered_items 159 | } else if app.filter_options.is_active() { 160 | &app.filtered_dashboard_items 161 | } else { 162 | &app.dashboard_items 163 | }; 164 | 165 | if items_to_display.is_empty() { 166 | let message = if app.is_searching { 167 | let no_results = format!("No results found for '{}'", app.search_query); 168 | 169 | // Create a visually appealing empty search results screen 170 | let mut lines = Vec::new(); 171 | lines.push(""); 172 | lines.push(" 🔍 "); 173 | lines.push(""); 174 | lines.push(&no_results); 175 | lines.push(""); 176 | lines.push("Try different keywords or add more feeds"); 177 | 178 | lines.join("\n") 179 | } else if app.feeds.is_empty() { 180 | // Enhanced ASCII art with color coding and interactive suggestions 181 | let ascii_art = vec![ 182 | " ", 183 | " ███████╗███████╗███████╗██████╗ ██████╗ ", 184 | " ██╔════╝██╔════╝██╔════╝██╔══██╗██╔══██╗ ", 185 | " █████╗ █████╗ █████╗ ██║ ██║██████╔╝ ", 186 | " ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║██╔══██╗ ", 187 | " ██║ ███████╗███████╗██████╔╝██║ ██║ ", 188 | " ╚═╝ ╚══════╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ", 189 | " ", 190 | " Welcome to Feedr - Your Terminal RSS Reader ", 191 | " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", 192 | " ", 193 | " 📚 Get started by adding your favorite RSS feeds", 194 | " 🔶 Press 'a' to add a feed URL ", 195 | " ", 196 | " Quick-add suggestions: (press the number key) ", 197 | " 1️⃣ news.ycombinator.com/rss ", 198 | " 2️⃣ feeds.feedburner.com/TechCrunch ", 199 | ]; 200 | ascii_art.join("\n") 201 | } else { 202 | let empty_msg = vec![ 203 | "", 204 | " 📭 ", 205 | "", 206 | "No recent items", 207 | "", 208 | "Refresh with 'r' to update", 209 | "", 210 | ]; 211 | empty_msg.join("\n") 212 | }; 213 | 214 | // Rich text for empty dashboard 215 | let mut text = Text::default(); 216 | 217 | if app.feeds.is_empty() && !app.is_searching { 218 | // For welcome screen 219 | for line in message.lines() { 220 | if line.contains("Welcome") { 221 | text.lines.push(Line::from(vec![Span::styled( 222 | line, 223 | Style::default() 224 | .fg(ACCENT_COLOR) 225 | .add_modifier(Modifier::BOLD), 226 | )])); 227 | } else if line.contains("Press") { 228 | text.lines.push(Line::from(vec![Span::styled( 229 | line, 230 | Style::default().fg(HIGHLIGHT_COLOR), 231 | )])); 232 | } else if line.contains("Some suggestions") { 233 | text.lines.push(Line::from(vec![Span::styled( 234 | line, 235 | Style::default() 236 | .fg(SECONDARY_COLOR) 237 | .add_modifier(Modifier::BOLD), 238 | )])); 239 | } else if line.contains("•") { 240 | text.lines.push(Line::from(vec![Span::styled( 241 | line, 242 | Style::default().fg(PRIMARY_COLOR), 243 | )])); 244 | } else if line.contains("Get started") { 245 | text.lines.push(Line::from(vec![Span::styled( 246 | line, 247 | Style::default().fg(TEXT_COLOR), 248 | )])); 249 | } else if line.contains("━") { 250 | text.lines.push(Line::from(vec![Span::styled( 251 | line, 252 | Style::default().fg(BORDER_COLOR), 253 | )])); 254 | } else if line.contains("███") { 255 | text.lines.push(Line::from(vec![Span::styled( 256 | line, 257 | Style::default().fg(PRIMARY_COLOR), 258 | )])); 259 | } else { 260 | text.lines.push(Line::from(line)); 261 | } 262 | } 263 | } else { 264 | // For empty search or empty dashboard 265 | for line in message.lines() { 266 | if line.contains("🔍") || line.contains("📭") { 267 | text.lines.push(Line::from(vec![Span::styled( 268 | line, 269 | Style::default().fg(SECONDARY_COLOR), 270 | )])); 271 | } else if line.contains("No results") || line.contains("No recent") { 272 | text.lines.push(Line::from(vec![Span::styled( 273 | line, 274 | Style::default().fg(TEXT_COLOR).add_modifier(Modifier::BOLD), 275 | )])); 276 | } else { 277 | text.lines.push(Line::from(vec![Span::styled( 278 | line, 279 | Style::default().fg(MUTED_COLOR), 280 | )])); 281 | } 282 | } 283 | } 284 | 285 | let paragraph = Paragraph::new(text).alignment(Alignment::Center).block( 286 | Block::default() 287 | .title(title) 288 | .title_alignment(Alignment::Center) 289 | .borders(Borders::ALL) 290 | .border_type(NORMAL_BORDER) 291 | .border_style(Style::default().fg(PRIMARY_COLOR)) 292 | .padding(Padding::new(1, 1, 1, 1)), 293 | ); 294 | 295 | f.render_widget(paragraph, area); 296 | return; 297 | } 298 | 299 | if app.filter_options.is_active() && items_to_display.is_empty() { 300 | let mut text = Text::default(); 301 | 302 | text.lines.push(Line::from("")); 303 | text.lines.push(Line::from(Span::styled( 304 | " 🔍 ", 305 | Style::default().fg(SECONDARY_COLOR), 306 | ))); 307 | text.lines.push(Line::from("")); 308 | text.lines.push(Line::from(Span::styled( 309 | "No items match your current filters", 310 | Style::default().fg(TEXT_COLOR).add_modifier(Modifier::BOLD), 311 | ))); 312 | text.lines.push(Line::from("")); 313 | text.lines.push(Line::from(Span::styled( 314 | app.get_filter_summary(), 315 | Style::default().fg(SECONDARY_COLOR), 316 | ))); 317 | text.lines.push(Line::from("")); 318 | text.lines.push(Line::from(Span::styled( 319 | "Press 'f' to adjust filters or 'r' to refresh feeds", 320 | Style::default().fg(HIGHLIGHT_COLOR), 321 | ))); 322 | 323 | let paragraph = Paragraph::new(text).alignment(Alignment::Center).block( 324 | Block::default() 325 | .title(title) 326 | .title_alignment(Alignment::Center) 327 | .borders(Borders::ALL) 328 | .border_type(NORMAL_BORDER) 329 | .border_style(Style::default().fg(PRIMARY_COLOR)) 330 | .padding(Padding::new(1, 1, 1, 1)), 331 | ); 332 | 333 | f.render_widget(paragraph, area); 334 | return; 335 | } 336 | 337 | // For non-empty dashboard, create richly formatted items 338 | let items: Vec = items_to_display 339 | .iter() 340 | .enumerate() 341 | .map(|(idx, &(feed_idx, item_idx))| { 342 | let (feed, item) = if app.is_searching { 343 | app.search_item(idx).unwrap() 344 | } else { 345 | app.dashboard_item(idx).unwrap() 346 | }; 347 | 348 | let date_str = item.formatted_date.as_deref().unwrap_or("Unknown date"); 349 | let is_selected = app.selected_item.map_or(false, |selected| selected == idx); 350 | let is_read = app.is_item_read(feed_idx, item_idx); 351 | 352 | // Create clearer visual group with feed name as header 353 | ListItem::new(vec![ 354 | // Feed source with icon - more prominent 355 | Line::from(vec![ 356 | Span::styled( 357 | if is_selected { "► " } else { "● " }, 358 | Style::default().fg(if is_selected { 359 | HIGHLIGHT_COLOR 360 | } else if is_read { 361 | MUTED_COLOR // Use muted color for read items 362 | } else { 363 | PRIMARY_COLOR 364 | }), 365 | ), 366 | Span::styled( 367 | format!("[{}]", feed.title), 368 | Style::default() 369 | .fg(SECONDARY_COLOR) 370 | .add_modifier(Modifier::BOLD), 371 | ), 372 | ]), 373 | // Item title - more prominent 374 | Line::from(vec![ 375 | Span::styled(" │ ", Style::default().fg(BORDER_COLOR)), 376 | Span::styled( 377 | &item.title, 378 | Style::default() 379 | .fg(if is_selected { 380 | HIGHLIGHT_COLOR 381 | } else { 382 | TEXT_COLOR 383 | }) 384 | .add_modifier(if is_selected { 385 | Modifier::BOLD 386 | } else { 387 | Modifier::empty() 388 | }), 389 | ), 390 | ]), 391 | // Publication date with icon 392 | Line::from(vec![ 393 | Span::styled(" └─ ", Style::default().fg(BORDER_COLOR)), 394 | Span::styled("🕒 ", Style::default().fg(PRIMARY_COLOR)), 395 | Span::styled(date_str, Style::default().fg(MUTED_COLOR)), 396 | ]), 397 | // Empty line for spacing between items 398 | Line::from(""), 399 | ]) 400 | .style(Style::default().fg(TEXT_COLOR).bg(if is_selected { 401 | Color::Rgb(59, 66, 82) 402 | } else { 403 | BACKGROUND_COLOR 404 | })) 405 | }) 406 | .collect(); 407 | 408 | let dashboard_list = List::new(items) 409 | .block( 410 | Block::default() 411 | .title(title) 412 | .title_alignment(Alignment::Center) 413 | .borders(Borders::ALL) 414 | .border_type(NORMAL_BORDER) 415 | .border_style(Style::default().fg(PRIMARY_COLOR)) 416 | .padding(Padding::new(1, 0, 0, 0)), 417 | ) 418 | .highlight_style( 419 | Style::default() 420 | .bg(Color::Rgb(59, 66, 82)) // Slightly lighter than background 421 | .fg(HIGHLIGHT_COLOR) 422 | .add_modifier(Modifier::BOLD), 423 | ) 424 | .highlight_symbol(" ► "); 425 | 426 | let mut state = ratatui::widgets::ListState::default(); 427 | state.select(app.selected_item); 428 | 429 | f.render_stateful_widget(dashboard_list, area, &mut state); 430 | } 431 | 432 | fn render_feed_list(f: &mut Frame, app: &App, area: Rect) { 433 | let chunks = Layout::default() 434 | .direction(Direction::Vertical) 435 | .constraints([ 436 | Constraint::Length(3), 437 | Constraint::Min(0), 438 | ]) 439 | .split(area); 440 | 441 | let title_text = vec![ 442 | Line::from(Span::styled( 443 | " Add your RSS/Atom feeds to get started ", 444 | Style::default().fg(MUTED_COLOR), 445 | )), 446 | Line::from(Span::styled( 447 | " Press 'a' to add a feed ", 448 | Style::default().fg(HIGHLIGHT_COLOR), 449 | )), 450 | ]; 451 | 452 | let title_para = Paragraph::new(Text::from(title_text)) 453 | .block(Block::default().borders(Borders::NONE)) 454 | .alignment(Alignment::Left); 455 | 456 | f.render_widget(title_para, chunks[0]); 457 | 458 | if app.feeds.is_empty() { 459 | // Restore the stylized ASCII robot penguin 460 | let mut text = Text::default(); 461 | 462 | // Stylized ASCII robot penguin 463 | text.lines.push(Line::from(Span::styled( 464 | " ", 465 | Style::default().fg(MUTED_COLOR), 466 | ))); 467 | text.lines.push(Line::from(Span::styled( 468 | " .---. ", 469 | Style::default().fg(PRIMARY_COLOR), 470 | ))); 471 | text.lines.push(Line::from(vec![ 472 | Span::styled(" |", Style::default().fg(PRIMARY_COLOR)), 473 | Span::styled("o_o", Style::default().fg(ACCENT_COLOR)), 474 | Span::styled( 475 | " | ", 476 | Style::default().fg(PRIMARY_COLOR), 477 | ), 478 | ])); 479 | text.lines.push(Line::from(vec![ 480 | Span::styled(" |", Style::default().fg(PRIMARY_COLOR)), 481 | Span::styled(":_/", Style::default().fg(SECONDARY_COLOR)), 482 | Span::styled( 483 | " | ", 484 | Style::default().fg(PRIMARY_COLOR), 485 | ), 486 | ])); 487 | text.lines.push(Line::from(Span::styled( 488 | " // \\ \\ ", 489 | Style::default().fg(PRIMARY_COLOR), 490 | ))); 491 | text.lines.push(Line::from(Span::styled( 492 | " (| | ) ", 493 | Style::default().fg(PRIMARY_COLOR), 494 | ))); 495 | text.lines.push(Line::from(Span::styled( 496 | " /'\\_ _/`\\ ", 497 | Style::default().fg(PRIMARY_COLOR), 498 | ))); 499 | text.lines.push(Line::from(Span::styled( 500 | " \\___)=(___/ ", 501 | Style::default().fg(PRIMARY_COLOR), 502 | ))); 503 | text.lines.push(Line::from(Span::styled( 504 | " ", 505 | Style::default().fg(MUTED_COLOR), 506 | ))); 507 | 508 | // Help message 509 | text.lines.push(Line::from(Span::styled( 510 | " No feeds added yet! ", 511 | Style::default().fg(TEXT_COLOR).add_modifier(Modifier::BOLD), 512 | ))); 513 | text.lines.push(Line::from(Span::styled( 514 | " ", 515 | Style::default().fg(MUTED_COLOR), 516 | ))); 517 | text.lines.push(Line::from(Span::styled( 518 | " Press 'a' to add a feed ", 519 | Style::default().fg(HIGHLIGHT_COLOR), 520 | ))); 521 | 522 | let paragraph = Paragraph::new(text).alignment(Alignment::Center).block( 523 | Block::default() 524 | .title(" 📋 Feeds ") 525 | .title_alignment(Alignment::Center) 526 | .borders(Borders::ALL) 527 | .border_type(NORMAL_BORDER) 528 | .border_style(Style::default().fg(PRIMARY_COLOR)) 529 | .padding(Padding::new(1, 1, 1, 1)), 530 | ); 531 | 532 | f.render_widget(paragraph, chunks[1]); 533 | return; 534 | } 535 | 536 | // Modify to show category indicators next to feeds 537 | let items: Vec = app 538 | .feeds 539 | .iter() 540 | .enumerate() 541 | .map(|(i, feed)| { 542 | let mut content = vec![]; 543 | 544 | // Add category indicator if the feed is in a category 545 | let category = app.get_category_for_feed(&feed.url); 546 | let category_tag = if let Some(cat_idx) = category { 547 | if cat_idx < app.categories.len() { 548 | format!(" [{}]", app.categories[cat_idx].name) 549 | } else { 550 | String::new() 551 | } 552 | } else { 553 | String::new() 554 | }; 555 | 556 | // Feed title with category tag 557 | let feed_title = format!("{}{}", feed.title, category_tag); 558 | 559 | if Some(i) == app.selected_feed { 560 | content.push(Span::styled( 561 | format!("▶ {}", feed_title), 562 | Style::default() 563 | .fg(HIGHLIGHT_COLOR) 564 | .add_modifier(Modifier::BOLD), 565 | )); 566 | } else { 567 | content.push(Span::styled( 568 | format!(" {}", feed_title), 569 | Style::default().fg(TEXT_COLOR), 570 | )); 571 | } 572 | 573 | let domain = extract_domain(&feed.url); 574 | content.push(Span::styled( 575 | format!(" ({})", domain), 576 | Style::default().fg(MUTED_COLOR), 577 | )); 578 | 579 | ListItem::new(Line::from(content)) 580 | }) 581 | .collect(); 582 | 583 | let feeds = List::new(items) 584 | .block( 585 | Block::default() 586 | .borders(Borders::ALL) 587 | .border_type(NORMAL_BORDER) 588 | .title(" 📋 Feeds ") 589 | .title_alignment(Alignment::Center) 590 | .border_style(Style::default().fg(PRIMARY_COLOR)), 591 | ) 592 | .highlight_style( 593 | Style::default() 594 | .bg(PRIMARY_COLOR) 595 | .fg(TEXT_COLOR) 596 | .add_modifier(Modifier::BOLD), 597 | ) 598 | .highlight_symbol(" ► "); 599 | 600 | // Create a mutable ListState to track selection 601 | let mut list_state = ListState::default(); 602 | list_state.select(app.selected_feed); 603 | 604 | f.render_stateful_widget(feeds, chunks[1], &mut list_state); 605 | } 606 | 607 | // Add this helper function to extract domain from URL 608 | pub fn extract_domain(url: &str) -> String { 609 | let clean_url = url 610 | .replace("https://", "") 611 | .replace("http://", "") 612 | .replace("www.", ""); 613 | 614 | if let Some(slash_pos) = clean_url.find('/') { 615 | clean_url[..slash_pos].to_string() 616 | } else { 617 | clean_url 618 | } 619 | } 620 | 621 | fn render_feed_items(f: &mut Frame, app: &App, area: Rect) { 622 | if let Some(feed) = app.current_feed() { 623 | let title = format!(" 📰 {} ", feed.title); 624 | 625 | if feed.items.is_empty() { 626 | // Empty feed visualization 627 | let mut text = Text::default(); 628 | 629 | text.lines.push(Line::from("")); 630 | text.lines.push(Line::from(Span::styled( 631 | " 📭 ", 632 | Style::default().fg(SECONDARY_COLOR), 633 | ))); 634 | text.lines.push(Line::from("")); 635 | text.lines.push(Line::from(Span::styled( 636 | "No items in this feed", 637 | Style::default().fg(TEXT_COLOR).add_modifier(Modifier::BOLD), 638 | ))); 639 | text.lines.push(Line::from("")); 640 | text.lines.push(Line::from(Span::styled( 641 | "This feed might be empty or need refreshing", 642 | Style::default().fg(MUTED_COLOR), 643 | ))); 644 | text.lines.push(Line::from("")); 645 | text.lines.push(Line::from(Span::styled( 646 | "Press 'r' to refresh feeds", 647 | Style::default().fg(HIGHLIGHT_COLOR), 648 | ))); 649 | 650 | let paragraph = Paragraph::new(text).alignment(Alignment::Center).block( 651 | Block::default() 652 | .title(title) 653 | .title_alignment(Alignment::Center) 654 | .borders(Borders::ALL) 655 | .border_type(NORMAL_BORDER) 656 | .border_style(Style::default().fg(PRIMARY_COLOR)) 657 | .padding(Padding::new(1, 1, 1, 1)), 658 | ); 659 | 660 | f.render_widget(paragraph, area); 661 | return; 662 | } 663 | 664 | // Enhanced feed items for better readability 665 | let items: Vec = feed 666 | .items 667 | .iter() 668 | .enumerate() 669 | .map(|(idx, item)| { 670 | let date_str = item.formatted_date.as_deref().unwrap_or(""); 671 | let author = item.author.as_deref().unwrap_or(""); 672 | let is_selected = app.selected_item.map_or(false, |selected| selected == idx); 673 | let is_read = app 674 | .selected_feed 675 | .map_or(false, |feed_idx| app.is_item_read(feed_idx, idx)); 676 | 677 | // More distinct selection indicators 678 | let (title_color, bullet_icon, bullet_color) = if is_selected { 679 | (HIGHLIGHT_COLOR, "►", PRIMARY_COLOR) 680 | } else if is_read { 681 | (MUTED_COLOR, "•", MUTED_COLOR) // Muted for read items 682 | } else { 683 | (SECONDARY_COLOR, "•", MUTED_COLOR) 684 | }; 685 | 686 | // Better formatted snippet with HTML cleanup 687 | let snippet = if let Some(desc) = &item.description { 688 | let plain_text = html2text::from_read(desc.as_bytes(), 50); 689 | // Remove excess whitespace for cleaner display 690 | let clean_text = plain_text 691 | .replace('\n', " ") 692 | .replace(" ", " ") 693 | .trim() 694 | .to_string(); 695 | let snippet = truncate_str(&clean_text, 80); 696 | snippet 697 | } else { 698 | "".to_string() 699 | }; 700 | 701 | // Create visually separated blocks for each item 702 | let mut lines = vec![ 703 | // Title with more distinct selection indicator 704 | Line::from(vec![ 705 | Span::styled( 706 | format!("{} ", bullet_icon), 707 | Style::default().fg(bullet_color), 708 | ), 709 | Span::styled( 710 | &item.title, 711 | Style::default() 712 | .fg(title_color) 713 | .add_modifier(if is_selected { 714 | Modifier::BOLD 715 | } else { 716 | Modifier::empty() 717 | }), 718 | ), 719 | ]), 720 | ]; 721 | 722 | // Add content preview with better formatting 723 | if !snippet.is_empty() { 724 | lines.push(Line::from(vec![ 725 | Span::styled(" │ ", Style::default().fg(BORDER_COLOR)), 726 | Span::styled( 727 | snippet, 728 | Style::default().fg(if is_selected { TEXT_COLOR } else { MUTED_COLOR }), 729 | ), 730 | ])); 731 | } 732 | 733 | // Add better formatted metadata with icons 734 | if !author.is_empty() && !date_str.is_empty() { 735 | lines.push(Line::from(vec![ 736 | Span::styled(" └─ ", Style::default().fg(BORDER_COLOR)), 737 | Span::styled("👤 ", Style::default().fg(PRIMARY_COLOR)), 738 | Span::styled(author, Style::default().fg(TEXT_COLOR)), 739 | Span::styled(" • ", Style::default().fg(BORDER_COLOR)), 740 | Span::styled("🕒 ", Style::default().fg(PRIMARY_COLOR)), 741 | Span::styled(date_str, Style::default().fg(TEXT_COLOR)), 742 | ])); 743 | } else if !author.is_empty() { 744 | lines.push(Line::from(vec![ 745 | Span::styled(" └─ ", Style::default().fg(BORDER_COLOR)), 746 | Span::styled("👤 ", Style::default().fg(PRIMARY_COLOR)), 747 | Span::styled(author, Style::default().fg(TEXT_COLOR)), 748 | ])); 749 | } else if !date_str.is_empty() { 750 | lines.push(Line::from(vec![ 751 | Span::styled(" └─ ", Style::default().fg(BORDER_COLOR)), 752 | Span::styled("🕒 ", Style::default().fg(PRIMARY_COLOR)), 753 | Span::styled(date_str, Style::default().fg(TEXT_COLOR)), 754 | ])); 755 | } 756 | 757 | // Add a subtle separator line between items for better visual grouping 758 | lines.push(Line::from(Span::styled( 759 | " ", 760 | Style::default().fg(MUTED_COLOR), 761 | ))); 762 | 763 | ListItem::new(lines).style(Style::default().fg(TEXT_COLOR).bg(if is_selected { 764 | Color::Rgb(59, 66, 82) 765 | } else { 766 | BACKGROUND_COLOR 767 | })) 768 | }) 769 | .collect(); 770 | 771 | let items_list = List::new(items) 772 | .block( 773 | Block::default() 774 | .title(title) 775 | .title_alignment(Alignment::Center) 776 | .borders(Borders::ALL) 777 | .border_type(NORMAL_BORDER) 778 | .border_style(Style::default().fg(PRIMARY_COLOR)) 779 | .padding(Padding::new(1, 0, 0, 0)), 780 | ) 781 | .highlight_style( 782 | Style::default() 783 | .bg(Color::Rgb(59, 66, 82)) 784 | .fg(HIGHLIGHT_COLOR) 785 | .add_modifier(Modifier::BOLD), 786 | ) 787 | .highlight_symbol(""); // We're handling the symbol manually now 788 | 789 | let mut state = ratatui::widgets::ListState::default(); 790 | state.select(app.selected_item); 791 | 792 | f.render_stateful_widget(items_list, area, &mut state); 793 | } 794 | } 795 | 796 | fn render_item_detail(f: &mut Frame, app: &App, area: Rect) { 797 | if let Some(item) = app.current_item() { 798 | // Split the area into header and content 799 | let chunks = Layout::default() 800 | .direction(Direction::Vertical) 801 | .constraints([ 802 | Constraint::Length(6), // Header (increased for richer metadata) 803 | Constraint::Min(0), // Content 804 | ]) 805 | .split(area); 806 | 807 | // Create header with rich styling and icons 808 | let mut header_lines = vec![ 809 | // Title with icon 810 | Line::from(vec![ 811 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 812 | Span::styled( 813 | &item.title, 814 | Style::default() 815 | .fg(HIGHLIGHT_COLOR) 816 | .add_modifier(Modifier::BOLD), 817 | ), 818 | ]), 819 | // Separator line 820 | Line::from(vec![ 821 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 822 | Span::styled( 823 | "─".repeat( 824 | (chunks[0].width as usize) 825 | .saturating_sub(4) 826 | .min(item.title.width()), 827 | ), 828 | Style::default().fg(BORDER_COLOR), 829 | ), 830 | ]), 831 | ]; 832 | 833 | // Add read status with icon 834 | if let (Some(feed_idx), Some(item_idx)) = (app.selected_feed, app.selected_item) { 835 | let is_read = app.is_item_read(feed_idx, item_idx); 836 | header_lines.push(Line::from(vec![ 837 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 838 | Span::styled( 839 | if is_read { "✓ " } else { "✗ " }, 840 | Style::default().fg(if is_read { 841 | HIGHLIGHT_COLOR 842 | } else { 843 | MUTED_COLOR 844 | }), 845 | ), 846 | Span::styled("Status: ", Style::default().fg(SECONDARY_COLOR)), 847 | Span::styled( 848 | if is_read { "Read" } else { "Unread" }, 849 | Style::default().fg(if is_read { 850 | HIGHLIGHT_COLOR 851 | } else { 852 | MUTED_COLOR 853 | }), 854 | ), 855 | ])); 856 | } 857 | // Add publication date with icon 858 | if let Some(date) = &item.formatted_date { 859 | header_lines.push(Line::from(vec![ 860 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 861 | Span::styled( 862 | "🕒 ", 863 | Style::default() 864 | .fg(PRIMARY_COLOR) 865 | .add_modifier(Modifier::BOLD), 866 | ), 867 | Span::styled("Published: ", Style::default().fg(SECONDARY_COLOR)), 868 | Span::styled(date, Style::default().fg(TEXT_COLOR)), 869 | ])); 870 | } 871 | 872 | // Add author with icon 873 | if let Some(author) = &item.author { 874 | header_lines.push(Line::from(vec![ 875 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 876 | Span::styled( 877 | "👤 ", 878 | Style::default() 879 | .fg(PRIMARY_COLOR) 880 | .add_modifier(Modifier::BOLD), 881 | ), 882 | Span::styled("Author: ", Style::default().fg(SECONDARY_COLOR)), 883 | Span::styled(author, Style::default().fg(TEXT_COLOR)), 884 | ])); 885 | } 886 | 887 | // Add link with visual cue 888 | if let Some(link) = &item.link { 889 | header_lines.push(Line::from(vec![ 890 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 891 | Span::styled( 892 | "🔗 ", 893 | Style::default() 894 | .fg(PRIMARY_COLOR) 895 | .add_modifier(Modifier::BOLD), 896 | ), 897 | Span::styled("Link: ", Style::default().fg(SECONDARY_COLOR)), 898 | Span::styled( 899 | truncate_url(link, 40), 900 | Style::default() 901 | .fg(ACCENT_COLOR) 902 | .add_modifier(Modifier::UNDERLINED), 903 | ), 904 | ])); 905 | 906 | // Add guidance for opening in browser 907 | header_lines.push(Line::from(vec![ 908 | Span::styled(" ", Style::default().fg(MUTED_COLOR)), 909 | Span::styled(" └─ Press ", Style::default().fg(BORDER_COLOR)), 910 | Span::styled( 911 | "'o'", 912 | Style::default() 913 | .fg(HIGHLIGHT_COLOR) 914 | .add_modifier(Modifier::BOLD), 915 | ), 916 | Span::styled(" to open in browser", Style::default().fg(BORDER_COLOR)), 917 | ])); 918 | } 919 | 920 | let header = Paragraph::new(header_lines) 921 | .block( 922 | Block::default() 923 | .title(" 📄 Article Details ") 924 | .title_alignment(Alignment::Center) 925 | .borders(Borders::ALL) 926 | .border_type(NORMAL_BORDER) 927 | .border_style(Style::default().fg(PRIMARY_COLOR)) 928 | .padding(Padding::new(1, 1, 0, 0)), 929 | ) 930 | .style(Style::default().fg(TEXT_COLOR)); 931 | 932 | f.render_widget(header, chunks[0]); 933 | 934 | // Render content with HTML converted to plain text and formatted 935 | let description = if let Some(desc) = &item.description { 936 | from_read(desc.as_bytes(), 80) 937 | } else { 938 | "No description available".to_string() 939 | }; 940 | 941 | // Create content paragraph with enhanced formatting 942 | let content = Paragraph::new(description) 943 | .block( 944 | Block::default() 945 | .title(" 📝 Content ") 946 | .title_alignment(Alignment::Center) 947 | .borders(Borders::ALL) 948 | .border_type(NORMAL_BORDER) 949 | .border_style(Style::default().fg(PRIMARY_COLOR)) 950 | .padding(Padding::new(1, 1, 1, 1)), 951 | ) 952 | .style(Style::default().fg(TEXT_COLOR)) 953 | .wrap(Wrap { trim: true }); 954 | 955 | f.render_widget(content, chunks[1]); 956 | } 957 | } 958 | 959 | fn render_help_bar(f: &mut Frame, app: &App, area: Rect) { 960 | // Match on the input mode and view to determine the help text and style 961 | let (help_text, _style) = match app.input_mode { 962 | InputMode::Normal => { 963 | let help_text = match app.view { 964 | View::Dashboard => { 965 | if app.feeds.is_empty() { 966 | "a: Add feed | q: Quit | CTRL+C: Manage categories" 967 | } else { 968 | "↑/↓: Navigate | ENTER: View feed | a: Add feed | r: Refresh | f: Filter | /: Search | q: Quit" 969 | } 970 | } 971 | View::FeedList => { 972 | if app.feeds.is_empty() { 973 | "a: Add feed | q: Quit | TAB: Dashboard | CTRL+C: Categories" 974 | } else { 975 | "a: Add feed | c: Assign to category | DEL: Remove feed | ENTER: Open | q: Quit | CTRL+C: Categories" 976 | } 977 | } 978 | View::CategoryManagement => { 979 | "n: New category | e: Edit | d: Delete | SPACE: Toggle feeds | c: Add selected feed | ESC/q: Back" 980 | } 981 | View::FeedItems => { 982 | "h/esc: back to feeds | home: dashboard | enter: view detail | o: open link | /: search | q: quit" 983 | } 984 | View::FeedItemDetail => { 985 | "h/esc: back | home: dashboard | o: open in browser | q: quit" 986 | } 987 | }; 988 | (help_text, Style::default().fg(TEXT_COLOR)) 989 | } 990 | InputMode::InsertUrl => ("Enter feed URL (e.g., https://news.ycombinator.com/rss)", Style::default().fg(HIGHLIGHT_COLOR)), 991 | InputMode::SearchMode => ("Enter search term (press ENTER to search)", Style::default().fg(HIGHLIGHT_COLOR)), 992 | InputMode::FilterMode => ("", Style::default().fg(MUTED_COLOR)), 993 | InputMode::CategoryNameInput => ("", Style::default().fg(MUTED_COLOR)), 994 | InputMode::CategoryMode => ("", Style::default().fg(MUTED_COLOR)), 995 | }; 996 | 997 | // Only show help bar in normal mode 998 | if matches!(app.input_mode, InputMode::Normal) { 999 | // Create a stylized help bar with visually separated commands 1000 | let parts: Vec<&str> = help_text.split('|').collect(); 1001 | let mut spans = Vec::new(); 1002 | 1003 | for (idx, part) in parts.iter().enumerate() { 1004 | let trimmed = part.trim(); 1005 | 1006 | // Extract the command key and description 1007 | if let Some(pos) = trimmed.find(':') { 1008 | let (key, desc) = trimmed.split_at(pos + 1); 1009 | 1010 | // Add the key in highlight color 1011 | spans.push(Span::styled( 1012 | key, 1013 | Style::default() 1014 | .fg(HIGHLIGHT_COLOR) 1015 | .add_modifier(Modifier::BOLD), 1016 | )); 1017 | 1018 | // Add the description in normal text color 1019 | spans.push(Span::styled(desc, Style::default().fg(TEXT_COLOR))); 1020 | } else { 1021 | spans.push(Span::styled(trimmed, Style::default().fg(TEXT_COLOR))); 1022 | } 1023 | 1024 | // Add separator unless this is the last item 1025 | if idx < parts.len() - 1 { 1026 | spans.push(Span::styled(" | ", Style::default().fg(BORDER_COLOR))); 1027 | } 1028 | } 1029 | 1030 | let help = Paragraph::new(Line::from(spans)) 1031 | .alignment(Alignment::Center) 1032 | .block( 1033 | Block::default() 1034 | .borders(Borders::ALL) 1035 | .border_type(NORMAL_BORDER) 1036 | .border_style(Style::default().fg(PRIMARY_COLOR)) 1037 | .title(" 💡 Commands ") 1038 | .title_alignment(Alignment::Center), 1039 | ); 1040 | f.render_widget(help, area); 1041 | } 1042 | } 1043 | 1044 | fn render_error_modal(f: &mut Frame, error: &str) { 1045 | let area = centered_rect(60, 25, f.size()); 1046 | 1047 | // Clear the background 1048 | f.render_widget(Clear, area); 1049 | 1050 | // Create a visually appealing error box 1051 | let error_lines = vec![ 1052 | Line::from(Span::styled( 1053 | " ⚠️ ERROR ⚠️ ", 1054 | Style::default() 1055 | .fg(ERROR_COLOR) 1056 | .add_modifier(Modifier::BOLD), 1057 | )), 1058 | Line::from(""), 1059 | Line::from(Span::styled(error, Style::default().fg(TEXT_COLOR))), 1060 | Line::from(""), 1061 | Line::from(Span::styled( 1062 | "Press any key to dismiss", 1063 | Style::default().fg(MUTED_COLOR), 1064 | )), 1065 | ]; 1066 | 1067 | let error_text = Paragraph::new(error_lines) 1068 | .block( 1069 | Block::default() 1070 | .borders(Borders::ALL) 1071 | .border_type(ACTIVE_BORDER) 1072 | .border_style(Style::default().fg(ERROR_COLOR)) 1073 | .padding(Padding::new(2, 2, 1, 1)), 1074 | ) 1075 | .alignment(Alignment::Center) 1076 | .wrap(Wrap { trim: true }); 1077 | 1078 | f.render_widget(error_text, area); 1079 | } 1080 | 1081 | fn render_input_modal(f: &mut Frame, app: &App) { 1082 | let area = centered_rect(70, 20, f.size()); 1083 | 1084 | // Clear the background with semi-transparent effect 1085 | f.render_widget(Clear, area); 1086 | 1087 | // Create modal title and help text based on mode 1088 | let (title, help_text, icon) = if matches!(app.input_mode, InputMode::InsertUrl) { 1089 | ( 1090 | " Add Feed URL ", 1091 | "Enter the RSS feed URL and press Enter", 1092 | "🔗", 1093 | ) 1094 | } else { 1095 | (" Search ", "Enter search terms and press Enter", "🔍") 1096 | }; 1097 | 1098 | // Create an attractive input box 1099 | let mut lines = Vec::new(); 1100 | 1101 | // Add icon and title 1102 | lines.push(Line::from(vec![ 1103 | Span::styled( 1104 | format!(" {} ", icon), 1105 | Style::default().fg(SECONDARY_COLOR), 1106 | ), 1107 | Span::styled( 1108 | title, 1109 | Style::default() 1110 | .fg(HIGHLIGHT_COLOR) 1111 | .add_modifier(Modifier::BOLD), 1112 | ), 1113 | ])); 1114 | 1115 | // Add help text 1116 | lines.push(Line::from(vec![Span::styled( 1117 | format!(" {} ", help_text), 1118 | Style::default().fg(MUTED_COLOR), 1119 | )])); 1120 | 1121 | // Add spacer 1122 | lines.push(Line::from("")); 1123 | 1124 | // Add input field with cursor 1125 | let input_display = format!("{}█", app.input); 1126 | lines.push(Line::from(vec![ 1127 | Span::styled(" > ", Style::default().fg(PRIMARY_COLOR)), 1128 | Span::styled( 1129 | input_display, 1130 | Style::default() 1131 | .fg(ACCENT_COLOR) 1132 | .add_modifier(Modifier::BOLD), 1133 | ), 1134 | ])); 1135 | 1136 | // Add controls help 1137 | lines.push(Line::from("")); 1138 | lines.push(Line::from(vec![Span::styled( 1139 | " Press Enter to submit or Esc to cancel ", 1140 | Style::default().fg(TEXT_COLOR), 1141 | )])); 1142 | 1143 | let input_paragraph = Paragraph::new(lines).block( 1144 | Block::default() 1145 | .borders(Borders::ALL) 1146 | .border_type(ACTIVE_BORDER) 1147 | .border_style(Style::default().fg(PRIMARY_COLOR)) 1148 | .padding(Padding::new(2, 2, 1, 1)), 1149 | ); 1150 | 1151 | f.render_widget(input_paragraph, area); 1152 | } 1153 | 1154 | fn render_filter_modal(f: &mut Frame, app: &App) { 1155 | let area = centered_rect(70, 60, f.size()); 1156 | 1157 | // Clear the area 1158 | f.render_widget(Clear, area); 1159 | 1160 | // Create filter selection UI 1161 | let mut text = Vec::new(); 1162 | 1163 | // Header 1164 | text.push(Line::from(vec![ 1165 | Span::styled(" 🔍 ", Style::default().fg(PRIMARY_COLOR)), 1166 | Span::styled( 1167 | "Feed Filters", 1168 | Style::default() 1169 | .fg(HIGHLIGHT_COLOR) 1170 | .add_modifier(Modifier::BOLD), 1171 | ), 1172 | ])); 1173 | 1174 | text.push(Line::from("")); 1175 | text.push(Line::from(" Select filters to apply to your feed items:")); 1176 | text.push(Line::from("")); 1177 | 1178 | // Category filter 1179 | let available_categories = app.get_available_categories(); 1180 | let category_status = match &app.filter_options.category { 1181 | Some(cat) => format!("[{}]", cat), 1182 | None => "[Off]".to_string(), 1183 | }; 1184 | 1185 | text.push(Line::from(vec![ 1186 | Span::styled(" c - Category: ", Style::default().fg(TEXT_COLOR)), 1187 | Span::styled( 1188 | category_status, 1189 | Style::default().fg(if app.filter_options.category.is_some() { 1190 | HIGHLIGHT_COLOR 1191 | } else { 1192 | MUTED_COLOR 1193 | }), 1194 | ), 1195 | Span::styled( 1196 | if !available_categories.is_empty() { 1197 | format!(" ({})", available_categories.join(", ")) 1198 | } else { 1199 | "".to_string() 1200 | }, 1201 | Style::default().fg(MUTED_COLOR), 1202 | ), 1203 | ])); 1204 | 1205 | // Age filter 1206 | let age_status = match &app.filter_options.age { 1207 | Some(age) => { 1208 | let age_str = match age { 1209 | TimeFilter::Today => "Today", 1210 | TimeFilter::ThisWeek => "This Week", 1211 | TimeFilter::ThisMonth => "This Month", 1212 | TimeFilter::Older => "Older", 1213 | }; 1214 | format!("[{}]", age_str) 1215 | } 1216 | None => "[Off]".to_string(), 1217 | }; 1218 | 1219 | text.push(Line::from(vec![ 1220 | Span::styled(" t - Time/Age: ", Style::default().fg(TEXT_COLOR)), 1221 | Span::styled( 1222 | age_status, 1223 | Style::default().fg(if app.filter_options.age.is_some() { 1224 | HIGHLIGHT_COLOR 1225 | } else { 1226 | MUTED_COLOR 1227 | }), 1228 | ), 1229 | ])); 1230 | 1231 | // Author filter 1232 | let author_status = match app.filter_options.has_author { 1233 | Some(true) => "[With author]", 1234 | Some(false) => "[No author]", 1235 | None => "[Off]", 1236 | }; 1237 | 1238 | text.push(Line::from(vec![ 1239 | Span::styled(" a - Author: ", Style::default().fg(TEXT_COLOR)), 1240 | Span::styled( 1241 | author_status, 1242 | Style::default().fg(if app.filter_options.has_author.is_some() { 1243 | HIGHLIGHT_COLOR 1244 | } else { 1245 | MUTED_COLOR 1246 | }), 1247 | ), 1248 | ])); 1249 | 1250 | // Read status filter 1251 | let read_status = match app.filter_options.read_status { 1252 | Some(true) => "[Read]", 1253 | Some(false) => "[Unread]", 1254 | None => "[Off]", 1255 | }; 1256 | 1257 | text.push(Line::from(vec![ 1258 | Span::styled(" r - Read status: ", Style::default().fg(TEXT_COLOR)), 1259 | Span::styled( 1260 | read_status, 1261 | Style::default().fg(if app.filter_options.read_status.is_some() { 1262 | HIGHLIGHT_COLOR 1263 | } else { 1264 | MUTED_COLOR 1265 | }), 1266 | ), 1267 | ])); 1268 | 1269 | // Length filter 1270 | let length_status = match app.filter_options.min_length { 1271 | Some(100) => "[Short]", 1272 | Some(500) => "[Medium]", 1273 | Some(1000) => "[Long]", 1274 | Some(n) => &format!("[{} chars]", n), 1275 | None => "[Off]", 1276 | }; 1277 | 1278 | text.push(Line::from(vec![ 1279 | Span::styled(" l - Length: ", Style::default().fg(TEXT_COLOR)), 1280 | Span::styled( 1281 | length_status, 1282 | Style::default().fg(if app.filter_options.min_length.is_some() { 1283 | HIGHLIGHT_COLOR 1284 | } else { 1285 | MUTED_COLOR 1286 | }), 1287 | ), 1288 | ])); 1289 | 1290 | // Clear filters option 1291 | text.push(Line::from("")); 1292 | text.push(Line::from(vec![ 1293 | Span::styled(" x - ", Style::default().fg(TEXT_COLOR)), 1294 | Span::styled("Clear all filters", Style::default().fg(ERROR_COLOR)), 1295 | ])); 1296 | 1297 | text.push(Line::from("")); 1298 | text.push(Line::from("")); 1299 | 1300 | // Update the filter statistics 1301 | let (active_count, filtered_count, total_count) = app.get_filter_stats(); 1302 | 1303 | text.push(Line::from(vec![Span::styled( 1304 | format!( 1305 | " Active Filters: {}/5 | Showing: {}/{} items", 1306 | active_count, filtered_count, total_count 1307 | ), 1308 | Style::default().fg(MUTED_COLOR), 1309 | )])); 1310 | 1311 | // Add the filter summary 1312 | if active_count > 0 { 1313 | text.push(Line::from("")); 1314 | text.push(Line::from(vec![Span::styled( 1315 | format!(" Current filters: {}", app.get_filter_summary()), 1316 | Style::default().fg(SECONDARY_COLOR), 1317 | )])); 1318 | } 1319 | 1320 | text.push(Line::from("")); 1321 | text.push(Line::from(vec![Span::styled( 1322 | " Press Esc to close this dialog", 1323 | Style::default().fg(TEXT_COLOR), 1324 | )])); 1325 | 1326 | let filter_paragraph = Paragraph::new(text).block( 1327 | Block::default() 1328 | .borders(Borders::ALL) 1329 | .border_type(ACTIVE_BORDER) 1330 | .border_style(Style::default().fg(PRIMARY_COLOR)) 1331 | .title(" Filter Options ") 1332 | .title_alignment(Alignment::Center) 1333 | .padding(Padding::new(2, 2, 1, 1)), 1334 | ); 1335 | 1336 | f.render_widget(filter_paragraph, area); 1337 | } 1338 | 1339 | // Helper function to create a centered rect using up certain percentage of the available rect 1340 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 1341 | // Create padding effect with backdrop 1342 | let _ = Canvas::default() 1343 | .paint(|ctx| { 1344 | ctx.draw(&Rectangle { 1345 | x: 0.0, 1346 | y: 0.0, 1347 | width: r.width as f64, 1348 | height: r.height as f64, 1349 | color: Color::Rgb(0, 0, 0), 1350 | }); 1351 | }) 1352 | .x_bounds([0.0, r.width as f64]) 1353 | .y_bounds([0.0, r.height as f64]); 1354 | 1355 | // Calculate popup dimensions 1356 | let popup_layout = Layout::default() 1357 | .direction(Direction::Vertical) 1358 | .constraints([ 1359 | Constraint::Percentage((100 - percent_y) / 2), 1360 | Constraint::Percentage(percent_y), 1361 | Constraint::Percentage((100 - percent_y) / 2), 1362 | ]) 1363 | .split(r); 1364 | 1365 | Layout::default() 1366 | .direction(Direction::Horizontal) 1367 | .constraints([ 1368 | Constraint::Percentage((100 - percent_x) / 2), 1369 | Constraint::Percentage(percent_x), 1370 | Constraint::Percentage((100 - percent_x) / 2), 1371 | ]) 1372 | .split(popup_layout[1])[1] 1373 | } 1374 | 1375 | // Helper function to truncate a URL for display 1376 | fn truncate_url(url: &str, max_length: usize) -> String { 1377 | // Remove common prefixes for cleaner display 1378 | let clean_url = url 1379 | .replace("https://", "") 1380 | .replace("http://", "") 1381 | .replace("www.", ""); 1382 | 1383 | truncate_str(&clean_url, max_length) 1384 | } 1385 | 1386 | // Helper function to truncate a string with unicode awareness 1387 | fn truncate_str(s: &str, max_chars: usize) -> String { 1388 | if s.width() <= max_chars { 1389 | s.to_string() 1390 | } else { 1391 | // Find position to truncate while respecting unicode boundaries 1392 | let mut total_width = 0; 1393 | let mut truncate_idx = 0; 1394 | 1395 | for (idx, c) in s.char_indices() { 1396 | let char_width = c.width_cjk().unwrap_or(1); 1397 | if total_width + char_width > max_chars.saturating_sub(3) { 1398 | truncate_idx = idx; 1399 | break; 1400 | } 1401 | total_width += char_width; 1402 | } 1403 | 1404 | if truncate_idx > 0 { 1405 | format!("{}...", &s[..truncate_idx]) 1406 | } else { 1407 | // Fallback if we couldn't properly calculate (shouldn't happen often) 1408 | format!("{}...", &s[..max_chars.saturating_sub(3)]) 1409 | } 1410 | } 1411 | } 1412 | 1413 | // Update the render_category_management function to show feeds when a category is expanded 1414 | fn render_category_management(f: &mut Frame, app: &App, area: Rect) { 1415 | let chunks = Layout::default() 1416 | .direction(Direction::Vertical) 1417 | .constraints([ 1418 | Constraint::Length(3), // Title 1419 | Constraint::Min(3), // Category list 1420 | Constraint::Length(5), // Help text 1421 | ]) 1422 | .split(area); 1423 | 1424 | // Add a title block 1425 | let title = match &app.category_action { 1426 | Some(CategoryAction::AddFeedToCategory(url)) => { 1427 | // Show which feed is being assigned to a category 1428 | let feed_idx = app.feeds.iter().position(|f| f.url == *url); 1429 | let feed_title = feed_idx 1430 | .and_then(|idx| app.feeds.get(idx)) 1431 | .map_or("Unknown Feed", |feed| feed.title.as_str()); 1432 | format!(" 📂 Add '{}' to Category ", truncate_str(feed_title, 30)) 1433 | } 1434 | _ => " 📂 Category Management ".to_string(), 1435 | }; 1436 | 1437 | let title_block = Block::default() 1438 | .borders(Borders::ALL) 1439 | .border_type(NORMAL_BORDER) 1440 | .title(title) 1441 | .title_alignment(Alignment::Center) 1442 | .border_style(Style::default().fg(PRIMARY_COLOR)); 1443 | 1444 | f.render_widget(title_block, chunks[0]); 1445 | 1446 | // Prepare list items for categories and their feeds 1447 | let mut list_items = Vec::new(); 1448 | let mut list_indices = Vec::new(); // To map UI index to category index 1449 | 1450 | if app.categories.is_empty() { 1451 | list_items.push(ListItem::new(Line::from(Span::styled( 1452 | "No categories yet. Press 'n' to create a new category.", 1453 | Style::default().fg(MUTED_COLOR), 1454 | )))); 1455 | } else { 1456 | for (cat_idx, category) in app.categories.iter().enumerate() { 1457 | // Add category to the list 1458 | let icon = if category.expanded { "▼" } else { "▶" }; 1459 | let feed_count = category.feed_count(); 1460 | let count_text = if feed_count == 1 { 1461 | "1 feed".to_string() 1462 | } else { 1463 | format!("{} feeds", feed_count) 1464 | }; 1465 | 1466 | let style = if Some(cat_idx) == app.selected_category { 1467 | Style::default().fg(HIGHLIGHT_COLOR).add_modifier(Modifier::BOLD) 1468 | } else { 1469 | Style::default().fg(TEXT_COLOR) 1470 | }; 1471 | 1472 | list_items.push(ListItem::new(Line::from(Span::styled( 1473 | format!("{} {} ({})", icon, category.name, count_text), 1474 | style, 1475 | )))); 1476 | list_indices.push(Some(cat_idx)); 1477 | 1478 | // If category is expanded, show its feeds 1479 | if category.expanded { 1480 | let feeds_in_category = app.feeds.iter() 1481 | .enumerate() 1482 | .filter(|(_, feed)| category.contains_feed(&feed.url)) 1483 | .collect::>(); 1484 | 1485 | for (feed_idx, feed) in &feeds_in_category { 1486 | let feed_style = if Some(*feed_idx) == app.selected_feed { 1487 | Style::default().fg(ACCENT_COLOR) 1488 | } else { 1489 | Style::default().fg(MUTED_COLOR) 1490 | }; 1491 | 1492 | list_items.push(ListItem::new(Line::from(Span::styled( 1493 | format!(" → {}", truncate_str(&feed.title, 40)), 1494 | feed_style, 1495 | )))); 1496 | list_indices.push(None); // None means this is a feed, not a category 1497 | } 1498 | 1499 | // Show a message if the category is empty 1500 | if feeds_in_category.is_empty() { 1501 | list_items.push(ListItem::new(Line::from(Span::styled( 1502 | " (No feeds in this category)", 1503 | Style::default().fg(MUTED_COLOR), 1504 | )))); 1505 | list_indices.push(None); 1506 | } 1507 | } 1508 | } 1509 | } 1510 | 1511 | let categories_list = List::new(list_items) 1512 | .block( 1513 | Block::default() 1514 | .borders(Borders::ALL) 1515 | .border_type(NORMAL_BORDER) 1516 | .title(" Categories ") 1517 | .border_style(Style::default().fg(SECONDARY_COLOR)), 1518 | ) 1519 | .highlight_style( 1520 | Style::default() 1521 | .bg(PRIMARY_COLOR) 1522 | .fg(Color::Black) 1523 | .add_modifier(Modifier::BOLD), 1524 | ); 1525 | 1526 | // Create a mutable ListState based on the selected category 1527 | let mut list_state = ListState::default(); 1528 | if let Some(selected_idx) = app.selected_category { 1529 | // Find the corresponding index in the UI list (may differ due to expanded feeds) 1530 | if let Some(ui_idx) = list_indices.iter().position(|&cat_idx| cat_idx == Some(selected_idx)) { 1531 | list_state.select(Some(ui_idx)); 1532 | } 1533 | } 1534 | 1535 | f.render_stateful_widget(categories_list, chunks[1], &mut list_state); 1536 | 1537 | // Render help text 1538 | let help_text = if let Some(CategoryAction::AddFeedToCategory(_)) = &app.category_action { 1539 | "ENTER: Add to category | ESC/q: Cancel | UP/DOWN: Navigate" 1540 | } else { 1541 | "n: New category | e: Edit | d: Delete | SPACE: Toggle feeds | c: Add selected feed | ESC/q: Back" 1542 | }; 1543 | 1544 | let help_block = Block::default() 1545 | .borders(Borders::ALL) 1546 | .border_type(NORMAL_BORDER) 1547 | .title(" Controls ") 1548 | .border_style(Style::default().fg(MUTED_COLOR)); 1549 | 1550 | let help_para = Paragraph::new(help_text) 1551 | .block(help_block) 1552 | .alignment(Alignment::Center) 1553 | .wrap(Wrap { trim: true }); 1554 | 1555 | f.render_widget(help_para, chunks[2]); 1556 | } 1557 | 1558 | // Add a new function to render the category name input modal 1559 | fn render_category_input_modal(f: &mut Frame, app: &App) { 1560 | let area = centered_rect(60, 20, f.size()); 1561 | 1562 | // Clear the area behind the modal 1563 | f.render_widget(Clear, area); 1564 | 1565 | let chunks = Layout::default() 1566 | .direction(Direction::Vertical) 1567 | .margin(1) 1568 | .constraints([ 1569 | Constraint::Length(3), // Title 1570 | Constraint::Length(3), // Input field 1571 | Constraint::Length(1), // Spacer 1572 | Constraint::Length(3), // Help text 1573 | ]) 1574 | .split(area); 1575 | 1576 | // Determine title based on the current action 1577 | let title = match &app.category_action { 1578 | Some(CategoryAction::Create) => " Create New Category ", 1579 | Some(CategoryAction::Rename(_)) => " Rename Category ", 1580 | _ => " Category Name ", 1581 | }; 1582 | 1583 | // Create title block 1584 | let title_block = Block::default() 1585 | .borders(Borders::ALL) 1586 | .title(title) 1587 | .title_alignment(Alignment::Center) 1588 | .border_style(Style::default().fg(PRIMARY_COLOR)); 1589 | 1590 | f.render_widget(title_block, chunks[0]); 1591 | 1592 | // Create input field 1593 | let input_block = Block::default() 1594 | .borders(Borders::ALL) 1595 | .title(" Name ") 1596 | .border_style(Style::default().fg(SECONDARY_COLOR)); 1597 | 1598 | let input_text = Paragraph::new(app.input.as_str()) 1599 | .block(input_block) 1600 | .style(Style::default().fg(HIGHLIGHT_COLOR)); 1601 | 1602 | f.render_widget(input_text, chunks[1]); 1603 | 1604 | // Position cursor at the end of input 1605 | let cursor_x = app.input.width() as u16 + chunks[1].x + 1; // +1 for border 1606 | let cursor_y = chunks[1].y + 1; 1607 | f.set_cursor(cursor_x, cursor_y); 1608 | 1609 | // Help text 1610 | let help_block = Block::default() 1611 | .borders(Borders::ALL) 1612 | .title(" Controls ") 1613 | .border_style(Style::default().fg(MUTED_COLOR)); 1614 | 1615 | let help_text = "ENTER: Confirm | ESC: Cancel"; 1616 | let help_para = Paragraph::new(help_text) 1617 | .block(help_block) 1618 | .alignment(Alignment::Center); 1619 | 1620 | f.render_widget(help_para, chunks[3]); 1621 | } 1622 | --------------------------------------------------------------------------------