diff --git a/.gitignore b/.gitignore index ea8c4bf..6af1d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +src/assets/icons +config-gen/node_modules diff --git a/Cargo.lock b/Cargo.lock index 7f37fc3..e4acad5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.19" @@ -82,6 +88,26 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "assert_fs" version = "1.1.3" @@ -158,6 +184,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.0" @@ -174,12 +209,39 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.27" @@ -235,12 +297,62 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.7.1" @@ -266,6 +378,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -291,6 +421,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -303,7 +449,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.0.7", "signal-hook", "signal-hook-mio", "winapi", @@ -318,6 +464,57 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -345,6 +542,47 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -377,6 +615,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -402,12 +646,67 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -423,6 +722,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -495,6 +800,26 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.0.7", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -567,11 +892,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -702,15 +1043,26 @@ name = "iconmate" version = "1.0.1" dependencies = [ "anyhow", + "arboard", "assert_fs", "clap", + "color-eyre", + "crossterm 0.29.0", + "dirs", "inquire", + "json5", + "nucleo-matcher", "predicates", + "ratatui", "regex", "reqwest", + "serde", + "serde_json", + "shlex", "tempfile", "tokio", "tokio-test", + "tui-textarea", ] [[package]] @@ -799,6 +1151,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -836,6 +1194,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.10.0" @@ -846,6 +1224,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "inquire" version = "0.9.1" @@ -853,11 +1237,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" dependencies = [ "bitflags", - "crossterm", + "crossterm 0.29.0", "dyn-clone", "fuzzy-matcher", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", +] + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -882,6 +1279,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -898,12 +1304,45 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -938,6 +1377,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.5" @@ -951,56 +1399,150 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "moxcms" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "adler2", + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", ] [[package]] -name = "mio" -version = "1.0.4" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "bitflags", + "dispatch2", + "objc2", ] [[package]] -name = "native-tls" -version = "0.2.14" +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] -name = "normalize-line-endings" -version = "0.3.0" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "autocfg", + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -1068,6 +1610,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parking_lot" version = "0.12.4" @@ -1091,12 +1645,61 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1115,6 +1718,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -1163,6 +1779,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.40" @@ -1178,6 +1809,27 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.13" @@ -1187,6 +1839,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.11.3" @@ -1276,6 +1939,19 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.0.7" @@ -1285,7 +1961,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -1425,6 +2101,26 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1461,6 +2157,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.10" @@ -1489,12 +2191,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1562,7 +2292,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -1572,6 +2302,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1581,6 +2331,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1739,6 +2503,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] @@ -1747,6 +2533,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1759,11 +2568,28 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" @@ -1794,12 +2620,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1915,6 +2753,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + [[package]] name = "winapi" version = "0.3.9" @@ -1952,13 +2796,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -1969,7 +2819,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -1978,7 +2828,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2151,6 +3001,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.0.7", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.0" @@ -2175,6 +3042,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2234,3 +3121,18 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 53cc1a0..62e1cf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,19 @@ clap = { version = "4.5", features = ["derive"] } reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1.37", features = ["full"] } anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" inquire = "0.9.1" regex = "1.11.3" +ratatui = "0.29.0" +crossterm = "0.29.0" +color-eyre = "0.6.5" +tui-textarea = "0.7.0" +arboard = "3.6.1" +dirs = "6.0.0" +json5 = "0.4.1" +shlex = "1.3.0" +nucleo-matcher = "0.3" [dev-dependencies] tempfile = "3.8" diff --git a/Justfile b/Justfile index a0187d0..d5d620f 100644 --- a/Justfile +++ b/Justfile @@ -1,9 +1,14 @@ -set dotenv-load +set dotenv-load := true default: just --list +# Usage: just config-schema +config-schema: + cd config-gen && bun install && bun run generate + # Tag and release a new version - custom script by carlo. + # Usage: just tag_and_release tag: sh tag_and_release.sh diff --git a/README.md b/README.md index 6c1df90..941aa59 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,43 @@ -# iconmate ๐Ÿ’š - -Your new favorite way to manage icons for your Vite, NextJS projects without icon libraries! - -Based on my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library). Built with ๐Ÿฆ€ Rust, โšก๏ธ designed for speed, ๐Ÿฆ… made for developers who hate icon library bloat. - -Stop installing bloated icon libraries. All you need is [icones.js.org](https://icones.js.org) or your designer's Figma icon pack and paste them into your project with surgical precision. - -**What Makes It Special โœจ** - -- **Zero Dependencies** ๐Ÿ“ฆ: Just a CLI, No icon libraries to bundle -- **Framework Native** ๐Ÿงฉ: Works with React, Vue, Svelte, Solid - generates components automatically -- **Interactive Mode** ๐ŸŽฎ: Just run `iconmate` and let it guide you -- **URL Support** ๐ŸŒ: Fetch from any SVG URL, not just iconify -- **Raw SVG** ๐Ÿ“‹: Copy-paste SVG code directly -- **Empty SVG** ๐Ÿ—๏ธ: Create placeholder icons for rapid prototyping +

+ iconmate logo +

+ +

iconmate

+ +

Add SVG icons to your JS apps without icon libraries.

+ +

+ iconmate banner +

+ +

+ Quick Start + ยท + Install + ยท + Presets + ยท + CLI Commands + ยท + AI Ready + ยท + Configuration +

+ +Built from my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library), `iconmate` is a Rust-powered CLI for developers who want the speed and control of plain files without icon-package bloat. + +Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. `iconmate` drops the icon straight into your project and keeps your exports tidy. + +## Why iconmate? + +- **Made with Rust๐Ÿฆ€**: Fast TUI that you can use on any IDE (powered by ratatui, nucleo). +- **AI-ready automation** ๐Ÿค–: Let your coding agents get icons and add it to your project! A CLI is basically an MCP, just let AI use `iconmate --help` and it should be able to get everything running! +- **Zero dependencies** ๐Ÿ“ฆ: No icon library runtime added to your app +- **Framework-native output** ๐Ÿงฉ: Generate files for React, Vue, Svelte, Solid, or plain SVG +- **Interactive by default** ๐ŸŽฎ: Run `iconmate` and have a pleasant interactive TUI +- **Multiple sources** ๐ŸŒ: Pull icons from Iconify names, URLs, or even raw SVG (which means it works with your private icon packs i.e. Anron) +- **Fast workflow** โšก: Generate file + export line in one step +- **Prototype-friendly** ๐Ÿ—๏ธ: Create empty SVG placeholders when needed ## Quick Start @@ -21,16 +45,11 @@ Stop installing bloated icon libraries. All you need is [icones.js.org](https:// # Install npm install -g iconmate -# Run inside your project ๐Ÿš€ +# Run the TUI in your project ๐Ÿš€ iconmate - -> ๐Ÿ“ Folder (src/assets/icons/) # Enter -> โœจ Preset # Choose react -> ๐Ÿš€ Icon # heroicons:heart -> ๐Ÿ’Ž Name # Heart ``` -โœจ That's it. The interactive CLI guides you through adding icon to your project! +โœจ That's it. The interactive TUI guides you through adding icons to your project. ```tsx // ๐Ÿ‘‡ Then, you can just easily use any icon on your project like this! @@ -42,49 +61,105 @@ function MyApp() { } ``` -You can also add sensible defaults by passing flags: +## Configuration + +You can also add sensible defaults by passing flags as configs: ```bash iconmate --folder src/components/Icons/ --folder src/components/icons iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart ``` +Iconmate now includes config schemas + TS type definitions in the repo: + +- Local config schema (repo): [`iconmatelocal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmatelocal.schema.json) +- Global config schema (repo): [`iconmateglobal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmateglobal.schema.json) +- Local config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json` +- Global config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json` +- Schema source: `config-gen/src/schema.ts` +- TS type definitions: `config-gen/src/types.ts` + +Regenerate schemas from project root: + +```bash +just config-schema +``` + +`just config-schema` installs `config-gen` deps and generates both schema files. + +### Local Config (`iconmate.config.json`) + +```json +{ + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + "folder": "src/assets/icons", + "preset": "normal", + "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';", + "svg_view_cmd": "zed %filename%" +} +``` + +Use the raw URL for `$schema` so editors can fetch JSON directly. + +Local config keys: + +- `folder` (default: `src/assets/icons`) +- `preset` (default: `normal`, meaning plain `.svg` mode) +- `output_line_template` (default: `export { default as Icon%name% } from './%icon%%ext%';`) +- `svg_view_cmd` (supports `%filename%` token) + +Allowed `preset` values: + +- `normal` (plain SVG mode) +- `react` +- `svelte` +- `solid` +- `vue` +- `emptysvg` + +### Global Config (user-level) + +Global config is for user-wide defaults and currently documents `svg_view_cmd`. + +Suggested paths: + +- macOS: `~/Library/Application Support/iconmate/config.json` +- Linux: `~/.config/iconmate/config.json` +- Windows: `%APPDATA%\\iconmate\\config.json` + +Example global config: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + "svg_view_cmd": "code %filename%" +} +``` + +> [!NOTE] +> This release adds config schemas and generated docs/types. Runtime loading/precedence wiring in the CLI/TUI is tracked in `folder-system-plan.md`. + ## Installation ### NPM ๐Ÿฆ– ```bash +# Install globally with either: npm install -g iconmate -# or pnpm add -g iconmate -# or bun add -g iconmate -``` - -For one-off usage: - -```bash +# Use without installing with either: npx iconmate -# or pnpm dlx iconmate -# or bunx iconmate ``` -> [!NOTE] -> **Note for Bun users:** Bun doesn't run `postinstall` scripts [by default](https://bun.com/guides/install/trusted) which is needed to install the iconmate binary. Add `"trustedDependencies": ["iconmate"]` to your `package.json` to do it! But you're only limited to running it with a package.json. -> -> Recommended: Just use pnpx for quick one-off usage. If in a project, either install globally or configure trustDependencies. - ### Install from Cargo ๐Ÿฆ€ ```bash cargo install iconmate -``` -Or clone and install from source: - -```bash +# Or clone and install from source: git clone https://github.com/blankeos/iconmate.git cd iconmate cargo install --path . @@ -104,33 +179,24 @@ Determines the output filetype and the contents inside that file type. | `emptysvg` | `.svg` | Placeholder | > [!IMPORTANT] -> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr). I covered how to do this in: +> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr) for your js apps. I covered how to do this in: > > - [SolidJS (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-solidjs) > - [React (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-vite) > - [React (NextJS)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-nextjs) > - Vue - contribution welcome! -> - Svelte - couldn't find an svgr integration :( +> - Svelte - couldn't find an svgr integration. Just use the svg preset. ## Command Line -### Interactive Mode (Recommended) +### Interactive TUI Mode (Recommended) ```bash iconmate - -# Description of each prompt: -> ๐Ÿ“ Folder (src/assets/icons/) # where your icons will be saved. - -> โœจ Preset # i.e. How will it be saved? An `.svg` or `.tsx` file in react, solid, etc. - -> ๐Ÿš€ Icon # Source of your icon. i.e. 'heroicons:heart' from https://icones.js.org, full URL, or any SVG. Can be empty except for 'emptysvg' preset. - -> ๐ŸŒ„ Filename # The filename without the extension. i.e. heroicons:heart. Will only be prompted if you used an SVG, or an URL on icon. - -> ๐Ÿ’Ž Name # The "Heart" in ``` +This section is helpful for AI: + ### Add Specific Icon ```bash @@ -149,6 +215,12 @@ iconmate add --folder src/assets/icons --icon https://api.iconify.design/mdi:hea iconmate add --folder src/assets/icons --icon '...' --name Heart ``` +You can also pull raw SVG directly from the Iconify API: + +```bash +iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart +``` + ### Custom Export Template ```bash @@ -161,6 +233,51 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out iconmate delete --folder src/assets/icons ``` +### Iconify API Commands + +```bash +# Search by keyword (text: one prefix:icon per line) +iconmate iconify search heart + +# Search with pagination and JSON output +iconmate iconify search heart --limit 20 --start 0 --format json + +# Include collection metadata in JSON search output +iconmate iconify search heart --format json --include-collections + +# List all available collections +iconmate iconify collections + +# List icons from one collection prefix +iconmate iconify collection mdi + +# Get one icon as raw SVG (default) +iconmate iconify get mdi:heart + +# Get one icon as raw Iconify JSON +iconmate iconify get mdi:heart --format json +``` + +`iconmate iconify get --format json` uses Iconify's JSON endpoint format, +for example `https://api.iconify.design/mdi.json?icons=heart`. + +### AI-Ready Workflows + +`iconmate` is designed to be easy for AI agents and scripts to drive end-to-end. + +```bash +# 1) Search in machine-readable JSON +iconmate iconify search heart --format json --limit 20 --include-collections + +# 2) Add an icon non-interactively from prefix:name +iconmate add --folder src/assets/icons --icon mdi:heart --name Heart + +# 3) Or fetch raw SVG from Iconify API and add directly +iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart +``` + +This means an AI can search, choose, and add icons without opening a browser. + ### Package.json Scripts Best practice: Add sensible defaults to your script runner. @@ -169,12 +286,6 @@ Best practice: Add sensible defaults to your script runner. "scripts": { // Usage: npm run iconmate (usually this is the only command you need)! "iconmate": "iconmate --folder src/assets/icons/" - - // Usage: npm run iconmate-react - "iconmate-react": "iconmate --folder ./src/assets/icons/ --preset react", - - // Usage: npm run iconmate-empty - "iconmate-empty": "iconmate --folder ./src/assets/icons/ --preset emptysvg", } ``` @@ -186,9 +297,9 @@ Best practice: Add sensible defaults to your script runner. ## How It Works -1. **Find your icon**: Visit https://icones.js.org. -2. **Copy the name**: Like `heroicons:heart`. -3. **Run iconmate**: `iconmate` +1. **Find your icon**: Use https://icones.js.org _or_ `iconmate iconify search `. +2. **Pick the icon id**: For example `heroicons:heart`. +3. **Add with iconmate**: Interactive (`iconmate`) or direct (`iconmate add ...`). ![illustration](https://raw.githubusercontent.com/Blankeos/iconmate/refs/heads/main/_docs/icones-cli-illustration.png) @@ -215,16 +326,23 @@ Contributions are welcomeโ€”pull requests for bug fixes, new framework presets, - โœ… Custom export templates - โœ… Zero-config installation -### Original Future Plans +### Roadmap & Out-of-scoped - [x] An empty command. Creates an .svg, adds it to the index.ts with a name you can specify. - [x] Paste an actual svg instead of an icon `name`. -- [x] Just a `--preset=svg,react,solid,svelte,vue` - which basically overrides templates. Default is `svg`. +- [x] Presets (`normal`, `react`, `solid`, `svelte`, `vue`, `emptysvg`) override output templates and file types. - [x] Prompt Mode via `iconmate` - Interactive mode so you won't need to pass arguments. - [x] Delete an icon using `iconmate delete` +- [x] An interactive TUI instead of prompt-mode. + - [x] Rename in the TUI (but recommended for you to just use the LSP to do it) + - [x] A lot of TUI functionalities wokr +- [x] `iconmate iconify --help` commands for AI to easily look for icons itself. +- [x] Search and add Iconify icons directly inside the TUI (no need to open https://icones.js.org). - [ ] Other frameworks i.e. --preset=flutter or Go/Rust GUI apps? (Not sure how they work yet though). - [ ] ~Zed or VSCode Extension~ (seems unnecessary now, it's just a CLI) +### Near-Term Roadmap + --- Made with Rust ๐Ÿฆ€ | Based on [my blog post][my_blog] diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..175352c --- /dev/null +++ b/TODOS.md @@ -0,0 +1,68 @@ +# Iconmate TODO Roadmap + +## Done in this pass + +- [x] Enter submits add popup (when focused on Name). +- [x] Paste shortcut support across add popup fields (Cmd/Ctrl+V behavior). +- [x] Default name + filename inference from Iconify names and known URLs. +- [x] Parse Iconify URL patterns and icones.js.org URL patterns. +- [x] Add popup visual cleanup (reduced borders/lines, removed debug block). +- [x] Delete popup visual cleanup (simpler `y` / `n` flow, less instruction noise). + +## Existing completed + +- [x] Help popup: status instead of defaults. +- [x] Homepage: Up/Down during search while keeping focus. +- [x] `iconmate.jsonc` generation. + +## Next high-priority slice + +- [x] Rename flow UX + - [x] Add a lightweight rename action that only renames the file path export target. + - [x] Keep icon component alias unchanged and show a warning for alias rename in-editor. + - [x] Add explicit messaging to recommend IDE rename refactor for symbol-level rename. + - [x] Acceptance: rename updates file + `index.ts` export path safely. + +- [x] Global system prefs (`~/.config/iconmate.jsonc` or `~/iconmate.jsonc`) + - [x] Confirm final search paths and precedence for macOS/Linux/Windows. + - [x] Implement robust loader with unknown-key warnings (non-fatal). + - [x] Add schema validation and friendly error output with file path context. + - [x] Acceptance: startup logs/source reflect applied global values. + +- [x] `o` key to open selected icon + - [x] Add keybind in main list and shared opener support for Iconify search list integration. + - [x] Resolve viewer command from local -> global -> OS default. + - [x] Add `%filename%` token substitution and command escaping behavior. + - [x] Acceptance: selected icon opens in configured app/editor. + +- [x] `svg_viewer_cmd` defaults and fallback behavior + - [x] macOS: Quick Look/open behavior. + - [x] Linux/Windows: browser/system default fallback. + - [x] Fallback to web preview when local command fails. + - [x] Acceptance: command works cross-platform with graceful fallback. + +## Add-flow product direction + +- [x] ~During add: allow icon discovery using Iconify API inside TUI~ + - Prototype inline query input + paginated result list. + - Validate whether this beats copy/paste from icones.js.org. + - Decide final UX based on keystroke count and speed. + +- [x] Dedicated icon search interface (TUI) + - [x] Launch from Home (`i`) instead of starting inside Add popup. + - [x] Build collection picker + icon view with `Tab` switching and Up/Down navigation. + - [x] Use `/collections` for collections and local client-side collection filtering. + - [x] Run debounced icon `/search` only while the Icons tab is active. + - [x] `Enter` on collection clears search input and opens collection-scoped icon list. + - [x] `Enter` on icon opens Add popup with icon/name/filename prefilled (no auto-submit). + - [x] `Ctrl+o` in Icons downloads to temp and opens preview with loading/status feedback. + +- [x] Search as standalone CLI for AI/tooling + - Add machine-friendly command (JSON first). + - Keep deterministic outputs and stable field names. + - Acceptance: scriptable icon search with no interactive prompts. + +## Notes + +- Keep TUI minimal-first: less chrome, more keyboard speed. +- Prefer defaults inferred from icon source whenever possible. diff --git a/_docs/iconmate_banner.jpg b/_docs/iconmate_banner.jpg new file mode 100644 index 0000000..7d14bda Binary files /dev/null and b/_docs/iconmate_banner.jpg differ diff --git a/_docs/iconmate_logo.png b/_docs/iconmate_logo.png new file mode 100644 index 0000000..733434a Binary files /dev/null and b/_docs/iconmate_logo.png differ diff --git a/config-gen/bun.lock b/config-gen/bun.lock new file mode 100644 index 0000000..30f53ae --- /dev/null +++ b/config-gen/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@iconmate/config-gen", + "dependencies": { + "zod": "^4.1.11", + }, + }, + }, + "packages": { + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/config-gen/package.json b/config-gen/package.json new file mode 100644 index 0000000..5c6efdb --- /dev/null +++ b/config-gen/package.json @@ -0,0 +1,12 @@ +{ + "name": "@iconmate/config-gen", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "generate": "bun src/generate.ts" + }, + "dependencies": { + "zod": "^4.1.11" + } +} diff --git a/config-gen/src/generate.ts b/config-gen/src/generate.ts new file mode 100644 index 0000000..3aa5d89 --- /dev/null +++ b/config-gen/src/generate.ts @@ -0,0 +1,44 @@ +import { writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as z from "zod"; + +import { GlobalConfigSchema, LocalConfigSchema } from "./schema.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../.."); +const localOutputPath = resolve(root, "iconmatelocal.schema.json"); +const globalOutputPath = resolve(root, "iconmateglobal.schema.json"); +const schemaDraft = "https://json-schema.org/draft/2020-12/schema"; + +function writeJson(filePath: string, json: object) { + writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`, "utf8"); +} + +const localSchema = z.toJSONSchema(LocalConfigSchema, { + io: "input", + target: "draft-2020-12" +}); + +const globalSchema = z.toJSONSchema(GlobalConfigSchema, { + io: "input", + target: "draft-2020-12" +}); + +const localStandaloneSchema = { + $schema: schemaDraft, + $id: "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + ...localSchema +}; + +const globalStandaloneSchema = { + $schema: schemaDraft, + $id: "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json", + ...globalSchema +}; + +writeJson(localOutputPath, localStandaloneSchema); +writeJson(globalOutputPath, globalStandaloneSchema); + +console.log(`Generated ${localOutputPath}`); +console.log(`Generated ${globalOutputPath}`); diff --git a/config-gen/src/schema.ts b/config-gen/src/schema.ts new file mode 100644 index 0000000..3e8c6fc --- /dev/null +++ b/config-gen/src/schema.ts @@ -0,0 +1,61 @@ +import * as z from "zod"; + +export const DEFAULT_FOLDER = "src/assets/icons"; +export const DEFAULT_PRESET = "normal"; +export const DEFAULT_OUTPUT_LINE_TEMPLATE = + "export { default as Icon%name% } from './%icon%%ext%';"; + +export const PRESET_VALUES = ["normal", "react", "svelte", "solid", "vue", "emptysvg"] as const; + +export const PresetSchema = z.enum(PRESET_VALUES).meta({ + title: "Preset", + description: + "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.", + default: DEFAULT_PRESET, + examples: ["normal", "react", "solid", "emptysvg"] +}); + +export const SvgViewCommandSchema = z.string().min(1).meta({ + title: "SVG View Command", + description: + "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.", + examples: ["zed %filename%", "code %filename%", "open %filename%"] +}); + +export const OutputLineTemplateSchema = z.string().min(1).meta({ + title: "Output Line Template", + description: + "Template for each export line. Supported placeholders: `%name%`, `%icon%`, `%ext%`.", + default: DEFAULT_OUTPUT_LINE_TEMPLATE, + examples: [DEFAULT_OUTPUT_LINE_TEMPLATE] +}); + +export const LocalConfigSchema = z + .object({ + folder: z.string().min(1).optional().meta({ + description: "Folder where icons are written.", + default: DEFAULT_FOLDER, + examples: [DEFAULT_FOLDER, "src/components/icons"] + }), + preset: PresetSchema.optional(), + output_line_template: OutputLineTemplateSchema.optional(), + svg_view_cmd: SvgViewCommandSchema.optional() + }) + .meta({ + id: "IconmateLocalConfig", + title: "Iconmate Local Config", + description: "Schema for project-level iconmate config (`iconmate.config.json`)." + }); + +export const GlobalConfigSchema = z + .object({ + svg_view_cmd: SvgViewCommandSchema.optional() + }) + .meta({ + id: "IconmateGlobalConfig", + title: "Iconmate Global Config", + description: "Schema for user-level iconmate config at the OS-specific config directory." + }); + +export type LocalConfig = z.input; +export type GlobalConfig = z.input; diff --git a/config-gen/src/types.ts b/config-gen/src/types.ts new file mode 100644 index 0000000..9e0b941 --- /dev/null +++ b/config-gen/src/types.ts @@ -0,0 +1,41 @@ +export type IconmatePreset = "normal" | "react" | "svelte" | "solid" | "vue" | "emptysvg"; + +/** + * Project-level Iconmate config loaded from `iconmate.config.json`. + */ +export interface IconmateLocalConfig { + /** + * Folder where icons are written. + * Default: `src/assets/icons`. + */ + folder?: string; + + /** + * Output preset. `normal` means plain `.svg` mode. + * Default: `"normal"`. + */ + preset?: IconmatePreset; + + /** + * Template used for each export line in `index.ts`. + * Supported variables: `%name%`, `%icon%`, `%ext%`. + */ + output_line_template?: string; + + /** + * Command used to open SVG files from the TUI. + * Use `%filename%` as the SVG file path placeholder. + */ + svg_view_cmd?: string; +} + +/** + * User-level Iconmate config loaded from the OS-specific config path. + */ +export interface IconmateGlobalConfig { + /** + * Command used to open SVG files from the TUI. + * Use `%filename%` as the SVG file path placeholder. + */ + svg_view_cmd?: string; +} diff --git a/config-gen/tsconfig.json b/config-gen/tsconfig.json new file mode 100644 index 0000000..bf4fec0 --- /dev/null +++ b/config-gen/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/folder-system-plan.md b/folder-system-plan.md new file mode 100644 index 0000000..7031e19 --- /dev/null +++ b/folder-system-plan.md @@ -0,0 +1,129 @@ +# Folder + Config System Plan + +Goal: add a clean configuration system for both project-level defaults and user-level defaults, plus a generated JSON schema and typed TS definitions for editor tooling. + +## Scope + +- Add a local config file for per-project behavior. +- Add a global config file for user-wide behavior. +- Generate root JSON schema files from a `config-gen` package using Zod v4. +- Wire `svg_view_cmd` into TUI open behavior (`o`) for both local icons and Iconify search results. + +## Config Surfaces + +### Local Config (project-level) + +- Filename: `iconmate.config.json` (in project root). +- Intended usage: checked into repo, shared with the team. +- Keys: + - `folder` (local only) + - `preset` (local only) + - `output_line_template` (local only) + - `svg_view_cmd` (local + global) + +### Global Config (user-level) + +- Intended usage: personal machine defaults. +- Initial key support: + - `svg_view_cmd` +- Suggested paths (using OS conventions): + - macOS: `~/Library/Application Support/iconmate/config.json` + - Linux: `~/.config/iconmate/config.json` + - Windows: `%APPDATA%\\iconmate\\config.json` + +## Config Keys and Defaults + +- `folder` + - Scope: local only + - Type: `string` + - Default: `src/assets/icons` +- `preset` + - Scope: local only + - Type: `string` + - Default: empty (`""`) which means plain SVG mode +- `svg_view_cmd` + - Scope: local + global + - Type: `string` + - Default behavior: + - macOS: open with Quick Look + - Linux: open with system browser + - Windows: open with default browser + - Custom value examples: + - `zed %filename%` + - `code %filename%` + - Token support: + - `%filename%` = full path to the SVG file to open +- `output_line_template` + - Scope: local only + - Type: `string` + - Default: `export { default as Icon%name% } from './%icon%%ext%';` + - Variables: + - `%name%` + - `%icon%` + - `%ext%` + +## Precedence Rules + +- For `folder`, `preset`, and `output_line_template`: CLI flag > local config > built-in default. +- For `svg_view_cmd`: CLI flag (if added later) > local config > global config > OS default. +- Unknown keys should be ignored with a warning (non-fatal). + +## TUI Open Behavior (`o`) + +- Local icon list: + - pressing `o` opens the existing file via resolved `svg_view_cmd`. +- Iconify search results: + - pressing `o` downloads the SVG to a cache file, then opens it via resolved `svg_view_cmd`. + +## Cache Plan for Iconify Preview + +- Use OS data directory with an `iconmate` namespace. +- Suggested cache root: + - macOS: `~/Library/Application Support/iconmate/cache/iconify` + - Linux: `~/.local/share/iconmate/cache/iconify` + - Windows: `%LOCALAPPDATA%\\iconmate\\cache\\iconify` +- Cache filename strategy: `__.svg` (sanitized for filesystem safety). + +## `config-gen` Package Plan + +- Create folder: `config-gen/`. +- Add `config-gen/package.json`. +- Use Zod v4 as source of truth for config schemas and descriptions. +- Define schemas in TypeScript and generate: + - local/global config TS types + - `iconmatelocal.schema.json` at repo root + - `iconmateglobal.schema.json` at repo root + +Suggested layout: + +- `config-gen/src/schema.ts` (zod definitions + docs metadata) +- `config-gen/src/generate.ts` (writes root schema files) +- `iconmatelocal.schema.json` (generated, committed) +- `iconmateglobal.schema.json` (generated, committed) + +## Documentation Requirements + +- README should include: + - local config filename and key meanings + - global config purpose and location conventions + - `svg_view_cmd` token usage (`%filename%`) + - defaults for macOS/Linux/Windows behavior + - note that `o` opens local and Iconify preview SVGs + +## Milestones + +1. Add `config-gen` scaffolding with Zod v4. +2. Define local/global schemas and TS types. +3. Generate and commit root schema files. +4. Load local/global config at app startup and apply precedence. +5. Implement `o` open flow for local and Iconify search entries. +6. Add docs and tests. + +## Acceptance Criteria + +- Local config supports `folder`, `preset`, `output_line_template`, and `svg_view_cmd`. +- Global config supports `svg_view_cmd`. +- Root schema files are generated from Zod schemas. +- `o` works for local icons and Iconify search results. +- Custom viewer command supports `%filename%` substitution. +- README clearly documents behavior and defaults. diff --git a/iconify-cli-plan.md b/iconify-cli-plan.md new file mode 100644 index 0000000..fc8795c --- /dev/null +++ b/iconify-cli-plan.md @@ -0,0 +1,152 @@ +# Iconify CLI + API Plan + +Goal: ship first-class Iconify commands in `iconmate` with a reusable API client and AI-friendly output defaults. + +## Scope + +- Keep command group: `iconmate iconify ...`. +- Keep shared API module: `src/iconify.rs`. +- Keep existing Iconify SVG fetches routed through shared client. +- Keep API models and errors presentation-agnostic (no CLI formatting in API layer). +- Update endpoint contracts to match real Iconify responses. + +## Reality Check from API Probes + +- Base URL: `https://api.iconify.design` +- `GET /collections` returns a top-level object keyed by prefix (not wrapped in `collections`). +- `GET /collections?prefix=mdi` works as an exact-prefix filter. +- `GET /collections?prefix=m` returns empty (no fuzzy prefix matching). +- `GET /collection?prefix=mdi` does not return an `icons` field; it returns `uncategorized`, `categories`, `aliases`, and `hidden`. +- `GET /{prefix}:{icon}.svg` returns SVG payload. +- `GET /{prefix}.json?icons={icon}` returns icon JSON payload. + +## CLI Surface + +### Commands + +- `iconmate iconify search ` +- `iconmate iconify collections` +- `iconmate iconify collection ` +- `iconmate iconify get ` + +### Flags + +- `search` + - `--limit ` + - `--start ` + - `--format ` (default: `text`) + - `--include-collections` (JSON mode only) +- `collections` + - `--format ` (default: `text`) + - `--prefix ` (optional exact match only) +- `collection` + - `--format ` (default: `text`) + - `--raw` (optional: return unmodified API shape in JSON mode) +- `get` + - `--format ` (default: `svg`) + +## Architecture + +- Keep `IconifyClient` with configurable base URL. +- Keep typed methods: + - `collections(prefix: Option<&str>)` + - `collection(prefix)` + - `search(query, limit, start, include_collections)` + - `svg(prefix_icon)` + - `icon_json(prefix, icon)` +- Add collection normalization helper in API layer: + - flatten `uncategorized` + category icon lists into one deduped icon list + - optionally expose raw categories/aliases/hidden in raw mode + +## Data Model Draft + +- `IconifySearchResponse` + - `icons: Vec` + - `total: u32` + - `limit: u32` + - `start: u32` + - `collections: Option>` +- `IconifyCollectionsResponse` + - `HashMap` (top-level map) +- `IconifyCollectionResponseRaw` + - `prefix: String` + - `total: Option` + - `title: Option` + - `uncategorized: Option>` + - `categories: Option>>` + - `aliases: Option>` + - `hidden: Option>` +- `IconifyCollectionResponseNormalized` + - `prefix: String` + - `icons: Vec` (deduped) + - `total: Option` + +## Output Contract (AI-Friendly) + +### `search` + +- Text: one `prefix:icon` per line. +- JSON: `{ icons, total, limit, start }`. +- Include `collections` only when `--include-collections` is set. + +### `collections` + +- Text: `prefixnametotal`. +- JSON: `[ { prefix, name, total } ]`. +- Optional `--prefix` is exact only. + +### `collection` + +- Text: one `prefix:icon` per line from normalized deduped icon list. +- JSON default: `{ prefix, icons, total }` (normalized). +- JSON with `--raw`: full API object for advanced agents. + +### `get` + +- Default: raw SVG to stdout. +- JSON mode: call `GET /{prefix}.json?icons={icon}` and return API payload. + +## Integration with Existing Icon Fetch Path + +- Keep `IconSourceType::IconifyName` using `IconifyClient::svg()`. +- Keep one shared Iconify transport path for CLI and TUI. + +## Error Handling + +- Keep structured API errors (network, decode, http status, invalid icon name). +- Include HTTP status and endpoint in errors. +- CLI maps API errors to concise `anyhow` messages and non-zero exit codes. + +## Testing Strategy + +- Unit tests in `src/iconify.rs` for: + - deserializing real `/collections` top-level map payload + - deserializing real `/collection` payload with categories and aliases + - normalization and dedupe behavior for collection icon lists + - optional field handling and 404/HTTP error mapping +- CLI tests for text/JSON output for all iconify subcommands. +- Integration test confirming `IconSourceType::IconifyName` uses shared client path. + +## Docs + +- Update `README.md` with: + - command usage and examples + - exact behavior of `collections --prefix` + - normalized vs raw output for `collection` + - raw SVG piping examples for `get` + +## Milestones + +1. Align `src/iconify.rs` models with actual endpoint shapes. +2. Add collection normalization and optional raw mode. +3. Wire CLI formatting and keep existing shared fetch integration. +4. Add tests and refresh docs. + +## Acceptance Criteria + +- All four iconify commands work for happy path and common errors. +- `collections` no longer returns empty due model mismatch. +- `collection` returns meaningful icon lists via normalization. +- `get` defaults to raw SVG and supports JSON mode. +- API layer is reusable and CLI-independent. +- Tests and docs cover real endpoint behavior. diff --git a/iconmate.jsonc b/iconmate.jsonc new file mode 100644 index 0000000..79e341a --- /dev/null +++ b/iconmate.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "./iconmatelocal.schema.json", + "svg_view_cmd": "zed %filename%", +} diff --git a/iconmateglobal.schema.json b/iconmateglobal.schema.json new file mode 100644 index 0000000..cd6b8ce --- /dev/null +++ b/iconmateglobal.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json", + "type": "object", + "properties": { + "svg_view_cmd": { + "type": "string", + "minLength": 1, + "title": "SVG View Command", + "description": "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.", + "examples": [ + "zed %filename%", + "code %filename%", + "open %filename%" + ] + } + }, + "id": "IconmateGlobalConfig", + "title": "Iconmate Global Config", + "description": "Schema for user-level iconmate config at the OS-specific config directory." +} diff --git a/iconmatelocal.schema.json b/iconmatelocal.schema.json new file mode 100644 index 0000000..261786c --- /dev/null +++ b/iconmatelocal.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + "type": "object", + "properties": { + "folder": { + "description": "Folder where icons are written.", + "default": "src/assets/icons", + "examples": [ + "src/assets/icons", + "src/components/icons" + ], + "type": "string", + "minLength": 1 + }, + "preset": { + "type": "string", + "enum": [ + "normal", + "react", + "svelte", + "solid", + "vue", + "emptysvg" + ], + "title": "Preset", + "description": "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.", + "default": "normal", + "examples": [ + "normal", + "react", + "solid", + "emptysvg" + ] + }, + "output_line_template": { + "type": "string", + "minLength": 1, + "title": "Output Line Template", + "description": "Template for each export line. Supported placeholders: `%name%`, `%icon%`, `%ext%`.", + "default": "export { default as Icon%name% } from './%icon%%ext%';", + "examples": [ + "export { default as Icon%name% } from './%icon%%ext%';" + ] + }, + "svg_view_cmd": { + "type": "string", + "minLength": 1, + "title": "SVG View Command", + "description": "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.", + "examples": [ + "zed %filename%", + "code %filename%", + "open %filename%" + ] + } + }, + "id": "IconmateLocalConfig", + "title": "Iconmate Local Config", + "description": "Schema for project-level iconmate config (`iconmate.config.json`)." +} diff --git a/npm/README.md b/npm/README.md index 6c1df90..24d875b 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,19 +1,43 @@ -# iconmate ๐Ÿ’š - -Your new favorite way to manage icons for your Vite, NextJS projects without icon libraries! - -Based on my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library). Built with ๐Ÿฆ€ Rust, โšก๏ธ designed for speed, ๐Ÿฆ… made for developers who hate icon library bloat. - -Stop installing bloated icon libraries. All you need is [icones.js.org](https://icones.js.org) or your designer's Figma icon pack and paste them into your project with surgical precision. - -**What Makes It Special โœจ** - -- **Zero Dependencies** ๐Ÿ“ฆ: Just a CLI, No icon libraries to bundle -- **Framework Native** ๐Ÿงฉ: Works with React, Vue, Svelte, Solid - generates components automatically -- **Interactive Mode** ๐ŸŽฎ: Just run `iconmate` and let it guide you -- **URL Support** ๐ŸŒ: Fetch from any SVG URL, not just iconify -- **Raw SVG** ๐Ÿ“‹: Copy-paste SVG code directly -- **Empty SVG** ๐Ÿ—๏ธ: Create placeholder icons for rapid prototyping +

+ iconmate logo +

+ +

iconmate

+ +

Add SVG icons to your JS apps without icon libraries.

+ +

+ iconmate banner +

+ +

+ Quick Start + ยท + Install + ยท + Presets + ยท + CLI Commands + ยท + AI Ready + ยท + Configuration +

+ +Built from my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library), `iconmate` is a Rust-powered CLI for developers who want the speed and control of plain files without icon-package bloat. + +Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. `iconmate` drops the icon straight into your project and keeps your exports tidy. + +## Why iconmate? + +- **Made with Rust๐Ÿฆ€**: Fast TUI that you can use on any IDE (powered by ratatui, nucleo). +- **AI-ready automation** ๐Ÿค–: Let your coding agents get icons and add it to your project! A CLI is basically an MCP, just let AI use `iconmate --help` and it should be able to get everything running! +- **Zero dependencies** ๐Ÿ“ฆ: No icon library runtime added to your app +- **Framework-native output** ๐Ÿงฉ: Generate files for React, Vue, Svelte, Solid, or plain SVG +- **Interactive by default** ๐ŸŽฎ: Run `iconmate` and have a pleasant interactive TUI +- **Multiple sources** ๐ŸŒ: Pull icons from Iconify names, URLs, or even raw SVG (which means it works with your private icon packs i.e. Anron) +- **Fast workflow** โšก: Generate file + export line in one step +- **Prototype-friendly** ๐Ÿ—๏ธ: Create empty SVG placeholders when needed ## Quick Start @@ -21,16 +45,11 @@ Stop installing bloated icon libraries. All you need is [icones.js.org](https:// # Install npm install -g iconmate -# Run inside your project ๐Ÿš€ +# Run the TUI in your project ๐Ÿš€ iconmate - -> ๐Ÿ“ Folder (src/assets/icons/) # Enter -> โœจ Preset # Choose react -> ๐Ÿš€ Icon # heroicons:heart -> ๐Ÿ’Ž Name # Heart ``` -โœจ That's it. The interactive CLI guides you through adding icon to your project! +โœจ That's it. The interactive TUI guides you through adding icons to your project. ```tsx // ๐Ÿ‘‡ Then, you can just easily use any icon on your project like this! @@ -42,49 +61,105 @@ function MyApp() { } ``` -You can also add sensible defaults by passing flags: +## Configuration + +You can also add sensible defaults by passing flags as configs: ```bash iconmate --folder src/components/Icons/ --folder src/components/icons iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart ``` +Iconmate now includes config schemas + TS type definitions in the repo: + +- Local config schema (repo): [`iconmatelocal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmatelocal.schema.json) +- Global config schema (repo): [`iconmateglobal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmateglobal.schema.json) +- Local config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json` +- Global config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json` +- Schema source: `config-gen/src/schema.ts` +- TS type definitions: `config-gen/src/types.ts` + +Regenerate schemas from project root: + +```bash +just config-schema +``` + +`just config-schema` installs `config-gen` deps and generates both schema files. + +### Local Config (`iconmate.config.json`) + +```json +{ + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + "folder": "src/assets/icons", + "preset": "normal", + "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';", + "svg_view_cmd": "zed %filename%" +} +``` + +Use the raw URL for `$schema` so editors can fetch JSON directly. + +Local config keys: + +- `folder` (default: `src/assets/icons`) +- `preset` (default: `normal`, meaning plain `.svg` mode) +- `output_line_template` (default: `export { default as Icon%name% } from './%icon%%ext%';`) +- `svg_view_cmd` (supports `%filename%` token) + +Allowed `preset` values: + +- `normal` (plain SVG mode) +- `react` +- `svelte` +- `solid` +- `vue` +- `emptysvg` + +### Global Config (user-level) + +Global config is for user-wide defaults and currently documents `svg_view_cmd`. + +Suggested paths: + +- macOS: `~/Library/Application Support/iconmate/config.json` +- Linux: `~/.config/iconmate/config.json` +- Windows: `%APPDATA%\\iconmate\\config.json` + +Example global config: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", + "svg_view_cmd": "code %filename%" +} +``` + +> [!NOTE] +> This release adds config schemas and generated docs/types. Runtime loading/precedence wiring in the CLI/TUI is tracked in `folder-system-plan.md`. + ## Installation ### NPM ๐Ÿฆ– ```bash +# Install globally with either: npm install -g iconmate -# or pnpm add -g iconmate -# or bun add -g iconmate -``` - -For one-off usage: - -```bash +# Use without installing with either: npx iconmate -# or pnpm dlx iconmate -# or bunx iconmate ``` -> [!NOTE] -> **Note for Bun users:** Bun doesn't run `postinstall` scripts [by default](https://bun.com/guides/install/trusted) which is needed to install the iconmate binary. Add `"trustedDependencies": ["iconmate"]` to your `package.json` to do it! But you're only limited to running it with a package.json. -> -> Recommended: Just use pnpx for quick one-off usage. If in a project, either install globally or configure trustDependencies. - ### Install from Cargo ๐Ÿฆ€ ```bash cargo install iconmate -``` -Or clone and install from source: - -```bash +# Or clone and install from source: git clone https://github.com/blankeos/iconmate.git cd iconmate cargo install --path . @@ -104,33 +179,24 @@ Determines the output filetype and the contents inside that file type. | `emptysvg` | `.svg` | Placeholder | > [!IMPORTANT] -> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr). I covered how to do this in: +> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr) for your js apps. I covered how to do this in: > > - [SolidJS (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-solidjs) > - [React (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-vite) > - [React (NextJS)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-nextjs) > - Vue - contribution welcome! -> - Svelte - couldn't find an svgr integration :( +> - Svelte - couldn't find an svgr integration. Just use the svg preset. ## Command Line -### Interactive Mode (Recommended) +### Interactive TUI Mode (Recommended) ```bash iconmate - -# Description of each prompt: -> ๐Ÿ“ Folder (src/assets/icons/) # where your icons will be saved. - -> โœจ Preset # i.e. How will it be saved? An `.svg` or `.tsx` file in react, solid, etc. - -> ๐Ÿš€ Icon # Source of your icon. i.e. 'heroicons:heart' from https://icones.js.org, full URL, or any SVG. Can be empty except for 'emptysvg' preset. - -> ๐ŸŒ„ Filename # The filename without the extension. i.e. heroicons:heart. Will only be prompted if you used an SVG, or an URL on icon. - -> ๐Ÿ’Ž Name # The "Heart" in ``` +This section is helpful for AI: + ### Add Specific Icon ```bash @@ -149,6 +215,12 @@ iconmate add --folder src/assets/icons --icon https://api.iconify.design/mdi:hea iconmate add --folder src/assets/icons --icon '...' --name Heart ``` +You can also pull raw SVG directly from the Iconify API: + +```bash +iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart +``` + ### Custom Export Template ```bash @@ -161,6 +233,51 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out iconmate delete --folder src/assets/icons ``` +### Iconify API Commands + +```bash +# Search by keyword (text: one prefix:icon per line) +iconmate iconify search heart + +# Search with pagination and JSON output +iconmate iconify search heart --limit 20 --start 0 --format json + +# Include collection metadata in JSON search output +iconmate iconify search heart --format json --include-collections + +# List all available collections +iconmate iconify collections + +# List icons from one collection prefix +iconmate iconify collection mdi + +# Get one icon as raw SVG (default) +iconmate iconify get mdi:heart + +# Get one icon as raw Iconify JSON +iconmate iconify get mdi:heart --format json +``` + +`iconmate iconify get --format json` uses Iconify's JSON endpoint format, +for example `https://api.iconify.design/mdi.json?icons=heart`. + +### AI-Ready Workflows + +`iconmate` is designed to be easy for AI agents and scripts to drive end-to-end. + +```bash +# 1) Search in machine-readable JSON +iconmate iconify search heart --format json --limit 20 --include-collections + +# 2) Add an icon non-interactively from prefix:name +iconmate add --folder src/assets/icons --icon mdi:heart --name Heart + +# 3) Or fetch raw SVG from Iconify API and add directly +iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart +``` + +This means an AI can search, choose, and add icons without opening a browser. + ### Package.json Scripts Best practice: Add sensible defaults to your script runner. @@ -169,12 +286,6 @@ Best practice: Add sensible defaults to your script runner. "scripts": { // Usage: npm run iconmate (usually this is the only command you need)! "iconmate": "iconmate --folder src/assets/icons/" - - // Usage: npm run iconmate-react - "iconmate-react": "iconmate --folder ./src/assets/icons/ --preset react", - - // Usage: npm run iconmate-empty - "iconmate-empty": "iconmate --folder ./src/assets/icons/ --preset emptysvg", } ``` @@ -186,9 +297,9 @@ Best practice: Add sensible defaults to your script runner. ## How It Works -1. **Find your icon**: Visit https://icones.js.org. -2. **Copy the name**: Like `heroicons:heart`. -3. **Run iconmate**: `iconmate` +1. **Find your icon**: Use https://icones.js.org _or_ `iconmate iconify search `. +2. **Pick the icon id**: For example `heroicons:heart`. +3. **Add with iconmate**: Interactive (`iconmate`) or direct (`iconmate add ...`). ![illustration](https://raw.githubusercontent.com/Blankeos/iconmate/refs/heads/main/_docs/icones-cli-illustration.png) @@ -215,16 +326,23 @@ Contributions are welcomeโ€”pull requests for bug fixes, new framework presets, - โœ… Custom export templates - โœ… Zero-config installation -### Original Future Plans +### Roadmap & Out-of-scoped - [x] An empty command. Creates an .svg, adds it to the index.ts with a name you can specify. - [x] Paste an actual svg instead of an icon `name`. -- [x] Just a `--preset=svg,react,solid,svelte,vue` - which basically overrides templates. Default is `svg`. +- [x] Presets (`normal`, `react`, `solid`, `svelte`, `vue`, `emptysvg`) override output templates and file types. - [x] Prompt Mode via `iconmate` - Interactive mode so you won't need to pass arguments. - [x] Delete an icon using `iconmate delete` +- [x] An interactive TUI instead of prompt-mode. + - [x] Rename in the TUI (but recommended for you to just use the LSP to do it) + - [x] A lot of TUI functionalities wokr +- [x] `iconmate iconify --help` commands for AI to easily look for icons itself. +- [x] Search and add Iconify icons directly inside the TUI (no need to open https://icones.js.org). - [ ] Other frameworks i.e. --preset=flutter or Go/Rust GUI apps? (Not sure how they work yet though). - [ ] ~Zed or VSCode Extension~ (seems unnecessary now, it's just a CLI) +### Near-Term Roadmap + --- Made with Rust ๐Ÿฆ€ | Based on [my blog post][my_blog] diff --git a/npm/bin.js b/npm/bin.js index 01c2893..f38d59d 100755 --- a/npm/bin.js +++ b/npm/bin.js @@ -3,23 +3,43 @@ const { spawn } = require("child_process"); const path = require("path"); const fs = require("fs"); +const { install } = require("./install"); const binaryName = process.platform === "win32" ? "iconmate.exe" : "iconmate"; const binaryPath = path.join(__dirname, "bin", binaryName); -if (!fs.existsSync(binaryPath)) { - console.error("โŒ iconmate binary not found. Please reinstall:"); - console.error("npm uninstall -g iconmate && npm install -g iconmate"); - process.exit(1); +async function ensureBinary() { + if (fs.existsSync(binaryPath)) { + return; + } + + console.error("iconmate binary not found. Attempting download..."); + + try { + await install(); + } catch (error) { + process.exit(1); + } + + if (!fs.existsSync(binaryPath)) { + console.error("โŒ iconmate binary still missing after download."); + process.exit(1); + } } -const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); +async function run() { + await ensureBinary(); -child.on("error", (err) => { - console.error("โŒ Failed to start iconmate:", err.message); - process.exit(1); -}); + const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); + + child.on("error", (err) => { + console.error("โŒ Failed to start iconmate:", err.message); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + process.exit(signal ? 1 : code || 0); + }); +} -child.on("exit", (code, signal) => { - process.exit(signal ? 1 : code || 0); -}); +run(); diff --git a/npm/install.js b/npm/install.js index d3a9a47..207cf3b 100644 --- a/npm/install.js +++ b/npm/install.js @@ -4,8 +4,6 @@ const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const https = require("https"); -const { pipeline } = require("stream"); -const { promisify } = require("util"); // Version should match your Rust crate version const VERSION = require("./package.json").version; @@ -91,13 +89,22 @@ function extractArchive(archivePath, extractDir, platformInfo) { const cmd = platformInfo.extension === ".zip" - ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command \"Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force\"` + ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"` : `tar -xf "${archivePath}" -C "${extractDir}"`; execSync(cmd, { stdio: "inherit" }); } -async function install() { +function logInstallFailure(error) { + const message = error instanceof Error ? error.message : String(error); + console.error("โŒ Installation failed:", message); + console.error("\nYou can install iconmate directly using:"); + console.error( + 'curl --proto "=https" --tlsv1.2 -LsSf https://github.com/Blankeos/iconmate/releases/latest/download/iconmate-installer.sh | sh', + ); +} + +async function install({ exitOnComplete = false } = {}) { try { const platformInfo = getPlatformInfo(); const binDir = path.join(__dirname, "bin"); @@ -132,20 +139,28 @@ async function install() { fs.unlinkSync(archivePath); console.log(`โœ… iconmate v${VERSION} installed successfully!`); - process.exit(0); + + if (exitOnComplete) { + process.exit(0); + return binaryPath; + } + + return binaryPath; } catch (error) { - console.error("โŒ Installation failed:", error.message); - console.error("\\nYou can install iconmate directly using:"); - console.error( - 'curl --proto \"=https\" --tlsv1.2 -LsSf https://github.com/Blankeos/iconmate/releases/latest/download/iconmate-installer.sh | sh', - ); - process.exit(1); + logInstallFailure(error); + + if (exitOnComplete) { + process.exit(1); + return; + } + + throw error; } } // Only run install if this script is executed directly if (require.main === module) { - install(); + install({ exitOnComplete: true }); } -module.exports = { getPlatformInfo }; +module.exports = { getPlatformInfo, install }; diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..f01b18b --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,135 @@ +use crate::{utils::IconEntry, views::main::MainState}; +use std::sync::mpsc::{Receiver, Sender}; +use tui_textarea::Input; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AppFocus { + Main, + AddPopup, + DeletePopup, + RenamePopup, + HelpPopup, + IconifySearchPopup, +} + +#[derive(Debug, Clone)] +pub struct IconifyCollectionListItem { + pub prefix: String, + pub name: String, + pub total: Option, +} + +#[derive(Debug, Clone)] +pub struct IconifySearchPayload { + pub icons: Vec, +} + +#[derive(Debug)] +pub enum AppEvent { + IconifyCollectionsLoaded { + request_id: u64, + result: Result, String>, + }, + IconifySearchLoaded { + request_id: u64, + query: String, + result: Result, + }, + IconifyCollectionIconsLoaded { + request_id: u64, + prefix: String, + result: Result, String>, + }, +} + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub folder: String, + pub preset: String, + pub template: Option, + pub svg_viewer_cmd: Option, + pub svg_viewer_cmd_source: String, + pub global_config_loaded: bool, + pub project_config_loaded: bool, +} + +pub struct App { + pub config: AppConfig, + + // App state + pub tx: Sender, + pub rx: Receiver, + + pub should_quit: bool, + + pub selected_index: usize, + + pub items: Vec, + + pub filtered_items: Vec, + + pub app_focus: AppFocus, + + // Deeper states + pub main_state: crate::views::main::MainState, + pub add_popup_state: Option, + pub delete_popup_state: Option, + pub rename_popup_state: Option, + pub iconify_search_popup_state: + Option, + + pub next_async_request_id: u64, +} + +impl App { + pub fn new(config: AppConfig) -> Self { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = Self { + config, + + should_quit: false, + tx, + rx, + + selected_index: 0, + filtered_items: Vec::new(), + items: Vec::new(), + + app_focus: AppFocus::Main, + add_popup_state: None, + delete_popup_state: None, + rename_popup_state: None, + iconify_search_popup_state: None, + next_async_request_id: 0, + main_state: MainState::new(), + }; + + app.init_icons(); + app + } + + pub fn init_icons(&mut self) { + // Try to read the current project's export file + self.items = crate::utils::get_existing_icons(&self.config.folder).unwrap_or_default(); + self.filtered_items = self.items.clone(); + } + + pub fn update(&mut self) { + while let Ok(event) = self.rx.try_recv() { + self.handle_app_event(event); + } + + self.tick_iconify_search_popup(); + } + + pub fn handlekeys(&mut self, key: Input) { + match self.app_focus { + AppFocus::Main => self.handlekeys_main(key), + AppFocus::AddPopup => self.handlekeys_add_popup(key), + AppFocus::DeletePopup => self.handlekeys_delete_popup(key), + AppFocus::RenamePopup => self.handlekeys_rename_popup(key), + AppFocus::HelpPopup => self.handlekeys_help_popup(key), + AppFocus::IconifySearchPopup => self.handlekeys_iconify_search_popup(key), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0e2f390 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,454 @@ +use anyhow::Context; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +use crate::utils::{PRESETS_OPTIONS, Preset}; + +pub const DEFAULT_FOLDER: &str = "src/assets/icons"; +pub const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = + "export { default as Icon%name% } from './%icon%%ext%';"; + +#[derive(Debug, Clone, Default)] +struct LocalConfigFile { + folder: Option, + preset: Option, + output_line_template: Option, + svg_viewer_cmd: Option, +} + +#[derive(Debug, Clone, Default)] +struct GlobalConfigFile { + svg_viewer_cmd: Option, +} + +#[derive(Debug, Clone)] +struct LoadedConfigFile { + path: PathBuf, + value: T, +} + +#[derive(Debug, Clone)] +pub struct ResolvedTuiConfig { + pub folder: String, + pub preset: String, + pub output_line_template: String, + pub svg_viewer_cmd: Option, + pub svg_viewer_cmd_source: String, + pub global_config_loaded: bool, + pub project_config_loaded: bool, + pub warnings: Vec, + pub info: Vec, +} + +pub fn resolve_tui_config( + cli_folder: Option<&PathBuf>, + cli_preset: Option<&Preset>, + cli_output_line_template: Option<&String>, +) -> anyhow::Result { + let mut warnings = Vec::new(); + let mut info = Vec::new(); + + let local = load_local_config(&mut warnings)?; + let global = load_global_config(&mut warnings)?; + + if let Some(config) = &local { + info.push(format!( + "Loaded local config from {}", + config.path.display() + )); + } + if let Some(config) = &global { + info.push(format!( + "Loaded global config from {}", + config.path.display() + )); + } + + let folder = cli_folder + .map(|path| path.display().to_string()) + .or_else(|| { + local + .as_ref() + .and_then(|config| config.value.folder.clone()) + }) + .unwrap_or_else(|| DEFAULT_FOLDER.to_string()); + + let preset = cli_preset + .map(|preset| preset.to_str().to_string()) + .or_else(|| { + local + .as_ref() + .and_then(|config| config.value.preset.clone()) + }) + .unwrap_or_else(|| "normal".to_string()); + + let output_line_template = cli_output_line_template + .cloned() + .or_else(|| { + local + .as_ref() + .and_then(|config| config.value.output_line_template.clone()) + }) + .unwrap_or_else(|| DEFAULT_OUTPUT_LINE_TEMPLATE.to_string()); + + let (svg_viewer_cmd, svg_viewer_cmd_source) = if let Some(config) = &local { + if let Some(command) = config.value.svg_viewer_cmd.clone() { + ( + Some(command), + format!("local config ({})", config.path.display()), + ) + } else if let Some(global_config) = &global { + if let Some(command) = global_config.value.svg_viewer_cmd.clone() { + ( + Some(command), + format!("global config ({})", global_config.path.display()), + ) + } else { + (None, "OS default".to_string()) + } + } else { + (None, "OS default".to_string()) + } + } else if let Some(global_config) = &global { + if let Some(command) = global_config.value.svg_viewer_cmd.clone() { + ( + Some(command), + format!("global config ({})", global_config.path.display()), + ) + } else { + (None, "OS default".to_string()) + } + } else { + (None, "OS default".to_string()) + }; + + info.push(format!( + "Resolved svg_viewer_cmd source: {}", + svg_viewer_cmd_source + )); + + Ok(ResolvedTuiConfig { + folder, + preset, + output_line_template, + svg_viewer_cmd, + svg_viewer_cmd_source, + global_config_loaded: global.is_some(), + project_config_loaded: local.is_some(), + warnings, + info, + }) +} + +fn load_local_config( + warnings: &mut Vec, +) -> anyhow::Result>> { + let current_dir = + std::env::current_dir().context("Failed to resolve current working directory")?; + let candidates = [ + current_dir.join("iconmate.config.jsonc"), + current_dir.join("iconmate.config.json"), + current_dir.join("iconmate.jsonc"), + current_dir.join("iconmate.json"), + ]; + + let Some(path) = candidates.into_iter().find(|candidate| candidate.exists()) else { + return Ok(None); + }; + + let value = parse_jsonc_file(&path)?; + let parsed = parse_local_value(value, &path, warnings)?; + Ok(Some(LoadedConfigFile { + path, + value: parsed, + })) +} + +fn load_global_config( + warnings: &mut Vec, +) -> anyhow::Result>> { + let mut candidates = Vec::::new(); + + if let Some(config_dir) = dirs::config_dir() { + candidates.push(config_dir.join("iconmate.jsonc")); + candidates.push(config_dir.join("iconmate.json")); + candidates.push(config_dir.join("iconmate").join("config.jsonc")); + candidates.push(config_dir.join("iconmate").join("config.json")); + } + + if let Some(home_dir) = dirs::home_dir() { + candidates.push(home_dir.join("iconmate.jsonc")); + candidates.push(home_dir.join("iconmate.json")); + } + + candidates.dedup(); + let Some(path) = candidates.into_iter().find(|candidate| candidate.exists()) else { + return Ok(None); + }; + + let value = parse_jsonc_file(&path)?; + let parsed = parse_global_value(value, &path, warnings)?; + Ok(Some(LoadedConfigFile { + path, + value: parsed, + })) +} + +fn parse_jsonc_file(path: &Path) -> anyhow::Result { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file {}", path.display()))?; + json5::from_str::(&raw).with_context(|| { + format!( + "Invalid config format in {}. Expected JSON/JSONC-compatible object.", + path.display() + ) + }) +} + +fn parse_local_value( + value: Value, + path: &Path, + warnings: &mut Vec, +) -> anyhow::Result { + let object = as_object(value, path)?; + warn_unknown_keys( + &object, + &[ + "$schema", + "folder", + "preset", + "output_line_template", + "svg_view_cmd", + "svg_viewer_cmd", + ], + path, + warnings, + ); + + let folder = read_string_field(&object, path, "folder", false)?; + + let mut preset = read_string_field(&object, path, "preset", true)?; + if matches!(preset.as_deref(), Some("")) { + warnings.push(format!( + "Config key 'preset' in {} uses deprecated empty value; use 'normal' instead.", + path.display() + )); + preset = Some("normal".to_string()); + } + + if let Some(value) = preset.as_deref() { + let valid_presets = PRESETS_OPTIONS + .iter() + .map(|option| option.preset.to_str()) + .collect::>(); + + if !valid_presets.contains(&value) { + anyhow::bail!( + "Invalid config at {}: key 'preset' must be one of [{}], got '{}'.", + path.display(), + valid_presets.join(", "), + value + ); + } + } + + let output_line_template = read_string_field(&object, path, "output_line_template", false)?; + let svg_viewer_cmd = read_svg_viewer_cmd(&object, path, warnings)?; + + Ok(LocalConfigFile { + folder, + preset, + output_line_template, + svg_viewer_cmd, + }) +} + +fn parse_global_value( + value: Value, + path: &Path, + warnings: &mut Vec, +) -> anyhow::Result { + let object = as_object(value, path)?; + warn_unknown_keys( + &object, + &["$schema", "svg_view_cmd", "svg_viewer_cmd"], + path, + warnings, + ); + + let svg_viewer_cmd = read_svg_viewer_cmd(&object, path, warnings)?; + Ok(GlobalConfigFile { svg_viewer_cmd }) +} + +fn as_object(value: Value, path: &Path) -> anyhow::Result> { + value.as_object().cloned().ok_or_else(|| { + anyhow::anyhow!( + "Invalid config at {}: expected top-level object.", + path.display() + ) + }) +} + +fn warn_unknown_keys( + object: &Map, + allowed_keys: &[&str], + path: &Path, + warnings: &mut Vec, +) { + for key in object.keys() { + if !allowed_keys.iter().any(|allowed| *allowed == key) { + warnings.push(format!( + "Ignoring unknown key '{}' in {}", + key, + path.display() + )); + } + } +} + +fn read_string_field( + object: &Map, + path: &Path, + key: &str, + allow_empty: bool, +) -> anyhow::Result> { + let Some(value) = object.get(key) else { + return Ok(None); + }; + + let Some(value) = value.as_str() else { + anyhow::bail!( + "Invalid config at {}: key '{}' must be a string.", + path.display(), + key + ); + }; + + if !allow_empty && value.trim().is_empty() { + anyhow::bail!( + "Invalid config at {}: key '{}' cannot be empty.", + path.display(), + key + ); + } + + Ok(Some(value.to_string())) +} + +fn read_svg_viewer_cmd( + object: &Map, + path: &Path, + warnings: &mut Vec, +) -> anyhow::Result> { + let legacy = read_string_field(object, path, "svg_view_cmd", false)?; + let modern = read_string_field(object, path, "svg_viewer_cmd", false)?; + + match (legacy, modern) { + (Some(old_value), Some(new_value)) => { + if old_value != new_value { + warnings.push(format!( + "Both 'svg_view_cmd' and 'svg_viewer_cmd' are set in {}; using 'svg_viewer_cmd'.", + path.display() + )); + } + Ok(Some(new_value)) + } + (Some(value), None) => Ok(Some(value)), + (None, Some(value)) => Ok(Some(value)), + (None, None) => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_global_svg_viewer_cmd_alias() { + let value: Value = serde_json::json!({ + "svg_viewer_cmd": "code %filename%" + }); + let mut warnings = Vec::new(); + let parsed = + parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap(); + assert_eq!(parsed.svg_viewer_cmd, Some("code %filename%".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn warns_on_unknown_global_key() { + let value: Value = serde_json::json!({ + "svg_view_cmd": "open %filename%", + "extra": true + }); + let mut warnings = Vec::new(); + let _ = parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("Ignoring unknown key 'extra'")); + } + + #[test] + fn allows_schema_key_without_warning() { + let value: Value = serde_json::json!({ + "$schema": "https://example.com/iconmate.schema.json", + "svg_view_cmd": "open %filename%" + }); + let mut warnings = Vec::new(); + let parsed = + parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap(); + + assert_eq!(parsed.svg_viewer_cmd, Some("open %filename%".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn allows_schema_key_in_local_config_without_warning() { + let value: Value = serde_json::json!({ + "$schema": "https://example.com/iconmate-local.schema.json", + "preset": "normal" + }); + let mut warnings = Vec::new(); + let parsed = parse_local_value( + value, + Path::new("/tmp/iconmate.config.jsonc"), + &mut warnings, + ) + .expect("local config with $schema should parse"); + + assert_eq!(parsed.preset.as_deref(), Some("normal")); + assert!(warnings.is_empty()); + } + + #[test] + fn validates_local_preset_values() { + let value: Value = serde_json::json!({ + "preset": "invalid" + }); + let mut warnings = Vec::new(); + let error = parse_local_value( + value, + Path::new("/tmp/iconmate.config.jsonc"), + &mut warnings, + ) + .expect_err("invalid preset should fail validation"); + assert!(error.to_string().contains("key 'preset' must be one of")); + } + + #[test] + fn normalizes_empty_local_preset_to_normal_with_warning() { + let value: Value = serde_json::json!({ + "preset": "" + }); + let mut warnings = Vec::new(); + let parsed = parse_local_value( + value, + Path::new("/tmp/iconmate.config.jsonc"), + &mut warnings, + ) + .expect("empty preset should be normalized"); + + assert_eq!(parsed.preset.as_deref(), Some("normal")); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("deprecated empty value")); + } +} diff --git a/src/iconify.rs b/src/iconify.rs new file mode 100644 index 0000000..c7218ea --- /dev/null +++ b/src/iconify.rs @@ -0,0 +1,487 @@ +use reqwest::{Client, StatusCode, Url}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::collections::HashMap; + +const DEFAULT_ICONIFY_BASE_URL: &str = "https://api.iconify.design"; +pub const ICONIFY_BASE_URL_ENV: &str = "ICONMATE_ICONIFY_BASE_URL"; + +#[derive(Debug, Clone)] +pub struct IconifyClient { + client: Client, + base_url: Url, +} + +impl IconifyClient { + pub fn new() -> Result { + Self::from_base_url(DEFAULT_ICONIFY_BASE_URL) + } + + pub fn from_env() -> Result { + let base = std::env::var(ICONIFY_BASE_URL_ENV) + .unwrap_or_else(|_| DEFAULT_ICONIFY_BASE_URL.to_string()); + Self::from_base_url(&base) + } + + pub fn from_base_url(base_url: &str) -> Result { + let normalized = if base_url.ends_with('/') { + base_url.to_string() + } else { + format!("{base_url}/") + }; + + let base_url = Url::parse(&normalized).map_err(|source| IconifyError::InvalidBaseUrl { + base_url: base_url.to_string(), + source: source.to_string(), + })?; + + Ok(Self { + client: Client::new(), + base_url, + }) + } + + pub async fn collections(&self) -> Result { + let collections: HashMap = + self.get_json("collections", &[]).await?; + Ok(IconifyCollectionsResponse { collections }) + } + + pub async fn collection( + &self, + prefix: &str, + ) -> Result { + let response: IconifyCollectionApiResponse = self + .get_json("collection", &[("prefix".to_string(), prefix.to_string())]) + .await?; + + let icons = merge_collection_icons( + response.icons, + response.uncategorized.as_ref(), + response.categories.as_ref(), + ); + + Ok(IconifyCollectionResponse { + prefix: response.prefix, + icons, + uncategorized: response.uncategorized, + }) + } + + pub async fn search( + &self, + query: &str, + limit: Option, + start: Option, + include_collections: bool, + ) -> Result { + let mut params = vec![("query".to_string(), query.to_string())]; + + if let Some(limit) = limit { + params.push(("limit".to_string(), limit.to_string())); + } + + if let Some(start) = start { + params.push(("start".to_string(), start.to_string())); + } + + let mut response: IconifySearchResponse = self.get_json("search", ¶ms).await?; + + if !include_collections { + response.collections = None; + } + + Ok(response) + } + + pub async fn svg(&self, prefix_icon: &str) -> Result { + let path = format!("{prefix_icon}.svg"); + self.get_text(&path, &[]).await + } + + pub async fn icon_json( + &self, + prefix: &str, + icon: &str, + ) -> Result { + let path = format!("{prefix}.json"); + self.get_json(&path, &[("icons".to_string(), icon.to_string())]) + .await + } + + pub async fn icon_json_by_name( + &self, + prefix_icon: &str, + ) -> Result { + let (prefix, icon) = prefix_icon + .split_once(':') + .ok_or_else(|| IconifyError::InvalidIconName(prefix_icon.to_string()))?; + self.icon_json(prefix, icon).await + } + + async fn get_json( + &self, + path: &str, + query: &[(String, String)], + ) -> Result { + let url = self.build_url(path, query)?; + let endpoint = url.to_string(); + + let response = self + .client + .get(url) + .send() + .await + .map_err(IconifyError::Network)?; + let status = response.status(); + let body = response.text().await.map_err(IconifyError::Network)?; + + if !status.is_success() { + return Err(IconifyError::HttpStatus { + status, + endpoint, + body, + }); + } + + serde_json::from_str(&body).map_err(|source| IconifyError::JsonDecode { endpoint, source }) + } + + async fn get_text( + &self, + path: &str, + query: &[(String, String)], + ) -> Result { + let url = self.build_url(path, query)?; + let endpoint = url.to_string(); + + let response = self + .client + .get(url) + .send() + .await + .map_err(IconifyError::Network)?; + let status = response.status(); + let body = response.text().await.map_err(IconifyError::Network)?; + + if !status.is_success() { + return Err(IconifyError::HttpStatus { + status, + endpoint, + body, + }); + } + + Ok(body) + } + + fn build_url(&self, path: &str, query: &[(String, String)]) -> Result { + let relative_path = if path.starts_with('/') { + format!("./{}", path.trim_start_matches('/')) + } else { + format!("./{path}") + }; + + let mut url = + self.base_url + .join(&relative_path) + .map_err(|source| IconifyError::InvalidEndpoint { + path: path.to_string(), + source: source.to_string(), + })?; + + if !query.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, value); + } + } + + Ok(url) + } +} + +fn merge_collection_icons( + icons: Vec, + uncategorized: Option<&Vec>, + categories: Option<&HashMap>>, +) -> Vec { + let mut merged = icons; + + if let Some(uncategorized) = uncategorized { + merged.extend(uncategorized.iter().cloned()); + } + + if let Some(categories) = categories { + for category_icons in categories.values() { + merged.extend(category_icons.iter().cloned()); + } + } + + let mut deduped = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for icon in merged { + if seen.insert(icon.clone()) { + deduped.push(icon); + } + } + + deduped +} + +#[derive(Debug)] +pub enum IconifyError { + InvalidBaseUrl { + base_url: String, + source: String, + }, + InvalidEndpoint { + path: String, + source: String, + }, + InvalidIconName(String), + Network(reqwest::Error), + HttpStatus { + status: StatusCode, + endpoint: String, + body: String, + }, + JsonDecode { + endpoint: String, + source: serde_json::Error, + }, +} + +impl std::fmt::Display for IconifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IconifyError::InvalidBaseUrl { base_url, .. } => { + write!(f, "invalid Iconify base URL: {base_url}") + } + IconifyError::InvalidEndpoint { path, .. } => { + write!(f, "invalid Iconify endpoint path: {path}") + } + IconifyError::InvalidIconName(name) => { + write!( + f, + "invalid Iconify icon name (expected ): {name}" + ) + } + IconifyError::Network(source) => write!(f, "Iconify network error: {source}"), + IconifyError::HttpStatus { + status, endpoint, .. + } => { + write!(f, "Iconify request failed ({status}) for {endpoint}") + } + IconifyError::JsonDecode { endpoint, source } => { + write!( + f, + "failed to parse Iconify response from {endpoint}: {source}" + ) + } + } + } +} + +impl std::error::Error for IconifyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + IconifyError::InvalidBaseUrl { .. } => None, + IconifyError::InvalidEndpoint { .. } => None, + IconifyError::InvalidIconName(_) => None, + IconifyError::Network(source) => Some(source), + IconifyError::JsonDecode { source, .. } => Some(source), + IconifyError::HttpStatus { .. } => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IconifySearchResponse { + #[serde(default)] + pub icons: Vec, + #[serde(default)] + pub total: u32, + #[serde(default)] + pub limit: u32, + #[serde(default)] + pub start: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collections: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IconifyCollectionsResponse { + pub collections: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IconifyCollectionMeta { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub total: Option, + #[serde(flatten, default)] + pub extra: HashMap, +} + +impl IconifyCollectionMeta { + pub fn display_name(&self, fallback: &str) -> String { + if let Some(name) = &self.name { + return name.clone(); + } + + if let Some(title) = &self.title { + return title.clone(); + } + + fallback.to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IconifyCollectionResponse { + pub prefix: String, + #[serde(default)] + pub icons: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uncategorized: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +struct IconifyCollectionApiResponse { + pub prefix: String, + #[serde(default)] + pub icons: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uncategorized: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub categories: Option>>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_search_response_without_collections() { + let fixture = r#" + { + "icons": ["mdi:home", "mdi:heart"], + "total": 2, + "limit": 20, + "start": 0 + } + "#; + + let response: IconifySearchResponse = + serde_json::from_str(fixture).expect("fixture should deserialize"); + + assert_eq!(response.icons.len(), 2); + assert_eq!(response.total, 2); + assert!(response.collections.is_none()); + } + + #[test] + fn parse_search_response_with_collections() { + let fixture = r#" + { + "icons": ["mdi:home"], + "total": 1, + "limit": 1, + "start": 0, + "collections": { + "mdi": {"name": "Material Design Icons", "total": 7000} + } + } + "#; + + let response: IconifySearchResponse = + serde_json::from_str(fixture).expect("fixture should deserialize"); + + assert_eq!(response.icons, vec!["mdi:home"]); + assert!(response.collections.is_some()); + assert!( + response + .collections + .as_ref() + .expect("collections should exist") + .contains_key("mdi") + ); + } + + #[test] + fn parse_collections_response() { + let fixture = r#" + { + "mdi": { + "name": "Material Design Icons", + "total": 7447 + }, + "heroicons": { + "title": "Heroicons", + "total": 292 + } + } + "#; + + let response: HashMap = + serde_json::from_str(fixture).expect("fixture should deserialize"); + + let mdi = response.get("mdi").expect("mdi should exist"); + assert_eq!(mdi.display_name("mdi"), "Material Design Icons"); + assert_eq!(mdi.total, Some(7447)); + + let heroicons = response.get("heroicons").expect("heroicons should exist"); + assert_eq!(heroicons.display_name("heroicons"), "Heroicons"); + } + + #[test] + fn parse_collection_response_with_optional_uncategorized() { + let fixture = r#" + { + "prefix": "mdi", + "icons": ["home", "heart"], + "uncategorized": ["orphan"] + } + "#; + + let response: IconifyCollectionResponse = + serde_json::from_str(fixture).expect("fixture should deserialize"); + + assert_eq!(response.prefix, "mdi"); + assert_eq!(response.icons, vec!["home", "heart"]); + assert_eq!(response.uncategorized, Some(vec!["orphan".to_string()])); + } + + #[test] + fn merge_icons_from_uncategorized_and_categories() { + let categories = HashMap::from([ + ( + "Actions".to_string(), + vec!["home".to_string(), "heart".to_string()], + ), + ( + "Shapes".to_string(), + vec!["star".to_string(), "home".to_string()], + ), + ]); + + let merged = merge_collection_icons( + Vec::new(), + Some(&vec!["orphan".to_string(), "home".to_string()]), + Some(&categories), + ); + + assert!(merged.contains(&"orphan".to_string())); + assert!(merged.contains(&"home".to_string())); + assert!(merged.contains(&"heart".to_string())); + assert!(merged.contains(&"star".to_string())); + assert_eq!( + merged.iter().filter(|icon| icon.as_str() == "home").count(), + 1 + ); + } +} diff --git a/src/main.rs b/src/main.rs index 572c43e..259ab7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,32 +1,22 @@ +mod app_state; +mod config; +mod iconify; +mod tui; +mod utils; +mod viewer; +mod views; + +use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse}; +use crate::utils::{ + _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry, + IconSourceType, PRESETS_OPTIONS, Preset, default_name_and_filename_from_icon_source, +}; use clap::{Parser, Subcommand, ValueEnum}; -use reqwest::Url; +use serde::Serialize; use std::fs; use std::io::Write; use std::path::PathBuf; -#[derive(ValueEnum, Clone, Debug, PartialEq)] -enum Preset { - /// Use a blank SVG. - #[value(name = "emptysvg")] - EmptySvg, - - /// React Component .tsx - #[value(name = "react")] - React, - - /// Svelte Component .svelte - #[value(name = "svelte")] - Svelte, - - /// Solid Component .tsx - #[value(name = "solid")] - Solid, - - /// Vue - #[value(name = "vue")] - Vue, -} - /// A CLI tool to fetch icons and save them into your Vite, NextJS, or similar project. #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -35,34 +25,30 @@ struct CliArgs { command: Option, /// Pathname of the folder where the icon will be saved and index.ts updated. - #[arg(long, global = true)] + #[arg(long)] folder: Option, /// Optional preset to use instead of fetching an icon. - #[arg(long, global = true)] + #[arg(long)] preset: Option, /// The alias for the SVG, used in the index.ts export (e.g., "Chevron"). - #[arg(long, global = true)] + #[arg(long)] name: Option, /// The name of the icon (e.g., "stash:chevron") or a full URL to the icon (e.g., "https://api.iconify.design/stash:chevron.svg") or an SVG. - #[arg(long, global = true)] + #[arg(long)] icon: Option, /// Optional custom filename for the SVG file (without extension). Defaults to the icon name. - #[arg(long, global = true)] + #[arg(long)] filename: Option, /// Custom template for the export line. Use %name% for the icon alias and %icon% for the filename stem. /// Variables: %icon%, %name% /// Normally for complex usecases where for example you might need url suffixes for imports i.e. `?react`. - #[arg( - long, - global = true, - default_value = "export { default as Icon%name% } from './%icon%%ext%';" - )] - output_line_template: String, + #[arg(long)] + output_line_template: Option, } #[derive(Debug, Subcommand)] @@ -94,20 +80,90 @@ enum Commands { /// Normally for complex usecases where for example you might need url suffixes for imports i.e. `?react`. #[arg( long, - default_value = "export { default as Icon%name% } from './%icon%.%ext%';" + default_value = "export { default as Icon%name% } from './%icon%%ext%';" )] output_line_template: String, }, /// Start an interactive prompt to add icons. - Prompt {}, + Tui {}, /// Delete an icon from your collection of icons Delete { /// Pathname of the folder where all the icons are saved. - #[arg(long, global = true)] + #[arg(long)] folder: Option, }, + + /// Query Iconify collections, search results, and raw SVGs. + Iconify { + #[command(subcommand)] + command: IconifyCommands, + }, +} + +#[derive(Clone, Debug, ValueEnum, PartialEq)] +enum OutputFormat { + Text, + Json, +} + +#[derive(Clone, Debug, ValueEnum)] +enum GetFormat { + Svg, + Json, +} + +#[derive(Debug, Subcommand)] +enum IconifyCommands { + /// Search Iconify by keyword. + Search { + /// Search query, such as "heart". + query: String, + + /// Maximum number of records. + #[arg(long)] + limit: Option, + + /// Start offset for pagination. + #[arg(long)] + start: Option, + + /// Output format. + #[arg(long, value_enum, default_value = "text")] + format: OutputFormat, + + /// Include collection metadata (JSON mode only). + #[arg(long)] + include_collections: bool, + }, + + /// List available Iconify collections. + Collections { + /// Output format. + #[arg(long, value_enum, default_value = "text")] + format: OutputFormat, + }, + + /// List all icons in a collection prefix. + Collection { + /// Collection prefix, such as "mdi". + prefix: String, + + /// Output format. + #[arg(long, value_enum, default_value = "text")] + format: OutputFormat, + }, + + /// Fetch one icon by Iconify name (). + Get { + /// Iconify icon name, such as "mdi:heart". + icon: String, + + /// Output format. + #[arg(long, value_enum, default_value = "svg")] + format: GetFormat, + }, } /// Configuration for the icon fetching and saving logic. @@ -120,127 +176,178 @@ struct AppConfig { preset: Option, } -/// Enum representing the type of icon source -#[derive(Debug, PartialEq)] -enum IconSourceType { - /// A plain iconify name (e.g., "stash:chevron") - IconifyName, - /// A full HTTP/HTTPS URL - Url, - /// Raw SVG content - SvgContent, - /// None provided - None, +#[derive(Serialize)] +struct SearchJsonOutput { + icons: Vec, + total: u32, + limit: u32, + start: u32, + #[serde(skip_serializing_if = "Option::is_none")] + collections: Option>, } -/// Util: Determines the type of icon source -fn _determine_icon_source_type(icon_source: Option<&String>) -> IconSourceType { - match icon_source { - Some(icon) => { - if icon.trim_start().starts_with(" IconSourceType::None, - } +#[derive(Serialize)] +struct CollectionsJsonOutput { + prefix: String, + name: String, + total: u32, } -/// Util: Converts any icon_source into an SVG -async fn _icon_source_to_svg( - icon_source: &Option, - append_attribute: Option<&'static str>, - remove_comments: bool, -) -> anyhow::Result { - // If icon_source is missing, return a minimal SVG (Note: rust skill issue idk how else to just reuse the last clause in the match below) - let Some(icon_source) = icon_source else { - return Ok(r#""#.to_string()); - }; +#[derive(Serialize)] +struct CollectionJsonOutput { + prefix: String, + icons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + uncategorized: Option>, +} - let mut content = match _determine_icon_source_type(Some(icon_source)) { - IconSourceType::SvgContent => { - // Already an SVG document - icon_source.clone() - } - IconSourceType::IconifyName => { - // Construct the full URL for the icon - let icon_url = Url::parse(&format!("https://api.iconify.design/{}.svg", icon_source))?; - println!("Fetching icon from: {}", icon_url); - - // Fetch the SVG content - let client = reqwest::Client::new(); - let response = client.get(icon_url).send().await?.error_for_status()?; - response.text().await? - } - IconSourceType::Url => { - // Already a full URL - let icon_url = Url::parse(icon_source)?; - println!("Fetching icon from: {}", icon_url); - - // Fetch the SVG content - let client = reqwest::Client::new(); - let response = client.get(icon_url).send().await?.error_for_status()?; - response.text().await? - } - IconSourceType::None => { - return Ok(r#""#.to_string()); - } - }; +fn print_json(value: &T) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn iconify_error_to_anyhow(error: crate::iconify::IconifyError) -> anyhow::Error { + match error { + crate::iconify::IconifyError::HttpStatus { + status, + endpoint, + body, + } => { + if status == reqwest::StatusCode::NOT_FOUND && endpoint.contains("/collection?prefix=") + { + return anyhow::anyhow!( + "Iconify collection not found. Use a collection prefix like 'mdi' (not 'mdi:home')." + ); + } - // -- Transformations if applicable --- - - // 1. Append attribute (i.e. for jsx,svelte,vue) - if let Some(attr) = append_attribute { - // Find the first occurrence of "" - if let Some(svg_start) = content.find("') { - let insert_pos = svg_start + svg_tag_end; - let before = &content[..insert_pos]; - let after = &content[insert_pos..]; - content = format!("{} {}{}", before, attr, after); + let body = body.trim(); + if body.is_empty() { + anyhow::anyhow!("Iconify request failed ({status}) for {endpoint}") + } else { + anyhow::anyhow!( + "Iconify request failed ({status}) for {endpoint}. Response: {body}" + ) } } + other => anyhow::Error::new(other), } +} - // 2. Remove Comments - if remove_comments { - // Remove HTML comments from SVG content - // This regex matches including any content in between - let re = regex::Regex::new(r"").unwrap(); - content = re.replace_all(&content, "").to_string(); +fn into_collection_output(response: IconifyCollectionResponse) -> CollectionJsonOutput { + CollectionJsonOutput { + prefix: response.prefix, + icons: response.icons, + uncategorized: response.uncategorized, } - - Ok(content) } -/// Util: Reused in all cases, for appending the filename of svg, i.e. add .tsx or .svg or .svelte. -/// Returns a file_stem and an ext -fn _make_svg_filename( - stem_from_cli: Option<&String>, - ext: &'static str, - icon_source: Option<&String>, - name_from_cli: &str, -) -> (String, &'static str) { - let stem = if let Some(stem) = stem_from_cli { - stem.clone() - } else if let Some(icon) = icon_source { - // Only use icon_source if it's a plain iconify name (no http/https, no icon.clone(), - _ => name_from_cli.to_string().to_lowercase(), - } - } else { - name_from_cli.to_string().to_lowercase() - }; +async fn run_iconify_command(command: IconifyCommands) -> anyhow::Result<()> { + let client = IconifyClient::from_env().map_err(iconify_error_to_anyhow)?; + + match command { + IconifyCommands::Search { + query, + limit, + start, + format, + include_collections, + } => { + if include_collections && format != OutputFormat::Json { + anyhow::bail!("--include-collections can only be used with --format json"); + } - if stem.ends_with(ext) { - (stem.replace(ext, ""), ext) - } else { - (stem, ext) + let response: IconifySearchResponse = client + .search(&query, limit, start, include_collections) + .await + .map_err(iconify_error_to_anyhow)?; + + match format { + OutputFormat::Text => { + for icon in response.icons { + println!("{icon}"); + } + } + OutputFormat::Json => { + let payload = SearchJsonOutput { + icons: response.icons, + total: response.total, + limit: response.limit, + start: response.start, + collections: response.collections, + }; + print_json(&payload)?; + } + } + } + IconifyCommands::Collections { format } => { + let response = client + .collections() + .await + .map_err(iconify_error_to_anyhow)?; + + let mut rows: Vec = response + .collections + .into_iter() + .map(|(prefix, meta)| CollectionsJsonOutput { + name: meta.display_name(&prefix), + total: meta.total.unwrap_or(0), + prefix, + }) + .collect(); + + rows.sort_by(|a, b| a.prefix.cmp(&b.prefix)); + + match format { + OutputFormat::Text => { + for row in rows { + println!("{}\t{}\t{}", row.prefix, row.name, row.total); + } + } + OutputFormat::Json => { + print_json(&rows)?; + } + } + } + IconifyCommands::Collection { prefix, format } => { + let prefix = prefix + .split_once(':') + .map(|(collection_prefix, _)| collection_prefix) + .unwrap_or(&prefix) + .to_string(); + + let response = client + .collection(&prefix) + .await + .map_err(iconify_error_to_anyhow)?; + + match format { + OutputFormat::Text => { + for icon in &response.icons { + println!("{}:{icon}", response.prefix); + } + } + OutputFormat::Json => { + let payload = into_collection_output(response); + print_json(&payload)?; + } + } + } + IconifyCommands::Get { icon, format } => match format { + GetFormat::Svg => { + let svg = client.svg(&icon).await.map_err(iconify_error_to_anyhow)?; + println!("{svg}"); + } + GetFormat::Json => { + let payload = client + .icon_json_by_name(&icon) + .await + .map_err(iconify_error_to_anyhow)?; + print_json(&payload)?; + } + }, } + + Ok(()) } /// The main logic of the application. @@ -262,20 +369,22 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { // eprintln!(" preset: {:?}", config.preset); // eprintln!("}}"); + let effective_preset = config.preset.clone().unwrap_or(Preset::Normal); + // Determine SVG content and filename stem based on a valid combination of arguments. let (svg_content, file_stem_str, ext, output_line_template) = match ( &config.icon, - &config.preset, + effective_preset, ) { // Case 1: Icon is provided AND the preset is EmptySvg. This is the only mutual exclusivity. - (Some(_), Some(Preset::EmptySvg)) => { + (Some(_), Preset::EmptySvg) => { anyhow::bail!( "The --icon argument cannot be used with the --preset emptysvg. Please provide only one or the other." ); } // Case 2: Only a preset is provided. - (None, Some(Preset::EmptySvg)) => { + (None, Preset::EmptySvg) => { let content = r#""#.to_string(); let (file_stem, ext) = _make_svg_filename( config.filename.as_ref(), @@ -292,7 +401,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { } // Case 3: React - (icon_source, Some(Preset::React)) => { + (icon_source, Preset::React) => { let content = _icon_source_to_svg(icon_source, Some("{...props}"), true).await?; // Wrap the SVG in a React component template @@ -316,7 +425,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { } // Case 4: Svelte - (icon_source, Some(Preset::Svelte)) => { + (icon_source, Preset::Svelte) => { let content = _icon_source_to_svg(icon_source, Some("{...props}"), false).await?; // Wrap the SVG in a Svelte component template @@ -340,7 +449,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { } // Case 5: Solid - (icon_source, Some(Preset::Solid)) => { + (icon_source, Preset::Solid) => { let content = _icon_source_to_svg(icon_source, Some("{...props}"), true).await?; // Wrap the SVG in a Solid component template @@ -364,7 +473,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { } // Case 6: Vue - (icon_source, Some(Preset::Vue)) => { + (icon_source, Preset::Vue) => { let content = _icon_source_to_svg(icon_source, Some("v-bind=\"$props\""), true).await?; // Wrap the SVG in a Vue component template @@ -387,8 +496,8 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { )) } - // Case 7: Only an icon is provided. - (Some(icon_source), None) => { + // Case 7: Only an icon is provided in `normal` mode. + (Some(icon_source), Preset::Normal) => { let content = _icon_source_to_svg(&Some(icon_source.clone()), None, false).await?; let (file_stem, ext) = _make_svg_filename( @@ -400,9 +509,9 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { Ok((content, file_stem, ext, config.output_line_template.clone())) } - // Case 8: Neither icon nor preset is provided. - (None, None) => { - anyhow::bail!("Either an --icon or a --preset must be provided."); + // Case 8: Normal mode still requires an icon source. + (None, Preset::Normal) => { + anyhow::bail!("The --icon argument is required when --preset is normal."); } }?; @@ -426,7 +535,15 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { if index_ts_path.exists() { let mut contents = fs::read_to_string(&index_ts_path)?; - if !contents.contains(&export_line) { + let export_line_trimmed = export_line.trim_end(); + let export_already_exists = contents + .lines() + .any(|line| line.trim_end() == export_line_trimmed); + + if !export_already_exists { + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } contents.push_str(&export_line); fs::write(&index_ts_path, contents)?; println!("Added export to: {}", index_ts_path.display()); @@ -466,61 +583,14 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { let preset = match &cli.preset { Some(p) => { - println!("> โœฆ Preset: emptysvg"); + println!("> โœฆ Preset: {}", p.to_str()); Some(p.clone()) } None => { - #[derive(Debug, Copy, Clone)] - struct PresetOpt { - key: &'static str, - desc: &'static str, - } - - impl std::fmt::Display for PresetOpt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} โ€” {}", self.key, self.desc) - } - } - - let preset_opts = vec![ - PresetOpt { - key: "normal", - desc: "Plain svg (.svg)", - }, - PresetOpt { - key: "emptysvg", - desc: "A blank SVG file (.svg)", - }, - PresetOpt { - key: "react", - desc: "React Component (.tsx)", - }, - PresetOpt { - key: "svelte", - desc: "Svelte Component (.svelte)", - }, - PresetOpt { - key: "solid", - desc: "Solid Component (.tsx)", - }, - PresetOpt { - key: "vue", - desc: "Vue Component (.vue)", - }, - ]; - let preset_raw = Select::new("โœฆ Preset", preset_opts) + let preset_opt = Select::new("โœฆ Preset", PRESETS_OPTIONS.to_vec()) .with_render_config(render_config.clone()) .prompt()?; - - // My rust skill issue doesn't know how to return this as just 1 item. - match preset_raw.key { - "emptysvg" => Some(Preset::EmptySvg), - "react" => Some(Preset::React), - "svelte" => Some(Preset::Svelte), - "solid" => Some(Preset::Solid), - "vue" => Some(Preset::Vue), - _ => None, - } + Some(preset_opt.preset) } }; @@ -569,17 +639,29 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { }, }; + let inferred_name = icon + .as_ref() + .and_then(|icon_source| default_name_and_filename_from_icon_source(icon_source)) + .map(|(name, _)| name); + let name = match &cli.name { Some(n) => { println!("> โœง Name: {}", n); n.clone() } - None => Text::new("โœง Name (required, e.g., Heart)") - .with_render_config(render_config) - .with_validator(inquire::validator::ValueRequiredValidator::new( - "Name is required.", - )) - .prompt()?, + None => { + let mut prompt = Text::new("โœง Name (required, e.g., Heart)") + .with_render_config(render_config) + .with_validator(inquire::validator::ValueRequiredValidator::new( + "Name is required.", + )); + + if let Some(default_name) = inferred_name.as_deref() { + prompt = prompt.with_default(default_name); + } + + prompt.prompt()? + } }; let config = AppConfig { @@ -587,72 +669,64 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { name, icon, filename, - output_line_template: cli.output_line_template.clone(), + output_line_template: cli + .output_line_template + .clone() + .unwrap_or_else(|| config::DEFAULT_OUTPUT_LINE_TEMPLATE.to_string()), preset, }; run_app(config).await } -/// Struct to hold icon information for deletion -#[derive(Debug)] -struct IconEntry { - name: String, - file_path: String, -} - impl std::fmt::Display for IconEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} โ€” {}", self.name, self.file_path) } } -/// Parses an export line to extract icon name and file path -fn _parse_export_line(line: &str) -> Option { - // Example line: "export { default as IconWallet } from './ic:baseline-account-balance-wallet.svg';" - // or: "export { default as IconHeart } from './heroicons:heart.svg?react';" - // or: "export { default as IconHeart } from './heroicons:heart.tsx';" - // or: "export { default as IconHeart } from './heroicons:heart.svg';" - - // Trim whitespace and check for export pattern - let line = line.trim(); - if let Some(export_start) = line.find("export { default as Icon") { - // Find the name between "Icon" and "}" - let after_icon = &line[export_start + 24..]; - if let Some(name_end) = after_icon.find('}') { - let name = after_icon[..name_end].trim(); - - // Extract file path from the 'from' part - if let Some(from_start) = line.find("from '") { - let path_start = from_start + 6; - if let Some(path_end) = line[path_start..].find('\'') { - let raw_path = &line[path_start..path_start + path_end]; - // Remove leading ./ if present - let cleaned_path = raw_path.trim_start_matches("./"); - // Remove query parameters like "?raw" or "?react" - let cleaned_path = if let Some(query_pos) = cleaned_path.find('?') { - &cleaned_path[..query_pos] - } else { - cleaned_path - }; - return Some(IconEntry { - name: name.to_string(), - file_path: cleaned_path.to_string(), - }); - } - } - } +fn remove_selected_exports_from_index(contents: &str, selected_icons: &[IconEntry]) -> String { + use std::collections::HashSet; + + let selected = selected_icons + .iter() + .map(|icon| (icon.name.clone(), icon.file_path.clone())) + .collect::>(); + + let kept_lines = contents + .lines() + .filter(|line| { + !crate::utils::parse_export_line_ts(line) + .map(|entry| selected.contains(&(entry.name, entry.file_path))) + .unwrap_or(false) + }) + .collect::>(); + + let mut updated = kept_lines.join("\n"); + if contents.ends_with('\n') { + updated.push('\n'); } - None + + updated } /// Interactive mode: deleting an icon from a select list of icons. -async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { +fn resolve_delete_folder<'a>( + cli: &'a CliArgs, + command_folder: Option<&'a PathBuf>, +) -> Option<&'a PathBuf> { + command_folder.or(cli.folder.as_ref()) +} + +async fn run_delete_prompt_mode( + cli: &CliArgs, + command_folder: Option<&PathBuf>, +) -> anyhow::Result<()> { use inquire::{Confirm, MultiSelect, Text, ui::RenderConfig}; let render_config = RenderConfig::default().with_prompt_prefix(inquire::ui::Styled::new("โ—")); // Step 1: Get the folder path - let folder_raw = match &cli.folder { + let folder_raw = match resolve_delete_folder(cli, command_folder) { Some(f) => { println!("> ๏„• Folder: {}", f.display()); f.display().to_string() @@ -677,7 +751,7 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { let mut icons = Vec::new(); for line in contents.lines() { - if let Some(icon_entry) = _parse_export_line(line) { + if let Some(icon_entry) = crate::utils::parse_export_line_ts(line) { icons.push(icon_entry); } } @@ -712,8 +786,6 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { } // Step 7: Delete the icons - let mut updated_index_content = contents.clone(); - for icon_to_delete in &selected_icons { let full_path = folder.join(&icon_to_delete.file_path); @@ -722,46 +794,17 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { if let Err(e) = fs::remove_file(&full_path) { eprintln!("Failed to delete {}: {}", full_path.display(), e); } else { - println!("Deleted: {}", full_path.display()); + eprintln!("Deleted: {}", full_path.display()); } } else { - println!("File not found: {}", full_path.display()); - } - - // Remove the export line from index.ts content - if let Some(line_to_remove) = _parse_export_line(&contents) { - if line_to_remove.name == icon_to_delete.name - && line_to_remove.file_path == icon_to_delete.file_path - { - if let Some(line_start) = contents.find(&line_to_remove.name) { - // Find the start of the line - let mut start = line_start; - while start > 0 && contents.chars().nth(start - 1) != Some('\n') { - start -= 1; - } - - // Find the end of the line - let mut end = start; - let chars: Vec = contents.chars().collect(); - while end < chars.len() && chars[end] != '\n' { - end += 1; - } - if end < chars.len() { - end += 1; // Include the newline - } - - // Remove the line - let before = &updated_index_content[..start]; - let after = &updated_index_content[end..]; - updated_index_content = format!("{}{}", before, after); - } - } + eprintln!("File not found: {}", full_path.display()); } } + let updated_index_content = remove_selected_exports_from_index(&contents, &selected_icons); + // Write the updated index.ts fs::write(&index_ts_path, updated_index_content)?; - println!("Updated index.ts"); Ok(()) } @@ -789,8 +832,95 @@ async fn main() -> anyhow::Result<()> { }; run_app(config).await } - Some(Commands::Prompt {}) => run_prompt_mode(&args).await, - Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await, - None => run_prompt_mode(&args).await, + Some(Commands::Tui {}) => run_prompt_mode(&args).await, + Some(Commands::Delete { ref folder }) => { + run_delete_prompt_mode(&args, folder.as_ref()).await + } + Some(Commands::Iconify { command }) => run_iconify_command(command).await, + None => { + let resolved = config::resolve_tui_config( + args.folder.as_ref(), + args.preset.as_ref(), + args.output_line_template.as_ref(), + )?; + + for warning in &resolved.warnings { + eprintln!("Warning: {warning}"); + } + for info in &resolved.info { + eprintln!("{info}"); + } + + let config = app_state::AppConfig { + folder: resolved.folder, + preset: resolved.preset, + template: Some(resolved.output_line_template), + svg_viewer_cmd: resolved.svg_viewer_cmd, + svg_viewer_cmd_source: resolved.svg_viewer_cmd_source, + global_config_loaded: resolved.global_config_loaded, + project_config_loaded: resolved.project_config_loaded, + }; + tui::run(config).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remove_selected_exports_removes_each_selected_line() { + let contents = "export { default as IconOne } from './one.svg';\nexport { default as IconTwo } from './two.svg?react';\nexport { default as IconThree } from './three.svg';\n"; + + let selected_icons = vec![ + crate::utils::parse_export_line_ts("export { default as IconOne } from './one.svg';") + .expect("line should parse"), + crate::utils::parse_export_line_ts( + "export { default as IconTwo } from './two.svg?react';", + ) + .expect("line should parse"), + ]; + + let updated = remove_selected_exports_from_index(contents, &selected_icons); + + assert!(!updated.contains("IconOne")); + assert!(!updated.contains("IconTwo")); + assert!(updated.contains("IconThree")); + } + + #[test] + fn resolve_delete_folder_prefers_subcommand_folder() { + let cli_folder = PathBuf::from("src/assets/icons"); + let command_folder = PathBuf::from("icons/from/delete"); + let cli = CliArgs { + command: None, + folder: Some(cli_folder), + preset: None, + name: None, + icon: None, + filename: None, + output_line_template: None, + }; + + let resolved = resolve_delete_folder(&cli, Some(&command_folder)); + assert_eq!(resolved, Some(&command_folder)); + } + + #[test] + fn resolve_delete_folder_falls_back_to_global_folder() { + let cli_folder = PathBuf::from("src/assets/icons"); + let cli = CliArgs { + command: None, + folder: Some(cli_folder.clone()), + preset: None, + name: None, + icon: None, + filename: None, + output_line_template: None, + }; + + let resolved = resolve_delete_folder(&cli, None); + assert_eq!(resolved, Some(&cli_folder)); } } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..0cbfb10 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,77 @@ +use crate::{ + app_state::{App, AppConfig, AppFocus}, + views::main::render_main_view, +}; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Frame, Terminal, backend::CrosstermBackend, layout::Constraint}; +use std::{io, time::Duration}; +use tui_textarea::{Input, Key}; + +pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(config); + + loop { + terminal.draw(|f| ui(f, &mut app))?; + + if ratatui::crossterm::event::poll(Duration::from_millis(16))? { + match ratatui::crossterm::event::read()?.into() { + Input { + key: Key::Char('c'), + ctrl: true, + .. + } => break, + input => app.handlekeys(input), + } + } + + app.update(); + if app.should_quit { + break; + } + } + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + Ok(()) +} + +fn ui(f: &mut Frame, app: &mut App) { + let area = f.area(); + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); + + // Pages + render_main_view(f, layout[0], app); + + // Modals + + match app.app_focus { + AppFocus::AddPopup => crate::views::add_popup::render_add_popup(f, app), + AppFocus::DeletePopup => crate::views::delete_popup::render_delete_popup(f, app), + AppFocus::RenamePopup => crate::views::rename_popup::render_rename_popup(f, app), + AppFocus::HelpPopup => crate::views::help_popup::render_help_popup(f, app), + AppFocus::IconifySearchPopup => { + crate::views::iconify_search_popup::render_iconify_search_popup(f, app) + } + _ => {} + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..667ae27 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,1049 @@ +use clap::ValueEnum; +use ratatui::layout::Rect; +use reqwest::Url; + +use crate::iconify::IconifyClient; + +#[derive(ValueEnum, Clone, Debug, PartialEq, Hash)] +pub enum Preset { + /// Use the icon source as a regular SVG. + #[value(name = "normal")] + Normal, + + /// Use a blank SVG. + #[value(name = "emptysvg")] + EmptySvg, + + /// React Component .tsx + #[value(name = "react")] + React, + + /// Svelte Component .svelte + #[value(name = "svelte")] + Svelte, + + /// Solid Component .tsx + #[value(name = "solid")] + Solid, + + /// Vue + #[value(name = "vue")] + Vue, +} + +impl Preset { + pub fn to_str(&self) -> &'static str { + match self { + Preset::Normal => "normal", + Preset::EmptySvg => "emptysvg", + Preset::React => "react", + Preset::Svelte => "svelte", + Preset::Solid => "solid", + Preset::Vue => "vue", + } + } +} + +/// A helper struct that pairs a preset with its human-readable description +#[derive(Debug, Clone)] +pub struct PresetOption { + pub preset: Preset, + pub description: &'static str, +} + +impl std::fmt::Display for PresetOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} - {}", self.preset.to_str(), self.description) + } +} + +pub const PRESETS_OPTIONS: &[PresetOption] = &[ + PresetOption { + preset: Preset::Normal, + description: "Outputs an svg from an icon source (.svg)", + }, + PresetOption { + preset: Preset::EmptySvg, + description: "Outputs a blank svg placeholder (.svg)", + }, + PresetOption { + preset: Preset::React, + description: "Outputs a React component (.tsx)", + }, + PresetOption { + preset: Preset::Svelte, + description: "Outputs a Svelte component (.svelte)", + }, + PresetOption { + preset: Preset::Solid, + description: "Outputs a SolidJS component (.tsx)", + }, + PresetOption { + preset: Preset::Vue, + description: "Outputs a Vue component (.vue)", + }, +]; + +/// helper function to create a centered rect using up certain maximum dimensions `r` +pub fn popup_area(area: Rect, max_width: u16, max_height: u16) -> Rect { + let width = max_width.min(area.width); + let height = max_height.min(area.height); + let horizontal_margin = area.width.saturating_sub(width) / 2; + let vertical_margin = area.height.saturating_sub(height) / 2; + Rect { + x: area.x + horizontal_margin, + y: area.y + vertical_margin, + width, + height, + } +} + +/// Struct to hold icon information for deletion +#[derive(Debug, Clone)] +pub struct IconEntry { + pub name: String, + pub file_path: String, +} + +/// Enum representing the type of icon source +#[derive(Debug, PartialEq)] +pub enum IconSourceType { + /// A plain iconify name (e.g., "stash:chevron") + IconifyName, + /// A full HTTP/HTTPS URL + Url, + /// Raw SVG content + SvgContent, + /// None provided + None, +} + +fn decode_icon_candidate(value: &str) -> String { + value + .replace("%3A", ":") + .replace("%3a", ":") + .replace("%2F", "/") + .replace("%2f", "/") +} + +fn is_iconify_name(value: &str) -> bool { + let Some((prefix, icon)) = value.split_once(':') else { + return false; + }; + + if prefix.trim().is_empty() || icon.trim().is_empty() { + return false; + } + + !value.chars().any(char::is_whitespace) +} + +fn to_pascal_case(input: &str) -> String { + input + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut built = String::new(); + built.extend(first.to_uppercase()); + built.push_str(&chars.as_str().to_ascii_lowercase()); + built + } + None => String::new(), + } + }) + .collect::() +} + +pub fn iconify_name_from_icon_source(icon_source: &str) -> Option { + let trimmed = icon_source.trim(); + if trimmed.is_empty() || trimmed.trim_start().starts_with(" = url.path_segments()?.collect(); + if segments.len() >= 4 && segments[0] == "collection" && segments[2] == "icon" { + let candidate = format!( + "{}:{}", + decode_icon_candidate(segments[1]), + decode_icon_candidate(segments[3]) + ); + if is_iconify_name(&candidate) { + return Some(candidate); + } + } + + if segments.len() >= 3 && segments[0] == "icon" { + let candidate = format!( + "{}:{}", + decode_icon_candidate(segments[1]), + decode_icon_candidate(segments[2]) + ); + if is_iconify_name(&candidate) { + return Some(candidate); + } + } + } + + if host.contains("api.iconify.design") { + let path = url.path().trim_start_matches('/').trim_end_matches('/'); + + if path.ends_with(".json") { + let prefix = path.trim_end_matches(".json"); + for (key, value) in url.query_pairs() { + if key == "icons" { + let icon = value.split(',').next().unwrap_or("").trim(); + let candidate = format!( + "{}:{}", + decode_icon_candidate(prefix), + decode_icon_candidate(icon) + ); + if is_iconify_name(&candidate) { + return Some(candidate); + } + } + } + } + + let without_ext = path.trim_end_matches(".svg"); + let decoded = decode_icon_candidate(without_ext); + if is_iconify_name(&decoded) { + return Some(decoded); + } + + if let Some((prefix, icon)) = decoded.split_once('/') { + let candidate = format!("{}:{}", prefix, icon); + if is_iconify_name(&candidate) { + return Some(candidate); + } + } + } + + None +} + +pub fn default_name_and_filename_from_icon_source(icon_source: &str) -> Option<(String, String)> { + let iconify_name = iconify_name_from_icon_source(icon_source)?; + let icon_name = iconify_name + .split_once(':') + .map(|(_, icon)| icon) + .unwrap_or(iconify_name.as_str()); + let component_name = to_pascal_case(icon_name); + + if component_name.is_empty() { + return None; + } + + Some((component_name, iconify_name)) +} + +/// Util: Determines the type of icon source +pub fn _determine_icon_source_type(icon_source: Option<&String>) -> IconSourceType { + match icon_source { + Some(icon) => { + if icon.trim_start().starts_with(" IconSourceType::None, + } +} + +/// Util: Converts any icon_source into an SVG +pub async fn _icon_source_to_svg( + icon_source: &Option, + append_attribute: Option<&'static str>, + remove_comments: bool, +) -> anyhow::Result { + // If icon_source is missing, return a minimal SVG (Note: rust skill issue idk how else to just reuse the last clause in the match below) + let Some(icon_source) = icon_source else { + return Ok(r#""#.to_string()); + }; + + let mut content = match _determine_icon_source_type(Some(icon_source)) { + IconSourceType::SvgContent => { + // Already an SVG document + icon_source.clone() + } + IconSourceType::IconifyName => { + let client = IconifyClient::from_env()?; + client.svg(icon_source).await? + } + IconSourceType::Url => { + if let Some(iconify_name) = iconify_name_from_icon_source(icon_source) { + let client = IconifyClient::from_env()?; + client.svg(&iconify_name).await? + } else { + // Already a full URL + let icon_url = Url::parse(icon_source)?; + println!("Fetching icon from: {}", icon_url); + + // Fetch the SVG content + let client = reqwest::Client::new(); + let response = client.get(icon_url).send().await?.error_for_status()?; + response.text().await? + } + } + IconSourceType::None => { + return Ok(r#""#.to_string()); + } + }; + + // -- Transformations if applicable --- + + // 1. Append attribute (i.e. for jsx,svelte,vue) + if let Some(attr) = append_attribute { + // Find the first occurrence of "" + if let Some(svg_start) = content.find("') { + let insert_pos = svg_start + svg_tag_end; + let before = &content[..insert_pos]; + let after = &content[insert_pos..]; + content = format!("{} {}{}", before, attr, after); + } + } + } + + // 2. Remove Comments + if remove_comments { + // Remove HTML comments from SVG content + // This regex matches including any content in between + let re = regex::Regex::new(r"").unwrap(); + content = re.replace_all(&content, "").to_string(); + } + + Ok(content) +} + +/// Util: Reused in all cases, for appending the filename of svg, i.e. add .tsx or .svg or .svelte. +/// Returns a file_stem and an ext +pub fn _make_svg_filename( + stem_from_cli: Option<&String>, + ext: &'static str, + icon_source: Option<&String>, + name_from_cli: &str, +) -> (String, &'static str) { + let stem = if let Some(stem) = stem_from_cli { + stem.clone() + } else if let Some(icon) = icon_source { + // Only use icon_source if it's a plain iconify name (no http/https, no { + iconify_name_from_icon_source(icon).unwrap_or(icon.clone()) + } + IconSourceType::Url => iconify_name_from_icon_source(icon) + .unwrap_or_else(|| name_from_cli.to_string().to_lowercase()), + _ => name_from_cli.to_string().to_lowercase(), + } + } else { + name_from_cli.to_string().to_lowercase() + }; + + if stem.ends_with(ext) { + (stem.replace(ext, ""), ext) + } else { + (stem, ext) + } +} + +// Util for tui view in add. +pub fn filename_from_preset(file_name: Option, preset: Option) -> String { + if let Some(preset) = preset { + let ext = match preset { + Preset::Normal => "svg", + Preset::EmptySvg => "svg", + Preset::React => "tsx", + Preset::Svelte => "svelte", + Preset::Solid => "tsx", + Preset::Vue => "vue", + }; + + if let Some(name) = file_name { + if name.contains('.') { + return name; + } else { + return format!("{}.{}", name, ext); + } + } else { + return format!("component.{}", ext); + } + } + + if let Some(name) = file_name { + return name; + } + + "".to_string() +} + +/// Util: Reads a file line-by-line and extracts every icon entry that matches +/// the template used by the current project. +/// Returns a vector of `IconEntry` with the export alias and import file path. +pub fn get_existing_icons(folder_path: &str) -> anyhow::Result> { + use std::fs::File; + use std::io::{BufRead, BufReader}; + use std::path::Path; + + let index_path = Path::new(folder_path).join("index.ts"); // FUTURE: for flutter suport, make sure to configure this + the parsing of it. + + let file = File::open(&index_path)?; + + let reader = BufReader::new(file); + + let mut icons = Vec::new(); + + for line in reader.lines() { + let line = line?; + + // Skip empty lines and comments + if line.trim().is_empty() || line.trim_start().starts_with("//") { + continue; + } + + // Parse one or more exports per physical line. + // This keeps discovery resilient if exports were accidentally concatenated. + for statement in line.split(';') { + let statement = statement.trim(); + if statement.is_empty() || statement.starts_with("//") { + continue; + } + + if let Some(icon_entry) = parse_export_line_ts(statement) { + icons.push(icon_entry); + } + } + } + + Ok(icons) +} + +/// For parsing a single export line in typescript. +pub fn parse_export_line_ts(line: &str) -> Option { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.trim_start().starts_with("//") { + return None; + } + + // Example lines: + // export { default as IconGitHub } from "./devicon:github.svg"; + // export { default as IconGitHub } from './devicon:github.svg'; + if !line.starts_with("export") { + return None; + } + + let open_brace_idx = line.find('{')?; + let close_brace_idx = line[open_brace_idx + 1..].find('}')? + open_brace_idx + 1; + + let inside_braces = line[open_brace_idx + 1..close_brace_idx].trim(); + let mut tokens = inside_braces.split_whitespace(); + if tokens.next()? != "default" || tokens.next()? != "as" { + return None; + } + + let name = tokens.next()?.trim_end_matches(','); + if name.is_empty() { + return None; + } + + let after_brace = line[close_brace_idx + 1..].trim_start(); + let after_from = after_brace.strip_prefix("from")?.trim_start(); + let quote_char = after_from.chars().next()?; + if quote_char != '"' && quote_char != '\'' { + return None; + } + + let path_start = quote_char.len_utf8(); + let path_end = after_from[path_start..].find(quote_char)?; + let relative_path = &after_from[path_start..path_start + path_end]; + let mut import_path_end = relative_path.len(); + if let Some(index) = relative_path.find('?') { + import_path_end = import_path_end.min(index); + } + if let Some(index) = relative_path.find('#') { + import_path_end = import_path_end.min(index); + } + let import_path = relative_path[..import_path_end].trim(); + if import_path.is_empty() { + return None; + } + + Some(IconEntry { + name: name.to_string(), + file_path: import_path.to_string(), + }) +} + +// FUTURE: +// pub fn _parse_export_line_dart(line: &str) -> Option {} + +/// Deletes an IconEntry based on its file path +pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { + use std::fs; + use std::path::Path; + + let path = Path::new(file_path); + + // Delete the icon file when present. We still continue to clean index.ts + // if the file is already missing (stale export entry). + if path.exists() { + fs::remove_file(path)?; + } + + // Find the parent folder and index.ts + if let Some(parent) = path.parent() { + let index_path = parent.join("index.ts"); + + if index_path.exists() { + // Read the current index.ts + let contents = fs::read_to_string(&index_path)?; + + // Generate the file path relative to the parent folder + let relative_path = path + .strip_prefix(parent) + .ok() + .and_then(|value| value.to_str()) + .unwrap_or(file_path); + + // Create the normalized relative path for comparison + let normalized_relative_path = normalize_icon_relative_path(relative_path); + // Remove all lines that export this file + let mut lines_to_keep = Vec::new(); + let mut found_export = false; + + for line in contents.lines() { + let should_remove = parse_export_line_ts(line) + .map(|entry| { + normalize_icon_relative_path(&entry.file_path) == normalized_relative_path + }) + .unwrap_or(false); + + if should_remove { + found_export = true; + continue; // Skip this line (remove it) + } + lines_to_keep.push(line); + } + + if found_export { + // Write the updated content back + let mut updated_content = lines_to_keep.join("\n"); + if contents.ends_with('\n') { + updated_content.push('\n'); + } + fs::write(&index_path, updated_content)?; + // println!("Updated index.ts"); + } + } + } + + Ok(()) +} + +fn normalize_icon_relative_path(value: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .replace('\\', "/") + .trim_start_matches("./") + .to_string() +} + +fn split_import_path_suffix(value: &str) -> (&str, &str) { + let query_index = value.find('?'); + let hash_index = value.find('#'); + + let split_index = match (query_index, hash_index) { + (Some(query), Some(hash)) => Some(query.min(hash)), + (Some(index), None) | (None, Some(index)) => Some(index), + (None, None) => None, + }; + + match split_index { + Some(index) => (&value[..index], &value[index..]), + None => (value, ""), + } +} + +pub fn rename_icon_entry( + folder_path: &str, + current_file_path: &str, + new_file_path_input: &str, +) -> anyhow::Result<()> { + use std::fs; + use std::path::{Component, Path}; + + let current_relative_path = normalize_icon_relative_path(current_file_path); + if current_relative_path.is_empty() { + anyhow::bail!("Current icon path is empty"); + } + + let mut new_relative_path = normalize_icon_relative_path(new_file_path_input); + if new_relative_path.is_empty() { + anyhow::bail!("New filename cannot be empty"); + } + + let new_path = Path::new(&new_relative_path); + if new_path.is_absolute() { + anyhow::bail!("Please provide a relative filename"); + } + if new_path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("Parent directory traversals are not allowed"); + } + + if new_path.extension().is_none() { + if let Some(extension) = Path::new(¤t_relative_path) + .extension() + .and_then(|ext| ext.to_str()) + { + new_relative_path = format!("{}.{}", new_relative_path, extension); + } + } + + if new_relative_path == current_relative_path { + anyhow::bail!("Filename is unchanged"); + } + + let folder = Path::new(folder_path); + let current_abs_path = folder.join(¤t_relative_path); + if !current_abs_path.exists() { + anyhow::bail!("Icon file not found: {}", current_abs_path.display()); + } + + let new_abs_path = folder.join(&new_relative_path); + if new_abs_path.exists() { + anyhow::bail!("Target file already exists: {}", new_abs_path.display()); + } + + let index_path = folder.join("index.ts"); + if !index_path.exists() { + anyhow::bail!("No index.ts found in folder: {}", folder.display()); + } + + let index_contents = fs::read_to_string(&index_path)?; + let mut replaced_count = 0usize; + let mut updated_lines = Vec::new(); + for line in index_contents.lines() { + if let Some(from_pos) = line.find("from ") { + let path_start_search = from_pos + "from ".len(); + let line_after_from = &line[path_start_search..]; + if let Some(first_quote_offset) = line_after_from.find(|c| c == '"' || c == '\'') { + let first_quote_idx = path_start_search + first_quote_offset; + let quote_char = line.as_bytes()[first_quote_idx] as char; + let path_start_idx = first_quote_idx + 1; + if let Some(second_quote_offset) = line[path_start_idx..].find(quote_char) { + let path_end_idx = path_start_idx + second_quote_offset; + let matched_path = &line[path_start_idx..path_end_idx]; + let (matched_base_path, matched_suffix) = + split_import_path_suffix(matched_path); + + if normalize_icon_relative_path(matched_base_path) == current_relative_path { + let with_dot_prefix = matched_base_path.starts_with("./"); + let replacement_path = if with_dot_prefix { + format!("./{}", new_relative_path) + } else { + new_relative_path.clone() + }; + let updated_line = format!( + "{}{}{}{}{}", + &line[..path_start_idx], + replacement_path, + matched_suffix, + quote_char, + &line[path_end_idx + 1..] + ); + updated_lines.push(updated_line); + replaced_count += 1; + continue; + } + } + } + } + + updated_lines.push(line.to_string()); + } + + if replaced_count == 0 { + anyhow::bail!( + "Could not find an export path for '{}' in index.ts", + current_file_path + ); + } + + if let Some(parent) = new_abs_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::rename(¤t_abs_path, &new_abs_path)?; + + let mut updated_index = updated_lines.join("\n"); + if index_contents.ends_with('\n') { + updated_index.push('\n'); + } + if let Err(write_error) = fs::write(&index_path, updated_index) { + let _ = fs::rename(&new_abs_path, ¤t_abs_path); + anyhow::bail!( + "Failed to update index.ts after rename: {}. Rolled back file rename.", + write_error + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn parses_iconify_name_from_plain_value() { + assert_eq!( + iconify_name_from_icon_source("mdi:heart-outline"), + Some("mdi:heart-outline".to_string()) + ); + } + + #[test] + fn parses_iconify_name_from_iconify_api_url() { + assert_eq!( + iconify_name_from_icon_source("https://api.iconify.design/mdi%3Aheart.svg"), + Some("mdi:heart".to_string()) + ); + } + + #[test] + fn parses_iconify_name_from_icones_collection_url() { + assert_eq!( + iconify_name_from_icon_source("https://icones.js.org/collection/lucide/icon/heart"), + Some("lucide:heart".to_string()) + ); + } + + #[test] + fn parses_iconify_name_from_icones_query_url() { + assert_eq!( + iconify_name_from_icon_source("https://icones.js.org/?icon=tabler:home"), + Some("tabler:home".to_string()) + ); + } + + #[test] + fn derives_name_and_filename_defaults() { + assert_eq!( + default_name_and_filename_from_icon_source( + "https://api.iconify.design/mdi:arrow-left.svg" + ), + Some(("ArrowLeft".to_string(), "mdi:arrow-left".to_string())) + ); + } + + #[test] + fn parses_typescript_export_with_double_quotes() { + let parsed = + parse_export_line_ts("export { default as IconGithub } from \"./mdi:github.svg\";") + .expect("export with double quotes should parse"); + + assert_eq!(parsed.name, "IconGithub"); + assert_eq!(parsed.file_path, "./mdi:github.svg"); + } + + #[test] + fn parses_typescript_export_with_single_quotes() { + let parsed = + parse_export_line_ts("export { default as IconGithub } from './mdi:github.svg';") + .expect("export with single quotes should parse"); + + assert_eq!(parsed.name, "IconGithub"); + assert_eq!(parsed.file_path, "./mdi:github.svg"); + } + + #[test] + fn parses_typescript_export_with_extra_spacing() { + let parsed = parse_export_line_ts( + "export { default as IconGithub } from './mdi:github.svg';", + ) + .expect("export with variable spacing should parse"); + + assert_eq!(parsed.name, "IconGithub"); + assert_eq!(parsed.file_path, "./mdi:github.svg"); + } + + #[test] + fn parses_typescript_export_and_strips_import_query() { + let parsed = parse_export_line_ts( + "export { default as IconGithub } from './mdi:github.svg?react#hash';", + ) + .expect("export with query suffix should parse"); + + assert_eq!(parsed.name, "IconGithub"); + assert_eq!(parsed.file_path, "./mdi:github.svg"); + } + + #[test] + fn renames_file_and_updates_index_path() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let old_file = icons_folder.join("old-name.svg"); + std::fs::write(&old_file, "").expect("old file should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconHeart } from \"./old-name.svg\";\n", + ) + .expect("index.ts should be created"); + + rename_icon_entry( + icons_folder.to_string_lossy().as_ref(), + "./old-name.svg", + "new-name", + ) + .expect("rename should succeed"); + + assert!(!old_file.exists(), "old file should be removed"); + assert!( + icons_folder.join("new-name.svg").exists(), + "new file should exist" + ); + + let index_contents = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!( + index_contents.contains("export { default as IconHeart } from \"./new-name.svg\";"), + "index.ts should point to the renamed file" + ); + } + + #[test] + fn renames_path_without_touching_alias() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let old_file = icons_folder.join("foo.svg"); + std::fs::write(&old_file, "").expect("old file should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconAliasStays } from './foo.svg';\n", + ) + .expect("index.ts should be created"); + + rename_icon_entry( + icons_folder.to_string_lossy().as_ref(), + "./foo.svg", + "bar.svg", + ) + .expect("rename should succeed"); + + let index_contents = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!( + index_contents.contains("export { default as IconAliasStays } from './bar.svg';"), + "index.ts should update only the path" + ); + assert!( + !index_contents.contains("Iconbar") && index_contents.contains("IconAliasStays"), + "icon alias should stay unchanged" + ); + } + + #[test] + fn renames_file_and_preserves_import_suffix() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let old_file = icons_folder.join("foo.svg"); + std::fs::write(&old_file, "").expect("old file should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconAliasStays } from './foo.svg?react#hash';\n", + ) + .expect("index.ts should be created"); + + rename_icon_entry( + icons_folder.to_string_lossy().as_ref(), + "./foo.svg", + "bar.svg", + ) + .expect("rename should succeed"); + + let index_contents = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!( + index_contents + .contains("export { default as IconAliasStays } from './bar.svg?react#hash';"), + "index.ts should preserve import suffixes" + ); + } + + #[test] + fn get_existing_icons_reads_multiple_exports_on_same_line() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconGithub } from './mdi:github.svg';export { default as IconDarkTheme24Filled } from './fluent:dark-theme-24-filled.svg';\n", + ) + .expect("index.ts should be created"); + + let icons = get_existing_icons(icons_folder.to_string_lossy().as_ref()) + .expect("icons should be discovered"); + + assert_eq!(icons.len(), 2, "both exports should be discovered"); + assert!(icons.iter().any(|icon| icon.name == "IconGithub")); + assert!( + icons + .iter() + .any(|icon| icon.name == "IconDarkTheme24Filled") + ); + } + + #[test] + fn delete_icon_entry_preserves_trailing_newline() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let keep_file = icons_folder.join("keep.svg"); + let remove_file = icons_folder.join("remove.svg"); + std::fs::write(&keep_file, "").expect("keep icon should be created"); + std::fs::write(&remove_file, "").expect("remove icon should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconKeep } from './keep.svg';\nexport { default as IconRemove } from './remove.svg';\n", + ) + .expect("index.ts should be created"); + + delete_icon_entry(remove_file.to_string_lossy().as_ref()) + .expect("delete should remove icon entry"); + + let updated_index = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!( + updated_index.ends_with('\n'), + "index.ts should retain trailing newline" + ); + assert!(updated_index.contains("IconKeep")); + assert!(!updated_index.contains("IconRemove")); + } + + #[test] + fn delete_icon_entry_removes_only_exact_export_path() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let remove_file = icons_folder.join("remove.svg"); + let keep_file = icons_folder.join("remove-filled.svg"); + std::fs::write(&remove_file, "").expect("remove icon should be created"); + std::fs::write(&keep_file, "").expect("keep icon should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconRemove } from './remove.svg';\nexport { default as IconRemoveFilled } from './remove-filled.svg';\n", + ) + .expect("index.ts should be created"); + + delete_icon_entry(remove_file.to_string_lossy().as_ref()) + .expect("delete should remove only the exact icon entry"); + + let updated_index = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!(updated_index.contains("IconRemoveFilled")); + assert!(!updated_index.contains("IconRemove }")); + } + + #[test] + fn delete_icon_entry_handles_absolute_dot_segment_path() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let remove_file = icons_folder.join("remove.svg"); + std::fs::write(&remove_file, "").expect("remove icon should be created"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconRemove } from './remove.svg';\n", + ) + .expect("index.ts should be created"); + + let absolute_with_dot = icons_folder.join("./remove.svg"); + delete_icon_entry(absolute_with_dot.to_string_lossy().as_ref()) + .expect("delete should accept absolute paths with dot segments"); + + let updated_index = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!(!updated_index.contains("IconRemove")); + } + + #[test] + fn delete_icon_entry_updates_index_even_if_file_is_missing() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let icons_folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&icons_folder).expect("icons folder should be created"); + + let missing_file = icons_folder.join("fluent:dark-theme-24-regular.svg"); + + let index_path = icons_folder.join("index.ts"); + std::fs::write( + &index_path, + "export { default as IconDarkTheme24Regular } from './fluent:dark-theme-24-regular.svg';\nexport { default as IconKeep } from './keep.svg';\n", + ) + .expect("index.ts should be created"); + + delete_icon_entry(missing_file.to_string_lossy().as_ref()) + .expect("delete should remove stale export when file is missing"); + + let updated_index = + std::fs::read_to_string(&index_path).expect("index.ts should be readable"); + assert!(!updated_index.contains("IconDarkTheme24Regular")); + assert!(updated_index.contains("IconKeep")); + } +} diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..87e32dd --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,200 @@ +use anyhow::Context; +use std::path::Path; +use std::process::{Command, Stdio}; + +#[derive(Debug, Clone)] +pub enum OpenSvgOutcome { + OpenedWithCustomCommand, + OpenedWithOsDefault, + OpenedWithOsDefaultAfterCustomFailure, + OpenedWithWebPreview(String), +} + +pub fn open_svg_with_fallback( + svg_path: &Path, + svg_viewer_cmd: Option<&str>, +) -> anyhow::Result { + if !svg_path.exists() { + anyhow::bail!("Icon file not found: {}", svg_path.display()); + } + + let mut errors = Vec::::new(); + + if let Some(command_template) = svg_viewer_cmd { + match open_with_custom_command(command_template, svg_path) { + Ok(()) => return Ok(OpenSvgOutcome::OpenedWithCustomCommand), + Err(error) => errors.push(format!("custom svg_viewer_cmd failed: {error}")), + } + } + + match open_with_os_default(svg_path) { + Ok(()) => { + if svg_viewer_cmd.is_some() { + return Ok(OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure); + } + return Ok(OpenSvgOutcome::OpenedWithOsDefault); + } + Err(error) => errors.push(format!("OS default open failed: {error}")), + } + + if let Some(web_preview_url) = iconify_web_preview_url(svg_path) { + open_url_in_browser(&web_preview_url).with_context(|| { + format!( + "Failed to open web preview URL after local open failures: {}", + web_preview_url + ) + })?; + return Ok(OpenSvgOutcome::OpenedWithWebPreview(web_preview_url)); + } + + if errors.is_empty() { + anyhow::bail!("Failed to open icon file {}", svg_path.display()); + } + + anyhow::bail!( + "Failed to open icon file {}. {}", + svg_path.display(), + errors.join(" | ") + ) +} + +fn open_with_custom_command(command_template: &str, svg_path: &Path) -> anyhow::Result<()> { + let mut parts = shlex::split(command_template).ok_or_else(|| { + anyhow::anyhow!( + "Could not parse svg_viewer_cmd. Check quoting in '{}'.", + command_template + ) + })?; + + if parts.is_empty() { + anyhow::bail!("svg_viewer_cmd is empty"); + } + + let file_name = svg_path.to_string_lossy().to_string(); + let mut used_placeholder = false; + for part in &mut parts { + if part.contains("%filename%") { + *part = part.replace("%filename%", &file_name); + used_placeholder = true; + } + } + + if !used_placeholder { + parts.push(file_name); + } + + let executable = parts.remove(0); + spawn_background(&executable, &parts) + .with_context(|| format!("Failed to run svg_viewer_cmd '{}'.", command_template)) +} + +fn spawn_background(executable: &str, args: &[String]) -> anyhow::Result<()> { + Command::new(executable) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .with_context(|| { + if args.is_empty() { + format!("Failed to spawn '{}'", executable) + } else { + format!("Failed to spawn '{}' with args {:?}", executable, args) + } + })?; + Ok(()) +} + +fn open_with_os_default(svg_path: &Path) -> anyhow::Result<()> { + let file_name = svg_path.to_string_lossy().to_string(); + + #[cfg(target_os = "macos")] + { + let ql_args = vec!["-p".to_string(), file_name.clone()]; + if spawn_background("qlmanage", &ql_args).is_ok() { + return Ok(()); + } + + let open_args = vec![file_name]; + spawn_background("open", &open_args) + .context("Failed to open icon via macOS Quick Look/open") + } + + #[cfg(target_os = "linux")] + { + let args = vec![file_name]; + return spawn_background("xdg-open", &args).context("Failed to open icon via xdg-open"); + } + + #[cfg(target_os = "windows")] + { + let args = vec![ + "/C".to_string(), + "start".to_string(), + "".to_string(), + file_name, + ]; + return spawn_background("cmd", &args).context("Failed to open icon via cmd start"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + anyhow::bail!("Unsupported OS for default icon opener") + } +} + +fn iconify_web_preview_url(svg_path: &Path) -> Option { + let stem = svg_path.file_stem()?.to_string_lossy(); + let icon_name = crate::utils::iconify_name_from_icon_source(stem.as_ref())?; + let encoded = icon_name.replace(':', "%3A"); + Some(format!("https://api.iconify.design/{encoded}.svg")) +} + +pub fn open_url_in_browser(url: &str) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + spawn_background("open", &[url.to_string()]).context("Failed to open URL via open") + } + + #[cfg(target_os = "linux")] + { + return spawn_background("xdg-open", &[url.to_string()]) + .context("Failed to open URL via xdg-open"); + } + + #[cfg(target_os = "windows")] + { + let args = vec![ + "/C".to_string(), + "start".to_string(), + "".to_string(), + url.to_string(), + ]; + return spawn_background("cmd", &args).context("Failed to open URL via cmd start"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + anyhow::bail!("Unsupported OS for URL opener") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_iconify_web_preview_url_from_iconify_stem() { + let path = Path::new("/tmp/mdi:heart.svg"); + assert_eq!( + iconify_web_preview_url(path), + Some("https://api.iconify.design/mdi%3Aheart.svg".to_string()) + ); + } + + #[test] + fn returns_none_for_non_iconify_stem() { + let path = Path::new("/tmp/logo.svg"); + assert_eq!(iconify_web_preview_url(path), None); + } +} diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs new file mode 100644 index 0000000..5ad7d2c --- /dev/null +++ b/src/views/add_popup.rs @@ -0,0 +1,660 @@ +use std::process::Command; + +use crate::app_state::{App, AppFocus}; +use crate::utils::{PRESETS_OPTIONS, Preset, PresetOption, popup_area}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Block, ListItem, Paragraph}; +use tui_textarea::{Input, Key, TextArea}; + +// Constants +// const FOLDER_FIELD_IDX: usize = 0; +const PRESET_FIELD_IDX: usize = 0; +const ICON_FIELD_IDX: usize = 1; +const FILENAME_FIELD_IDX: usize = 2; +const NAME_FIELD_IDX: usize = 3; +const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = "export { default as Icon%name% } from './%icon%%ext%';"; + +#[derive(Debug)] +pub struct AddPopupState { + // Saved values + // folder: Option, + preset: Option, + icon: Option, + filename: Option, + name: Option, + + // Form States + pub current_input: usize, + pub inputs: Vec>, + + pub preset_index: usize, + pub presets_filtered: Vec, + pub preset_filter: String, + pub status_message: Option, + pub status_is_error: bool, +} +impl AddPopupState { + fn is_paste_shortcut(input: &Input) -> bool { + matches!(input.key, Key::Char('v')) && (input.ctrl || input.alt) + } + + fn paste_into_current_input(&mut self) -> bool { + if let Ok(mut ctx) = arboard::Clipboard::new() { + if let Ok(text) = ctx.get_text() { + self.inputs[self.current_input].insert_str(&text); + return true; + } + } + false + } + + fn clear_status(&mut self) { + self.status_message = None; + self.status_is_error = false; + } + + fn set_status_error(&mut self, message: String) { + self.status_message = Some(message); + self.status_is_error = true; + } + + fn apply_icon_based_defaults(&mut self) { + let icon_raw = self.inputs[ICON_FIELD_IDX].lines().join("\n"); + let icon_source = icon_raw.trim(); + if icon_source.is_empty() { + return; + } + + if let Some((default_name, default_filename)) = + crate::utils::default_name_and_filename_from_icon_source(icon_source) + { + let has_filename = !self.inputs[FILENAME_FIELD_IDX] + .lines() + .join("") + .trim() + .is_empty(); + if !has_filename { + self.inputs[FILENAME_FIELD_IDX] = TextArea::default(); + self.inputs[FILENAME_FIELD_IDX].insert_str(&default_filename); + self.filename = Some(default_filename); + } + + let has_name = !self.inputs[NAME_FIELD_IDX] + .lines() + .join("") + .trim() + .is_empty(); + if !has_name { + self.inputs[NAME_FIELD_IDX] = TextArea::default(); + self.inputs[NAME_FIELD_IDX].insert_str(&default_name); + self.name = Some(default_name); + } + } + } + + fn sync_cursor(&mut self, index: usize) { + for (_i, textarea) in self.inputs.iter_mut().enumerate() { + // Stay + if index == _i { + textarea.set_cursor_style( + Style::default() + .bg(crate::views::theme::ACCENT) + .fg(crate::views::theme::BASE_BG), + ); + continue; + } + // Remove + textarea.set_cursor_style(Style::default()); + } + } + + pub fn handlekeys_preset_input(&mut self, input: Input) { + if self.current_input == PRESET_FIELD_IDX { + if Self::is_paste_shortcut(&input) && self.paste_into_current_input() { + self.preset_filter = self.inputs[PRESET_FIELD_IDX].lines().join("\n"); + } + + match input.key { + Key::Tab | Key::Enter => { + // Save the selected preset + if !self.presets_filtered.is_empty() + && self.preset_index < self.presets_filtered.len() + { + self.preset = Some(self.presets_filtered[self.preset_index].preset.clone()); + } + + self.current_input = (self.current_input + 1) % self.inputs.len(); + self.sync_cursor(self.current_input); + } + Key::Up => { + let len = self.presets_filtered.len(); + if len == 0 { + self.preset_index = 0; + return; + } + let delta = -1; + let new_index = + (self.preset_index as i32 + delta).rem_euclid(len as i32) as usize; + self.preset_index = new_index; + } + Key::Down => { + let len = self.presets_filtered.len(); + if len == 0 { + self.preset_index = 0; + return; + } + let delta = 1; + let new_index = + (self.preset_index as i32 + delta).rem_euclid(len as i32) as usize; + self.preset_index = new_index; + } + _ => { + if self.current_input == PRESET_FIELD_IDX { + if !Self::is_paste_shortcut(&input) { + self.inputs[PRESET_FIELD_IDX].input(input); + } + self.preset_filter = self.inputs[PRESET_FIELD_IDX].lines().join("\n"); + + self.presets_filtered = PRESETS_OPTIONS + .iter() + .filter(|opt| { + let filter = self.preset_filter.to_lowercase(); + filter.is_empty() + || opt.preset.to_str().contains(&filter) + || opt.description.to_lowercase().contains(&filter) + }) + .cloned() + .collect(); + if self.preset_index >= self.presets_filtered.len() { + self.preset_index = 0; + } + self.clear_status(); + return; + } + } + } + } + } + + pub fn handlekeys_text_area(&mut self, input: Input) { + if Self::is_paste_shortcut(&input) && self.paste_into_current_input() { + self.clear_status(); + return; + } + + match input.key { + Key::Tab => { + // Save the icon value + self.icon = Some(self.inputs[ICON_FIELD_IDX].lines().join("\n")); + self.apply_icon_based_defaults(); + self.current_input = (self.current_input + 1) % self.inputs.len(); + self.sync_cursor(self.current_input); + } + _ => { + self.inputs[self.current_input].input(input); + self.clear_status(); + } + } + } + + pub fn handlekeys_text_input(&mut self, input: Input) { + if Self::is_paste_shortcut(&input) && self.paste_into_current_input() { + self.clear_status(); + return; + } + + match input.key { + Key::Tab | Key::Enter => { + // Save the current input value before moving to next + let value = self.inputs[self.current_input].lines().join(""); + match self.current_input { + // 0 => self.folder = Some(value), // folder + FILENAME_FIELD_IDX => self.filename = Some(value), // filename + NAME_FIELD_IDX => self.name = Some(value), // name + _ => {} + } + self.current_input = (self.current_input + 1) % self.inputs.len(); + self.sync_cursor(self.current_input); + self.clear_status(); + } + _ => { + self.inputs[self.current_input].input(input); + self.clear_status(); + } + } + } +} + +impl App { + pub fn init_add_popup(&mut self) { + let configured_preset = PRESETS_OPTIONS + .iter() + .find(|option| option.preset.to_str() == self.config.preset.as_str()) + .map(|option| option.preset.clone()) + .unwrap_or(Preset::Normal); + let selected_index = PRESETS_OPTIONS + .iter() + .position(|option| option.preset == configured_preset) + .unwrap_or(0); + + self.app_focus = AppFocus::AddPopup; + self.add_popup_state = Some(AddPopupState { + // folder: None, + icon: None, + name: None, + preset: Some(configured_preset), + filename: None, + + preset_index: selected_index, + preset_filter: String::new(), + inputs: vec![ + TextArea::default(), // preset filter + TextArea::default(), // icon + TextArea::default(), // filename + TextArea::default(), // name + ], + presets_filtered: PRESETS_OPTIONS.to_vec(), + current_input: 0, + status_message: None, + status_is_error: false, + }); + + // Unused: Set default value for folder input + // Unused: self.add_popup_state.as_mut().unwrap().inputs[0].insert_str(&self.config.folder); + self.add_popup_state + .as_mut() + .unwrap() + .sync_cursor(PRESET_FIELD_IDX); // The first. + } + + pub fn init_add_popup_with_icon_source(&mut self, icon_source: &str) { + self.init_add_popup(); + + if let Some(state) = self.add_popup_state.as_mut() { + state.inputs[ICON_FIELD_IDX] = TextArea::default(); + state.inputs[ICON_FIELD_IDX].insert_str(icon_source); + state.icon = Some(icon_source.to_string()); + state.apply_icon_based_defaults(); + state.clear_status(); + state.sync_cursor(PRESET_FIELD_IDX); + } + } + + fn submit_add_popup(&mut self) -> Result<(), String> { + let (preset, icon, filename, name) = { + let Some(state) = self.add_popup_state.as_mut() else { + return Err("Add popup is not initialized".to_string()); + }; + + state.apply_icon_based_defaults(); + + let preset = state + .preset + .clone() + .or_else(|| { + if !state.presets_filtered.is_empty() + && state.preset_index < state.presets_filtered.len() + { + Some(state.presets_filtered[state.preset_index].preset.clone()) + } else { + None + } + }) + .unwrap_or(Preset::Normal); + let icon = state.inputs[ICON_FIELD_IDX] + .lines() + .join("\n") + .trim() + .to_string(); + let filename = state.inputs[FILENAME_FIELD_IDX] + .lines() + .join("") + .trim() + .to_string(); + + let mut name = state.inputs[NAME_FIELD_IDX] + .lines() + .join("") + .trim() + .to_string(); + if name.is_empty() { + if let Some(icon_source) = (!icon.is_empty()).then_some(icon.as_str()) { + if let Some((default_name, _)) = + crate::utils::default_name_and_filename_from_icon_source(icon_source) + { + state.inputs[NAME_FIELD_IDX] = TextArea::default(); + state.inputs[NAME_FIELD_IDX].insert_str(&default_name); + state.name = Some(default_name.clone()); + name = default_name; + } + } + } + + (preset, icon, filename, name) + }; + + if name.is_empty() { + return Err( + "Name is required. Add one or use an Iconify icon URL/name for auto-fill." + .to_string(), + ); + } + + let requires_icon = matches!(preset, Preset::Normal); + if icon.is_empty() && requires_icon { + return Err("Provide an icon source or choose a non-normal preset first.".to_string()); + } + + let mut command = Command::new(std::env::current_exe().map_err(|error| error.to_string())?); + command + .arg("add") + .arg("--folder") + .arg(&self.config.folder) + .arg("--name") + .arg(&name) + .arg("--output-line-template") + .arg( + self.config + .template + .as_deref() + .unwrap_or(DEFAULT_OUTPUT_LINE_TEMPLATE), + ); + + command.arg("--preset").arg(preset.to_str()); + + if !icon.is_empty() { + command.arg("--icon").arg(icon); + } + + if !filename.is_empty() { + command.arg("--filename").arg(filename); + } + + let output = command.output().map_err(|error| error.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "Failed to add icon".to_string() + }; + return Err(message); + } + + self.init_icons(); + self.app_focus = AppFocus::Main; + self.add_popup_state = None; + + Ok(()) + } + + pub fn handlekeys_add_popup(&mut self, input: Input) { + let should_submit = self + .add_popup_state + .as_ref() + .map(|state| state.current_input == NAME_FIELD_IDX && input.key == Key::Enter) + .unwrap_or(false); + + if should_submit { + if let Err(error) = self.submit_add_popup() { + if let Some(state) = self.add_popup_state.as_mut() { + state.set_status_error(error); + } + } + return; + } + + if let Some(state) = self.add_popup_state.as_mut() { + let _input = input.clone(); + + match state.current_input { + PRESET_FIELD_IDX => state.handlekeys_preset_input(_input), + ICON_FIELD_IDX => state.handlekeys_text_area(_input), + _ => state.handlekeys_text_input(_input), + } + + match input.key { + Key::Esc => { + self.app_focus = AppFocus::Main; + self.add_popup_state = None; + } + _ => {} + } + } + } +} + +pub fn render_add_popup(f: &mut Frame, app: &mut App) { + use ratatui::style::Modifier; + + let area = popup_area(f.area(), 78, 29); + let body_area = crate::views::theme::render_popup_shell(f, area, "Add Icon"); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(body_area); + let preset_area = layout[0]; + let icon_area = layout[2]; + let filename_area = layout[4]; + let name_area = layout[6]; + let footer_area = layout[8]; + + if let Some(state) = &mut app.add_popup_state { + let labels: Vec = vec![ + String::from("Preset"), + String::from("Icon source (name, URL, SVG, or empty)"), + String::from("Filename (auto keeps extension)"), + String::from("Component name"), + ]; + + let field_theme = |active: bool| { + if active { + (crate::views::theme::INPUT_BG, crate::views::theme::ACCENT) + } else { + ( + crate::views::theme::INPUT_BG, + crate::views::theme::MUTED_TEXT, + ) + } + }; + + // Create a selectable list for preset + let mut state_store = ratatui::widgets::ListState::default(); + let mut items: Vec = { + let filtered: Vec<_> = state + .presets_filtered + .iter() + .map(|p| { + ListItem::new(format!( + // https://stackoverflow.com/questions/50458144/what-is-the-easiest-way-to-pad-a-string-with-0-to-the-left + "{:<8} - {}", + p.preset.to_str(), + p.description + )) + }) + .collect(); + filtered + }; + if items.is_empty() { + state_store.select(None); + items = vec![ + ListItem::new("No presets found") + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)), + ]; + } else { + state_store.select(Some(state.preset_index)) + }; + + let (preset_bg, preset_title) = field_theme(state.current_input == PRESET_FIELD_IDX); + let preset_filter_hint = if state.preset_filter.is_empty() { + String::new() + } else { + format!("filter: {}", state.preset_filter) + }; + let list = ratatui::widgets::List::new(items) + .block( + Block::default() + .title(labels[PRESET_FIELD_IDX].clone()) + .title( + Line::from(preset_filter_hint) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)) + .alignment(Alignment::Right), + ) + .title_style( + Style::default() + .fg(preset_title) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(preset_bg).fg(crate::views::theme::TEXT)), + ) + .highlight_symbol(" ") + .highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, preset_area, &mut state_store); + + let (icon_bg, icon_title) = field_theme(state.current_input == ICON_FIELD_IDX); + let icon_block = Block::default() + .title(labels[ICON_FIELD_IDX].clone()) + .title_style(Style::default().fg(icon_title).add_modifier(Modifier::BOLD)) + .style(Style::default().bg(icon_bg).fg(crate::views::theme::TEXT)); + state.inputs[ICON_FIELD_IDX].set_block(icon_block); + state.inputs[ICON_FIELD_IDX].set_cursor_line_style(Style::default()); + f.render_widget(&state.inputs[ICON_FIELD_IDX], icon_area); + + let (filename_bg, filename_title) = field_theme(state.current_input == FILENAME_FIELD_IDX); + let filename_block = Block::default() + .title(labels[FILENAME_FIELD_IDX].clone()) + .title_style( + Style::default() + .fg(filename_title) + .add_modifier(Modifier::BOLD), + ) + .title( + Line::from(crate::utils::filename_from_preset( + Some(state.inputs[FILENAME_FIELD_IDX].lines().join("")), + state.preset.clone(), + )) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)) + .alignment(Alignment::Right), + ) + .style( + Style::default() + .bg(filename_bg) + .fg(crate::views::theme::TEXT), + ); + state.inputs[FILENAME_FIELD_IDX].set_block(filename_block); + state.inputs[FILENAME_FIELD_IDX].set_cursor_line_style(Style::default()); + f.render_widget(&state.inputs[FILENAME_FIELD_IDX], filename_area); + + let name_value = state.inputs[NAME_FIELD_IDX].lines().join(""); + let (name_bg, name_title) = field_theme(state.current_input == NAME_FIELD_IDX); + let name_block = Block::default() + .title(format!("{}", labels[NAME_FIELD_IDX])) + .title_style(Style::default().fg(name_title).add_modifier(Modifier::BOLD)) + .title( + Line::from(if name_value.is_empty() { + String::from("usage: ") + } else { + format!("usage: ", name_value) + }) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)) + .alignment(Alignment::Right), + ) + .style(Style::default().bg(name_bg).fg(crate::views::theme::TEXT)); + state.inputs[NAME_FIELD_IDX].set_block(name_block); + state.inputs[NAME_FIELD_IDX].set_cursor_line_style(Style::default()); + f.render_widget(&state.inputs[NAME_FIELD_IDX], name_area); + + let footer = if let Some(message) = &state.status_message { + let color = if state.status_is_error { + crate::views::theme::ERROR + } else { + crate::views::theme::MUTED_TEXT + }; + Paragraph::new(message.clone()) + .alignment(Alignment::Left) + .style(Style::default().fg(color)) + } else { + Paragraph::new(crate::views::theme::shortcut_line(&[ + ("Next", "tab"), + ("Submit", "enter"), + ("Close", "esc"), + ("Paste", "cmd/ctrl+v"), + ])) + .alignment(Alignment::Left) + }; + f.render_widget(footer, footer_area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config(folder: String) -> crate::app_state::AppConfig { + crate::app_state::AppConfig { + folder, + preset: "normal".to_string(), + template: None, + svg_viewer_cmd: None, + svg_viewer_cmd_source: "test".to_string(), + global_config_loaded: false, + project_config_loaded: false, + } + } + + #[test] + fn q_types_into_add_popup_textarea_without_quitting() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let folder = temp_dir.path().join("icons"); + std::fs::create_dir_all(&folder).expect("icons folder should be created"); + + let mut app = App::new(test_config(folder.to_string_lossy().into_owned())); + app.init_add_popup(); + + let state = app + .add_popup_state + .as_mut() + .expect("add popup should be initialized"); + state.current_input = ICON_FIELD_IDX; + state.sync_cursor(ICON_FIELD_IDX); + + app.handlekeys_add_popup(Input { + key: Key::Char('q'), + ..Default::default() + }); + + let state = app + .add_popup_state + .as_ref() + .expect("add popup should remain open"); + assert_eq!(state.inputs[ICON_FIELD_IDX].lines().join(""), "q"); + assert!( + !app.should_quit, + "typing q in add popup should not quit app" + ); + } +} diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs new file mode 100644 index 0000000..894f715 --- /dev/null +++ b/src/views/delete_popup.rs @@ -0,0 +1,177 @@ +use crate::app_state::{App, AppFocus}; +use crate::utils::popup_area; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::Style; +use ratatui::widgets::Paragraph; +use tui_textarea::{Input, Key}; + +#[derive(Debug)] +pub struct DeletePopupState { + pub selected_index: usize, // For yes or no only + + pub item_to_delete: Option, +} + +impl App { + pub fn init_delete_popup(&mut self) { + self.app_focus = AppFocus::DeletePopup; + + if let Some(item_to_delete) = self.filtered_items.get(self.selected_index) { + self.delete_popup_state = Some(DeletePopupState { + selected_index: 0, + item_to_delete: Some(item_to_delete.clone()), + }); + } else { + self.delete_popup_state = Some(DeletePopupState { + selected_index: 0, + item_to_delete: None, + }); + } + } + + fn close_delete_popup(&mut self) { + self.app_focus = AppFocus::Main; + self.delete_popup_state = None; + } + + fn perform_delete_action(&mut self) { + // Remove the item from the items vector + if let Some(state) = &self.delete_popup_state { + if let Some(item) = &state.item_to_delete { + if let Some(pos) = self.items.iter().position(|i| i.name == item.name) { + self.items.remove(pos); + } + + // Persist the change to disk + let abs_file_path = std::path::Path::new(&self.config.folder).join(&item.file_path); + if let Err(e) = + crate::utils::delete_icon_entry(abs_file_path.to_str().unwrap_or("")) + { + eprintln!("Failed to delete icon file: {}", e); + } + } + } + + // Re-initialize icons from disk to ensure consistency + self.init_icons(); + } + + pub fn handlekeys_delete_popup(&mut self, input: Input) { + let Some(state) = self.delete_popup_state.as_mut() else { + return; + }; + + match input.key { + Key::Char('y') => { + // Perform delete action + self.perform_delete_action(); + self.close_delete_popup(); + } + Key::Char('n') | Key::Esc => { + // Cancel + self.close_delete_popup(); + } + Key::Enter => { + if state.selected_index == 0 { + // Perform delete action + self.perform_delete_action(); + self.close_delete_popup(); + } else { + // Cancel if "n" is selected + self.close_delete_popup(); + } + } + Key::Left | Key::Char('h') | Key::Up | Key::Char('k') => { + state.selected_index = state.selected_index.saturating_sub(1); + } + Key::Right | Key::Char('l') | Key::Down | Key::Char('j') => { + state.selected_index = (state.selected_index + 1).min(1); + } + _ => {} + } + } +} + +pub fn render_delete_popup(f: &mut Frame, app: &mut App) { + use ratatui::{ + style::Modifier, + text::{Line, Span}, + }; + + let area = popup_area(f.area(), 58, 10); + let body_area = crate::views::theme::render_popup_shell(f, area, "Delete Icon"); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(body_area); + + if let Some(state) = &mut app.delete_popup_state { + let icon_name = state + .item_to_delete + .as_ref() + .map(|item| item.name.as_str()) + .unwrap_or("this icon"); + let prompt = Paragraph::new(format!("Delete '{icon_name}'?")) + .alignment(Alignment::Left) + .style( + Style::default() + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(prompt, layout[0]); + + let action_line = if state.selected_index == 0 { + Line::from(vec![ + Span::styled( + " Delete ", + Style::default() + .bg(crate::views::theme::ERROR) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("y", Style::default().fg(crate::views::theme::MUTED_TEXT)), + Span::raw(" "), + Span::styled( + "Cancel", + Style::default() + .fg(crate::views::theme::SUBTLE_TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("n", Style::default().fg(crate::views::theme::MUTED_TEXT)), + ]) + } else { + Line::from(vec![ + Span::styled( + "Delete", + Style::default() + .fg(crate::views::theme::ACCENT_SOFT) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("y", Style::default().fg(crate::views::theme::MUTED_TEXT)), + Span::raw(" "), + Span::styled( + " Cancel ", + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("n", Style::default().fg(crate::views::theme::MUTED_TEXT)), + ]) + }; + f.render_widget( + Paragraph::new(action_line).alignment(Alignment::Left), + layout[2], + ); + } +} diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs new file mode 100644 index 0000000..843ccc1 --- /dev/null +++ b/src/views/help_popup.rs @@ -0,0 +1,96 @@ +use crate::app_state::{App, AppFocus}; +use crate::utils::popup_area; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use tui_textarea::{Input, Key}; + +impl App { + pub fn init_help_popup(&mut self) { + self.app_focus = AppFocus::HelpPopup; + } + + pub fn handlekeys_help_popup(&mut self, input: Input) { + match input.key { + Key::Esc | Key::Char('?') => { + self.app_focus = AppFocus::Main; + } + _ => {} + } + } +} + +pub fn render_help_popup(f: &mut Frame, app: &App) { + use ratatui::style::Modifier; + + let area = popup_area(f.area(), 76, 15); + let body_area = crate::views::theme::render_popup_shell(f, area, "Help"); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(4), + ]) + .split(body_area); + + let config_lines = vec![ + Line::from(vec![ + Span::styled( + if app.config.global_config_loaded { + "โ— " + } else { + "โ—‹ " + }, + Style::default().fg(if app.config.global_config_loaded { + crate::views::theme::ACCENT + } else { + crate::views::theme::SUBTLE_TEXT + }), + ), + Span::styled( + "Global config", + Style::default() + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled( + if app.config.project_config_loaded { + "โ— " + } else { + "โ—‹ " + }, + Style::default().fg(if app.config.project_config_loaded { + crate::views::theme::ACCENT + } else { + crate::views::theme::SUBTLE_TEXT + }), + ), + Span::styled( + "Local config", + Style::default() + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]), + ]; + let config_status = Paragraph::new(config_lines).alignment(Alignment::Left); + f.render_widget(config_status, layout[0]); + + let svg_viewer_cmd = app.config.svg_viewer_cmd.as_deref().unwrap_or("Not set"); + let status = Paragraph::new(format!( + "Folder: {}\nPreset: {}\nSVG viewer cmd: {}\nViewer cmd source: {}", + app.config.folder, + app.config.preset.as_str(), + svg_viewer_cmd, + app.config.svg_viewer_cmd_source + )) + .alignment(Alignment::Left) + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)); + f.render_widget(status, layout[2]); +} diff --git a/src/views/iconify_search_popup.rs b/src/views/iconify_search_popup.rs new file mode 100644 index 0000000..7c35dbf --- /dev/null +++ b/src/views/iconify_search_popup.rs @@ -0,0 +1,1417 @@ +use std::{ + borrow::Cow, + time::{Duration, Instant}, +}; + +use nucleo_matcher::{ + Config, Matcher, + pattern::{CaseMatching, Normalization, Pattern}, +}; + +use ratatui::{ + Frame, + layout::{Alignment, Constraint}, + style::Style, + widgets::{Block, List, ListItem, Paragraph}, +}; +use tui_textarea::{Input, Key, TextArea}; + +use crate::{ + app_state::{App, AppEvent, AppFocus, IconifyCollectionListItem, IconifySearchPayload}, + iconify::IconifyClient, + utils::popup_area, +}; + +const SEARCH_DEBOUNCE_MS: u64 = 280; +const SEARCH_LIMIT: u32 = 80; + +#[derive(Debug, Clone)] +struct FuzzyCandidate<'a> { + index: usize, + haystack: Cow<'a, str>, +} + +impl AsRef for FuzzyCandidate<'_> { + fn as_ref(&self) -> &str { + &self.haystack + } +} + +fn fuzzy_rank_indices(query: &str, candidates: Vec>) -> Vec { + if query.trim().is_empty() { + return candidates + .into_iter() + .map(|candidate| candidate.index) + .collect(); + } + + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + let mut matcher = Matcher::new(Config::DEFAULT); + let mut matched = pattern.match_list(candidates, &mut matcher); + matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.index.cmp(&b.0.index))); + + matched + .into_iter() + .map(|(candidate, _)| candidate.index) + .collect() +} + +fn fuzzy_filter_collections( + collections: &[IconifyCollectionListItem], + query: &str, +) -> Vec { + let candidates = collections + .iter() + .enumerate() + .map(|(index, item)| FuzzyCandidate { + index, + haystack: Cow::Owned(format!("{} {}", item.prefix, item.name)), + }) + .collect::>(); + + fuzzy_rank_indices(query, candidates) + .into_iter() + .map(|index| collections[index].clone()) + .collect() +} + +fn fuzzy_filter_icons(icons: &[String], query: &str) -> Vec { + let candidates = icons + .iter() + .enumerate() + .map(|(index, icon)| FuzzyCandidate { + index, + haystack: Cow::Borrowed(icon.as_str()), + }) + .collect::>(); + + fuzzy_rank_indices(query, candidates) + .into_iter() + .map(|index| icons[index].clone()) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconifySearchTab { + Collections, + Icons, +} + +#[derive(Debug)] +pub struct IconifySearchPopupState { + pub search_textarea: TextArea<'static>, + pub search_value: String, + pub active_tab: IconifySearchTab, + + pub selected_collection_index: usize, + pub selected_icon_index: usize, + + pub all_collections: Vec, + pub filtered_collections: Vec, + pub search_icons: Vec, + pub collection_icons: Vec, + pub collection_icons_prefix: Option, + pub visible_icons: Vec, + pub selected_collection_filter: Option, + + pub pending_search_query: Option, + pub debounce_deadline: Option, + + pub latest_collections_request_id: u64, + pub latest_search_request_id: u64, + pub latest_collection_icons_request_id: u64, + + pub is_loading_collections: bool, + pub is_loading_search: bool, + pub is_loading_collection_icons: bool, + + pub status_message: Option, + pub status_is_error: bool, +} + +impl IconifySearchPopupState { + pub fn new() -> Self { + Self { + search_textarea: TextArea::default(), + search_value: String::new(), + active_tab: IconifySearchTab::Collections, + selected_collection_index: 0, + selected_icon_index: 0, + all_collections: Vec::new(), + filtered_collections: Vec::new(), + search_icons: Vec::new(), + collection_icons: Vec::new(), + collection_icons_prefix: None, + visible_icons: Vec::new(), + selected_collection_filter: None, + pending_search_query: None, + debounce_deadline: None, + latest_collections_request_id: 0, + latest_search_request_id: 0, + latest_collection_icons_request_id: 0, + is_loading_collections: false, + is_loading_search: false, + is_loading_collection_icons: false, + status_message: None, + status_is_error: false, + } + } + + fn active_collections(&self) -> &[IconifyCollectionListItem] { + if self.search_value.trim().is_empty() { + &self.all_collections + } else { + &self.filtered_collections + } + } + + fn refresh_filtered_collections(&mut self) { + let query = self.search_value.trim(); + if query.is_empty() { + self.filtered_collections.clear(); + return; + } + + self.filtered_collections = fuzzy_filter_collections(&self.all_collections, query); + } + + fn sync_search_dispatch_state(&mut self) { + self.pending_search_query = None; + self.debounce_deadline = None; + + let query = self.search_value.trim().to_string(); + if query.is_empty() + || self.active_tab != IconifySearchTab::Icons + || self.selected_collection_filter.is_some() + { + self.is_loading_search = false; + return; + } + + self.pending_search_query = Some(query); + self.debounce_deadline = Some(Instant::now() + Duration::from_millis(SEARCH_DEBOUNCE_MS)); + self.is_loading_search = true; + } + + fn clear_search_input(&mut self) { + self.search_textarea = TextArea::default(); + self.search_value.clear(); + self.filtered_collections.clear(); + self.search_icons.clear(); + self.selected_icon_index = 0; + self.sync_search_dispatch_state(); + self.clear_status(); + } + + fn selected_collection_prefix(&self) -> Option { + self.active_collections() + .get(self.selected_collection_index) + .map(|item| item.prefix.clone()) + } + + fn selected_icon_name(&self) -> Option { + self.visible_icons.get(self.selected_icon_index).cloned() + } + + fn set_status(&mut self, message: String, is_error: bool) { + self.status_message = Some(message); + self.status_is_error = is_error; + } + + fn clear_status(&mut self) { + self.status_message = None; + self.status_is_error = false; + } + + fn clamp_collection_selection(&mut self) { + let len = self.active_collections().len(); + if len == 0 { + self.selected_collection_index = 0; + } else if self.selected_collection_index >= len { + self.selected_collection_index = len.saturating_sub(1); + } + } + + fn clamp_icon_selection(&mut self) { + let len = self.visible_icons.len(); + if len == 0 { + self.selected_icon_index = 0; + } else if self.selected_icon_index >= len { + self.selected_icon_index = len.saturating_sub(1); + } + } + + fn update_search_value(&mut self) { + self.search_value = self.search_textarea.lines().join(""); + + self.clear_status(); + self.selected_collection_index = 0; + self.selected_icon_index = 0; + + self.refresh_filtered_collections(); + + let query = self.search_value.trim().to_string(); + if query.is_empty() { + self.filtered_collections.clear(); + self.search_icons.clear(); + self.refresh_visible_icons(); + self.sync_search_dispatch_state(); + return; + } + + self.search_icons.clear(); + self.refresh_visible_icons(); + self.sync_search_dispatch_state(); + } + + fn refresh_visible_icons(&mut self) { + if let Some(prefix) = &self.selected_collection_filter { + if self.collection_icons_prefix.as_deref() != Some(prefix.as_str()) { + self.visible_icons.clear(); + self.clamp_icon_selection(); + return; + } + + let query = self.search_value.trim(); + if query.is_empty() { + self.visible_icons = self.collection_icons.clone(); + } else { + self.visible_icons = fuzzy_filter_icons(&self.collection_icons, query); + } + } else { + self.visible_icons = self.search_icons.clone(); + } + + self.clamp_icon_selection(); + } + + fn move_collection_selection(&mut self, delta: i32) { + let len = self.active_collections().len(); + if len == 0 { + self.selected_collection_index = 0; + return; + } + + let next = (self.selected_collection_index as i32 + delta).rem_euclid(len as i32) as usize; + self.selected_collection_index = next; + } + + fn move_icon_selection(&mut self, delta: i32) { + let len = self.visible_icons.len(); + if len == 0 { + self.selected_icon_index = 0; + return; + } + + let next = (self.selected_icon_index as i32 + delta).rem_euclid(len as i32) as usize; + self.selected_icon_index = next; + } +} + +enum PopupAction { + None, + Close, + OpenCollection(String), + FillAddPopup(String), + OpenIconInBrowser(String), +} + +impl App { + fn next_request_id(&mut self) -> u64 { + self.next_async_request_id = self.next_async_request_id.saturating_add(1); + self.next_async_request_id + } + + pub fn init_iconify_search_popup(&mut self) { + self.app_focus = AppFocus::IconifySearchPopup; + self.iconify_search_popup_state = Some(IconifySearchPopupState::new()); + self.request_iconify_collections(); + } + + fn close_iconify_search_popup(&mut self) { + self.app_focus = AppFocus::Main; + self.iconify_search_popup_state = None; + } + + pub fn handlekeys_iconify_search_popup(&mut self, input: Input) { + let mut action = PopupAction::None; + + if let Some(state) = self.iconify_search_popup_state.as_mut() { + match input.key { + Key::Esc => action = PopupAction::Close, + Key::Tab => { + match state.active_tab { + IconifySearchTab::Collections => { + state.active_tab = IconifySearchTab::Icons; + } + IconifySearchTab::Icons => { + state.selected_collection_filter = None; + state.clear_search_input(); + state.active_tab = IconifySearchTab::Collections; + state.is_loading_collection_icons = false; + state.refresh_visible_icons(); + } + } + state.sync_search_dispatch_state(); + } + Key::Up => match state.active_tab { + IconifySearchTab::Collections => state.move_collection_selection(-1), + IconifySearchTab::Icons => state.move_icon_selection(-1), + }, + Key::Down => match state.active_tab { + IconifySearchTab::Collections => state.move_collection_selection(1), + IconifySearchTab::Icons => state.move_icon_selection(1), + }, + Key::Enter => match state.active_tab { + IconifySearchTab::Collections => { + if let Some(prefix) = state.selected_collection_prefix() { + state.clear_search_input(); + action = PopupAction::OpenCollection(prefix); + } + } + IconifySearchTab::Icons => { + if let Some(icon_name) = state.selected_icon_name() { + action = PopupAction::FillAddPopup(icon_name); + } else { + state.set_status("No icon selected.".to_string(), true); + } + } + }, + Key::Char('o') if input.ctrl => { + if state.active_tab == IconifySearchTab::Icons { + if let Some(icon_name) = state.selected_icon_name() { + action = PopupAction::OpenIconInBrowser(icon_name); + } else { + state.set_status("No icon selected.".to_string(), true); + } + } + } + _ => { + state.search_textarea.input(input); + state.update_search_value(); + } + } + } + + match action { + PopupAction::None => {} + PopupAction::Close => self.close_iconify_search_popup(), + PopupAction::OpenCollection(prefix) => self.open_collection_icons(prefix), + PopupAction::FillAddPopup(icon_name) => { + self.close_iconify_search_popup(); + self.init_add_popup_with_icon_source(&icon_name); + } + PopupAction::OpenIconInBrowser(icon_name) => { + self.open_icon_browser_preview(icon_name); + } + } + } + + pub fn tick_iconify_search_popup(&mut self) { + let query_to_dispatch = self.iconify_search_popup_state.as_ref().and_then(|state| { + if state.active_tab != IconifySearchTab::Icons + || state.selected_collection_filter.is_some() + { + return None; + } + + let deadline = state.debounce_deadline?; + if Instant::now() >= deadline { + state.pending_search_query.clone() + } else { + None + } + }); + + if let Some(query) = query_to_dispatch { + self.dispatch_iconify_search(query); + } + } + + pub fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::IconifyCollectionsLoaded { request_id, result } => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + if request_id != state.latest_collections_request_id { + return; + } + + state.is_loading_collections = false; + + match result { + Ok(items) => { + state.all_collections = items; + state.refresh_filtered_collections(); + state.clamp_collection_selection(); + if state.all_collections.is_empty() { + state.set_status("No Iconify collections found.".to_string(), true); + } else if !state.is_loading_search { + state.clear_status(); + } + } + Err(error) => state.set_status(error, true), + } + } + } + AppEvent::IconifySearchLoaded { + request_id, + query, + result, + } => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + if request_id != state.latest_search_request_id { + return; + } + + if query != state.search_value.trim() { + return; + } + + if state.active_tab != IconifySearchTab::Icons + || state.selected_collection_filter.is_some() + { + state.is_loading_search = false; + return; + } + + state.is_loading_search = false; + + match result { + Ok(payload) => { + state.search_icons = payload.icons; + state.clamp_collection_selection(); + state.refresh_visible_icons(); + + if state.search_icons.is_empty() + && state.active_collections().is_empty() + { + state.set_status( + "No matching icons or collections.".to_string(), + false, + ); + } else { + state.clear_status(); + } + } + Err(error) => { + state.search_icons.clear(); + state.refresh_visible_icons(); + state.set_status(error, true); + } + } + } + } + AppEvent::IconifyCollectionIconsLoaded { + request_id, + prefix, + result, + } => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + if request_id != state.latest_collection_icons_request_id { + return; + } + + state.is_loading_collection_icons = false; + + match result { + Ok(icons) => { + state.collection_icons_prefix = Some(prefix); + state.collection_icons = icons; + state.refresh_visible_icons(); + + if state.collection_icons.is_empty() { + state.set_status("No icons in this collection.".to_string(), false); + } else if !state.search_value.trim().is_empty() + && state.visible_icons.is_empty() + { + state.set_status( + "No matching icons in this collection.".to_string(), + false, + ); + } else { + state.clear_status(); + } + } + Err(error) => { + state.collection_icons.clear(); + state.collection_icons_prefix = None; + state.refresh_visible_icons(); + state.set_status(error, true); + } + } + } + } + } + } + + fn request_iconify_collections(&mut self) { + let request_id = self.next_request_id(); + + let Some(state) = self.iconify_search_popup_state.as_mut() else { + return; + }; + + state.latest_collections_request_id = request_id; + state.is_loading_collections = true; + state.set_status("Loading collections...".to_string(), false); + + let tx = self.tx.clone(); + tokio::spawn(async move { + let result = async { + let client = IconifyClient::from_env().map_err(|error| error.to_string())?; + let response = client + .collections() + .await + .map_err(|error| error.to_string())?; + + let mut collections: Vec = response + .collections + .into_iter() + .map(|(prefix, meta)| IconifyCollectionListItem { + name: meta.display_name(&prefix), + total: meta.total, + prefix, + }) + .collect(); + + collections.sort_by(|a, b| a.prefix.cmp(&b.prefix)); + Ok::, String>(collections) + } + .await; + + let _ = tx.send(AppEvent::IconifyCollectionsLoaded { request_id, result }); + }); + } + + fn dispatch_iconify_search(&mut self, query: String) { + let request_id = self.next_request_id(); + + let Some(state) = self.iconify_search_popup_state.as_mut() else { + return; + }; + + if state.active_tab != IconifySearchTab::Icons || state.selected_collection_filter.is_some() + { + state.pending_search_query = None; + state.debounce_deadline = None; + state.is_loading_search = false; + return; + } + + state.pending_search_query = None; + state.debounce_deadline = None; + state.latest_search_request_id = request_id; + state.is_loading_search = true; + + let tx = self.tx.clone(); + tokio::spawn(async move { + let result = async { + let client = IconifyClient::from_env().map_err(|error| error.to_string())?; + let response = client + .search(&query, Some(SEARCH_LIMIT), None, false) + .await + .map_err(|error| error.to_string())?; + + Ok::(IconifySearchPayload { + icons: response.icons, + }) + } + .await; + + let _ = tx.send(AppEvent::IconifySearchLoaded { + request_id, + query, + result, + }); + }); + } + + fn open_collection_icons(&mut self, prefix: String) { + let mut should_fetch = false; + + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.active_tab = IconifySearchTab::Icons; + state.selected_collection_filter = Some(prefix.clone()); + state.selected_icon_index = 0; + state.refresh_visible_icons(); + state.sync_search_dispatch_state(); + + should_fetch = state.collection_icons_prefix.as_deref() != Some(prefix.as_str()); + if should_fetch { + state.collection_icons.clear(); + state.refresh_visible_icons(); + } + } + + if !should_fetch { + return; + } + + let request_id = self.next_request_id(); + + let Some(state) = self.iconify_search_popup_state.as_mut() else { + return; + }; + + state.latest_collection_icons_request_id = request_id; + state.is_loading_collection_icons = true; + state.set_status(format!("Loading icons for collection '{prefix}'..."), false); + + let tx = self.tx.clone(); + tokio::spawn(async move { + let result = async { + let client = IconifyClient::from_env().map_err(|error| error.to_string())?; + let response = client + .collection(&prefix) + .await + .map_err(|error| error.to_string())?; + + let icons = response + .icons + .into_iter() + .map(|icon| format!("{}:{icon}", response.prefix)) + .collect::>(); + + Ok::, String>(icons) + } + .await; + + let _ = tx.send(AppEvent::IconifyCollectionIconsLoaded { + request_id, + prefix, + result, + }); + }); + } + + fn open_icon_browser_preview(&mut self, icon_name: String) { + let Some(url) = icones_collection_url(&icon_name) else { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status( + format!("Cannot open Icones page for invalid icon name '{icon_name}'."), + true, + ); + } + return; + }; + + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status("Opening icon in browser...".to_string(), false); + } + + match crate::viewer::open_url_in_browser(&url) { + Ok(()) => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status(format!("Opened Icones page: {url}"), false); + } + } + Err(error) => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status(format!("Failed to open browser: {error}"), true); + } + } + } + } +} + +fn icones_collection_url(icon_name: &str) -> Option { + let (prefix, _) = icon_name.split_once(':')?; + Some(format!( + "https://icones.js.org/collection/{prefix}?icon={icon_name}" + )) +} + +pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { + use ratatui::{ + style::Modifier, + text::{Line, Span}, + }; + + let area = popup_area(f.area(), 92, 23); + let body_area = crate::views::theme::render_popup_shell(f, area, "Iconify Search"); + + let inner = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(body_area); + + let Some(state) = app.iconify_search_popup_state.as_mut() else { + return; + }; + + let search_block = Block::default() + .title("Search") + .title_style( + Style::default() + .fg(crate::views::theme::MUTED_TEXT) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().fg(crate::views::theme::TEXT)); + state.search_textarea.set_block(search_block); + state.search_textarea.set_cursor_style( + Style::default() + .bg(crate::views::theme::ACCENT) + .fg(crate::views::theme::BASE_BG), + ); + state + .search_textarea + .set_cursor_line_style(Style::default()); + f.render_widget(&state.search_textarea, inner[0]); + + let collections_style = if state.active_tab == IconifySearchTab::Collections { + Style::default() + .bg(crate::views::theme::TAB_BG_ACTIVE) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(crate::views::theme::TAB_BG) + .fg(crate::views::theme::MUTED_TEXT) + }; + let icons_style = if state.active_tab == IconifySearchTab::Icons { + Style::default() + .bg(crate::views::theme::TAB_BG_ACTIVE) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(crate::views::theme::TAB_BG) + .fg(crate::views::theme::MUTED_TEXT) + }; + + let mut tabs_spans = vec![ + Span::styled(" Collections ", collections_style), + Span::styled(" | ", Style::default().fg(crate::views::theme::SUBTLE_TEXT)), + Span::styled(" Icons ", icons_style), + ]; + if let Some(prefix) = &state.selected_collection_filter { + tabs_spans.push(Span::raw(" ")); + tabs_spans.push(Span::styled( + format!("collection: {prefix}"), + Style::default().fg(crate::views::theme::SUBTLE_TEXT), + )); + } + + let tabs = Paragraph::new(Line::from(tabs_spans)).alignment(Alignment::Left); + f.render_widget(tabs, inner[1]); + + match state.active_tab { + IconifySearchTab::Collections => { + let collection_items = state.active_collections(); + let items: Vec = if collection_items.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No collections", + Style::default().fg(crate::views::theme::SUBTLE_TEXT), + )))] + } else { + collection_items + .iter() + .map(|item| { + let total_label = match item.total { + Some(total) => format!("{total}"), + None => "-".to_string(), + }; + let line = Line::from(vec![ + Span::styled( + format!("{: <10}", item.prefix), + Style::default() + .fg(crate::views::theme::ACCENT_SOFT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {total_label: <6}"), + Style::default().fg(crate::views::theme::MUTED_TEXT), + ), + Span::styled( + item.name.clone(), + Style::default().fg(crate::views::theme::TEXT), + ), + ]); + ListItem::new(line) + }) + .collect() + }; + + let mut list_state = ratatui::widgets::ListState::default(); + if !collection_items.is_empty() { + list_state.select(Some(state.selected_collection_index)); + } + + let list = List::new(items) + .block( + Block::default() + .title("Collections") + .title_style( + Style::default() + .fg(crate::views::theme::MUTED_TEXT) + .add_modifier(Modifier::BOLD), + ) + .style( + Style::default() + .bg(crate::views::theme::PANEL_BG) + .fg(crate::views::theme::TEXT), + ), + ) + .highlight_symbol(" ") + .highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, inner[3], &mut list_state); + } + IconifySearchTab::Icons => { + let items: Vec = if state.visible_icons.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No icons", + Style::default().fg(crate::views::theme::SUBTLE_TEXT), + )))] + } else { + state + .visible_icons + .iter() + .map(|icon| { + ListItem::new(Line::from(Span::styled( + icon.clone(), + Style::default().fg(crate::views::theme::TEXT), + ))) + }) + .collect() + }; + + let mut list_state = ratatui::widgets::ListState::default(); + if !state.visible_icons.is_empty() { + list_state.select(Some(state.selected_icon_index)); + } + + let title = if let Some(filter_prefix) = &state.selected_collection_filter { + format!("Icons (collection: {filter_prefix})") + } else { + "Icons".to_string() + }; + + let list = List::new(items) + .block( + Block::default() + .title(title) + .title_style( + Style::default() + .fg(crate::views::theme::MUTED_TEXT) + .add_modifier(Modifier::BOLD), + ) + .style( + Style::default() + .bg(crate::views::theme::PANEL_BG) + .fg(crate::views::theme::TEXT), + ), + ) + .highlight_symbol(" ") + .highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, inner[3], &mut list_state); + } + } + + let loading_message = if state.is_loading_collection_icons { + Some("Loading collection icons...") + } else if state.is_loading_search { + Some("Searching Iconify...") + } else if state.is_loading_collections { + Some("Loading collections...") + } else { + None + }; + + let status_message = loading_message + .map(std::string::ToString::to_string) + .or_else(|| state.status_message.clone()) + .unwrap_or_default(); + let status_color = if state.status_is_error { + crate::views::theme::ERROR + } else { + crate::views::theme::MUTED_TEXT + }; + let status = Paragraph::new(status_message) + .alignment(Alignment::Left) + .style(Style::default().fg(status_color)); + f.render_widget(status, inner[4]); + + let help_line = if state.active_tab == IconifySearchTab::Collections { + crate::views::theme::shortcut_line(&[ + ("Open", "enter"), + ("Switch", "tab"), + ("Move", "up/down"), + ("Close", "esc"), + ]) + } else { + crate::views::theme::shortcut_line(&[ + ("Use icon", "enter"), + ("Preview", "ctrl+o"), + ("Move", "up/down"), + ("Switch", "tab"), + ("Close", "esc"), + ]) + }; + let help = Paragraph::new(help_line).alignment(Alignment::Left); + f.render_widget(help, inner[5]); +} + +#[cfg(any())] +mod tests { + use super::{ + IconifySearchPopupState, IconifySearchTab, fuzzy_filter_collections, fuzzy_filter_icons, + icones_collection_url, + }; + use crate::app_state::{App, AppConfig, AppFocus, IconifyCollectionListItem}; + use tempfile::TempDir; + use tui_textarea::{Input, Key}; + + fn test_app() -> App { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let folder = temp_dir.path().join("icons"); + + let config = AppConfig { + folder: folder.to_string_lossy().into_owned(), + preset: "normal".to_string(), + template: None, + svg_viewer_cmd: None, + svg_viewer_cmd_source: "test".to_string(), + global_config_loaded: false, + project_config_loaded: false, + }; + + App::new(config) + } + + #[test] + fn builds_icones_collection_url_for_iconify_name() { + assert_eq!( + icones_collection_url("mdi:home"), + Some("https://icones.js.org/collection/mdi".to_string()) + ); + } + + #[test] + fn returns_none_for_invalid_icon_name() { + assert_eq!(icones_collection_url("mdi"), None); + assert_eq!(icones_collection_url(""), None); + } + + #[test] + fn fuzzy_collections_support_non_substring_queries() { + let items = vec![ + IconifyCollectionListItem { + prefix: "lucide".to_string(), + name: "Lucide".to_string(), + total: Some(100), + }, + IconifyCollectionListItem { + prefix: "mdi".to_string(), + name: "Material Design Icons".to_string(), + total: Some(200), + }, + ]; + + let filtered = fuzzy_filter_collections(&items, "lcd"); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].prefix, "lucide"); + } + + #[test] + fn fuzzy_icon_filter_supports_non_substring_queries() { + let icons = vec![ + "arrow-left".to_string(), + "arrow-right".to_string(), + "alert-circle".to_string(), + ]; + + let filtered = fuzzy_filter_icons(&icons, "arwlf"); + + assert_eq!(filtered, vec!["arrow-left".to_string()]); + } + + #[test] + fn j_and_k_type_into_search_input() { + let mut app = test_app(); + app.init_iconify_search_popup(); + + let state = app + .iconify_search_popup_state + .as_mut() + .expect("iconify popup should initialize"); + state.active_tab = IconifySearchTab::Icons; + state.visible_icons = vec!["arrow-left".to_string(), "arrow-right".to_string()]; + state.selected_icon_index = 1; + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('j'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup should remain open"); + assert_eq!(state.search_textarea.lines().join(""), "j"); + assert_eq!(state.selected_icon_index, 1); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('k'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup should remain open"); + assert_eq!(state.search_textarea.lines().join(""), "jk"); + assert_eq!(state.selected_icon_index, 1); + } + + #[test] + fn collection_icon_search_is_local_and_does_not_queue_remote_search() { + let mut app = test_app(); + app.init_iconify_search_popup(); + + let state = app + .iconify_search_popup_state + .as_mut() + .expect("iconify popup should initialize"); + state.active_tab = IconifySearchTab::Icons; + state.selected_collection_filter = Some("mdi".to_string()); + state.collection_icons = vec![ + "home-outline".to_string(), + "account-box".to_string(), + "heart".to_string(), + ]; + state.visible_icons = state.collection_icons.clone(); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('h'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup should remain open"); + assert_eq!(state.search_query, "h"); + assert_eq!(state.search_textarea.lines().join(""), "h"); + assert!(!state.is_loading_search); + assert_eq!(state.last_search_query, ""); + assert!(state.visible_icons.contains(&"home-outline".to_string())); + } + + #[test] + fn global_icon_search_keeps_remote_query_flow() { + let mut app = test_app(); + app.init_iconify_search_popup(); + + let state = app + .iconify_search_popup_state + .as_mut() + .expect("iconify popup should initialize"); + state.active_tab = IconifySearchTab::Icons; + state.selected_collection_filter = None; + state.visible_icons.clear(); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('m'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup should remain open"); + assert_eq!(state.search_query, "m"); + assert!(state.is_loading_search); + assert_eq!(state.last_search_query, "m"); + } + + #[test] + fn iconify_search_escape_closes_popup() { + let mut app = test_app(); + app.init_iconify_search_popup(); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Esc, + ..Default::default() + }); + + assert!(app.iconify_search_popup_state.is_none()); + assert!(matches!(app.app_focus, AppFocus::Main)); + } +} +/* + .fg(crate::views::theme::ACCENT) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, inner[3], &mut list_state); + } + IconifySearchTab::Icons => { + let items: Vec = if state.visible_icons.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No icons", + Style::default().fg(crate::views::theme::SUBTLE_TEXT), + )))] + } else { + state + .visible_icons + .iter() + .map(|icon| { + ListItem::new(Line::from(Span::styled( + icon.clone(), + Style::default().fg(crate::views::theme::TEXT), + ))) + }) + .collect() + }; + + let mut list_state = ratatui::widgets::ListState::default(); + if !state.visible_icons.is_empty() { + list_state.select(Some(state.selected_icon_index)); + } + + let title = if let Some(filter_prefix) = &state.selected_collection_filter { + format!("Icons (collection: {filter_prefix})") + } else { + "Icons".to_string() + }; + + let list = List::new(items) + .block( + Block::default() + .title(title) + .title_style( + Style::default() + .fg(crate::views::theme::MUTED_TEXT) + .add_modifier(Modifier::BOLD), + ) + .style( + Style::default() + .bg(crate::views::theme::PANEL_BG) + .fg(crate::views::theme::TEXT), + ), + ) + .highlight_symbol(" ") + .highlight_style( + Style::default() + .fg(crate::views::theme::ACCENT) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, inner[3], &mut list_state); + } + } + + let loading_message = if state.is_loading_collection_icons { + Some("Loading collection icons...") + } else if state.is_loading_search { + Some("Searching Iconify...") + } else if state.is_loading_collections { + Some("Loading collections...") + } else { + None + }; + + let status_message = loading_message + .map(std::string::ToString::to_string) + .or_else(|| state.status_message.clone()) + .unwrap_or_default(); + let status_color = if state.status_is_error { + crate::views::theme::ERROR + } else { + crate::views::theme::MUTED_TEXT + }; + let status = Paragraph::new(status_message) + .alignment(Alignment::Left) + .style(Style::default().fg(status_color)); + f.render_widget(status, inner[4]); + + let help_line = if state.active_tab == IconifySearchTab::Collections { + crate::views::theme::shortcut_line(&[ + ("Open", "enter"), + ("Switch", "tab"), + ("Move", "up/down"), + ("Close", "esc"), + ]) + } else { + crate::views::theme::shortcut_line(&[ + ("Use icon", "enter"), + ("Preview", "ctrl+o"), + ("Move", "up/down"), + ("Switch", "tab"), + ("Close", "esc"), + ]) + }; + let help = Paragraph::new(help_line).alignment(Alignment::Left); + f.render_widget(help, inner[5]); +} + +*/ + +#[cfg(test)] +mod tests { + use super::{ + IconifySearchPopupState, IconifySearchTab, fuzzy_filter_collections, fuzzy_filter_icons, + icones_collection_url, + }; + use crate::app_state::{App, AppConfig, AppFocus, IconifyCollectionListItem}; + use tempfile::TempDir; + use tui_textarea::{Input, Key}; + + fn test_app() -> App { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let folder = temp_dir.path().join("icons"); + + let config = AppConfig { + folder: folder.to_string_lossy().into_owned(), + preset: "normal".to_string(), + template: None, + svg_viewer_cmd: None, + svg_viewer_cmd_source: "test".to_string(), + global_config_loaded: false, + project_config_loaded: false, + }; + + App::new(config) + } + + #[test] + fn builds_icones_collection_url_for_iconify_name() { + assert_eq!( + icones_collection_url("lucide:bean"), + Some("https://icones.js.org/collection/lucide?icon=lucide:bean".to_string()) + ); + } + + #[test] + fn returns_none_for_invalid_icon_name() { + assert_eq!(icones_collection_url("bean"), None); + } + + #[test] + fn fuzzy_collections_support_non_substring_queries() { + let collections = vec![ + IconifyCollectionListItem { + prefix: "lucide".to_string(), + name: "Lucide Icons".to_string(), + total: Some(100), + }, + IconifyCollectionListItem { + prefix: "mdi".to_string(), + name: "Material Design Icons".to_string(), + total: Some(100), + }, + ]; + + let filtered = fuzzy_filter_collections(&collections, "lcd"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].prefix, "lucide"); + } + + #[test] + fn fuzzy_icon_filter_supports_non_substring_queries() { + let icons = vec![ + "lucide:bean".to_string(), + "lucide:beaker".to_string(), + "lucide:home".to_string(), + ]; + + let filtered = fuzzy_filter_icons(&icons, "bn"); + assert_eq!(filtered, vec!["lucide:bean".to_string()]); + } + + #[test] + fn collection_icon_search_is_local_and_does_not_queue_remote_search() { + let mut app = test_app(); + app.app_focus = AppFocus::IconifySearchPopup; + + let mut state = IconifySearchPopupState::new(); + state.active_tab = IconifySearchTab::Icons; + state.selected_collection_filter = Some("lucide".to_string()); + state.collection_icons_prefix = Some("lucide".to_string()); + state.collection_icons = vec![ + "lucide:bean".to_string(), + "lucide:beaker".to_string(), + "lucide:home".to_string(), + ]; + state.refresh_visible_icons(); + app.iconify_search_popup_state = Some(state); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('b'), + ..Default::default() + }); + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('n'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup state should exist"); + assert_eq!(state.search_value, "bn"); + assert!(state.pending_search_query.is_none()); + assert!(!state.is_loading_search); + assert_eq!(state.visible_icons, vec!["lucide:bean".to_string()]); + } + + #[test] + fn global_icon_search_keeps_remote_query_flow() { + let mut app = test_app(); + app.app_focus = AppFocus::IconifySearchPopup; + + let mut state = IconifySearchPopupState::new(); + state.active_tab = IconifySearchTab::Icons; + app.iconify_search_popup_state = Some(state); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('h'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup state should exist"); + assert_eq!(state.pending_search_query.as_deref(), Some("h")); + assert!(state.is_loading_search); + } + + #[test] + fn j_and_k_type_into_search_input() { + let mut app = test_app(); + app.app_focus = AppFocus::IconifySearchPopup; + app.iconify_search_popup_state = Some(IconifySearchPopupState::new()); + + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('j'), + ..Default::default() + }); + app.handlekeys_iconify_search_popup(Input { + key: Key::Char('k'), + ..Default::default() + }); + + let state = app + .iconify_search_popup_state + .as_ref() + .expect("iconify popup state should exist"); + assert_eq!(state.search_value, "jk"); + } +} diff --git a/src/views/main.rs b/src/views/main.rs new file mode 100644 index 0000000..459bcb8 --- /dev/null +++ b/src/views/main.rs @@ -0,0 +1,472 @@ +use nucleo_matcher::{ + Config, Matcher, + pattern::{CaseMatching, Normalization, Pattern}, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::Style, + widgets::{Block, Paragraph}, +}; +use tui_textarea::{Input, Key, TextArea}; + +use crate::{ + app_state::{App, AppFocus}, + utils::IconEntry, +}; + +#[derive(Debug, Clone)] +struct HomeSearchCandidate { + index: usize, + haystack: String, +} + +impl AsRef for HomeSearchCandidate { + fn as_ref(&self) -> &str { + self.haystack.as_str() + } +} + +fn fuzzy_filter_home_items(items: &[IconEntry], query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return items.to_vec(); + } + + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + let mut matcher = Matcher::new(Config::DEFAULT); + + let candidates = items + .iter() + .enumerate() + .map(|(index, item)| HomeSearchCandidate { + index, + haystack: format!("{} {}", item.name, item.file_path), + }) + .collect::>(); + + let mut matched = pattern.match_list(candidates, &mut matcher); + matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.index.cmp(&b.0.index))); + + matched + .into_iter() + .map(|(candidate, _)| items[candidate.index].clone()) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MainStateFocus { + Normal, + Search, +} + +#[derive(Debug)] +pub struct MainState { + pub main_state_focus: MainStateFocus, + pub search_items_value: String, + pub status_message: Option, + pub status_is_error: bool, + + pub search_textarea: TextArea<'static>, +} + +impl MainState { + pub fn new() -> Self { + Self { + main_state_focus: MainStateFocus::Normal, + search_items_value: String::new(), + status_message: None, + status_is_error: false, + search_textarea: TextArea::default(), + } + } + + fn set_status(&mut self, message: String, is_error: bool) { + self.status_message = Some(message); + self.status_is_error = is_error; + } + + fn clear_status(&mut self) { + self.status_message = None; + self.status_is_error = false; + } +} + +impl App { + fn main_item_count(&self) -> usize { + if self.main_state.search_items_value.is_empty() { + self.items.len() + } else { + self.filtered_items.len() + } + } + + fn move_main_selection_up(&mut self) { + let item_count = self.main_item_count(); + if item_count == 0 { + self.selected_index = 0; + } else if self.selected_index > 0 { + self.selected_index -= 1; + } else { + self.selected_index = item_count - 1; + } + } + + fn move_main_selection_down(&mut self) { + let item_count = self.main_item_count(); + if item_count == 0 { + self.selected_index = 0; + } else if self.selected_index < item_count.saturating_sub(1) { + self.selected_index += 1; + } else { + self.selected_index = 0; + } + } + + fn update_filtered_items_main(&mut self) { + self.filtered_items = + fuzzy_filter_home_items(&self.items, &self.main_state.search_items_value); + if self.filtered_items.is_empty() { + self.selected_index = 0; + } else if self.selected_index >= self.filtered_items.len() { + self.selected_index = self.filtered_items.len().saturating_sub(1); + } + } + + fn handlekeys_main_search(&mut self, input: &Input) { + match input.key { + Key::Esc => { + self.main_state.main_state_focus = MainStateFocus::Normal; + self.app_focus = AppFocus::Main; + self.main_state.search_textarea = TextArea::default(); + self.main_state.search_items_value.clear(); + self.update_filtered_items_main(); + } + Key::Enter => { + self.app_focus = AppFocus::Main; + self.main_state.main_state_focus = MainStateFocus::Normal; + } + Key::Up => self.move_main_selection_up(), + Key::Down => self.move_main_selection_down(), + _ => { + self.main_state.search_textarea.input(input.clone()); + self.main_state.search_items_value = + self.main_state.search_textarea.lines().join(""); + self.update_filtered_items_main(); + } + } + } + + fn handlekeys_main_normal(&mut self, input: &Input) { + match input.key { + Key::Char('q') => self.should_quit = true, + Key::Char('a') => { + self.init_add_popup(); + } + Key::Char('i') => { + self.init_iconify_search_popup(); + } + Key::Char('d') => { + self.init_delete_popup(); + } + Key::Char('r') => { + self.init_rename_popup(); + } + Key::Char('o') => match self.open_selected_icon() { + Ok(crate::viewer::OpenSvgOutcome::OpenedWithCustomCommand) => { + self.main_state.clear_status() + } + Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefault) => { + self.main_state.clear_status() + } + Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure) => { + self.main_state.set_status( + "svg_viewer_cmd failed; opened icon via OS default viewer".to_string(), + false, + ) + } + Ok(crate::viewer::OpenSvgOutcome::OpenedWithWebPreview(url)) => { + self.main_state.set_status( + format!("Local open failed; opened web preview: {url}"), + false, + ) + } + Err(error) => self + .main_state + .set_status(format!("Failed to open icon: {}", error), true), + }, + Key::Char('/') => { + self.main_state.main_state_focus = MainStateFocus::Search; + } + Key::Char('?') => { + self.init_help_popup(); + } + Key::Up | Key::Char('k') => self.move_main_selection_up(), + Key::Down | Key::Char('j') => self.move_main_selection_down(), + _ => {} + } + } + + pub fn open_selected_icon(&self) -> anyhow::Result { + use std::path::Path; + + let item_list = if self.main_state.search_items_value.is_empty() { + &self.items + } else { + &self.filtered_items + }; + let item = item_list + .get(self.selected_index) + .ok_or_else(|| anyhow::anyhow!("No icon selected."))?; + + let file_path = Path::new(&item.file_path); + let absolute_path = if file_path.is_absolute() { + file_path.to_path_buf() + } else { + Path::new(&self.config.folder).join(file_path) + }; + + crate::viewer::open_svg_with_fallback(&absolute_path, self.config.svg_viewer_cmd.as_deref()) + } + + pub fn handlekeys_main(&mut self, input: Input) { + match self.main_state.main_state_focus { + MainStateFocus::Search => self.handlekeys_main_search(&input), + MainStateFocus::Normal => self.handlekeys_main_normal(&input), + } + } +} + +pub fn render_main_view(f: &mut Frame, area: Rect, app: &mut App) { + use ratatui::{ + style::Modifier, + widgets::{Cell, Row, Table}, + }; + + let main_state = &mut app.main_state; + let is_searching = main_state.main_state_focus == MainStateFocus::Search; + + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let ascii_art = "โ–‘โ–€โ–ˆโ–€โ–‘โ–ˆโ–€โ–€โ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–€โ–ˆโ–‘โ–ˆโ–„โ–ˆโ–‘โ–ˆโ–€โ–ˆโ–‘โ–€โ–ˆโ–€โ–‘โ–ˆโ–€โ–€โ–‘\n\ + โ–‘โ–‘โ–ˆโ–‘โ–‘โ–ˆโ–‘โ–‘โ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–‘โ–ˆโ–€โ–ˆโ–‘โ–‘โ–ˆโ–‘โ–‘โ–ˆโ–€โ–€โ–‘\n\ + โ–‘โ–€โ–€โ–€โ–‘โ–€โ–€โ–€โ–‘โ–€โ–€โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–€โ–‘โ–‘โ–€โ–‘โ–‘โ–€โ–€โ–€โ–‘"; + f.render_widget( + Paragraph::new(ascii_art) + .style(Style::default().fg(crate::views::theme::ACCENT)) + .alignment(Alignment::Center), + main_chunks[0], + ); + + f.render_widget( + Paragraph::new("Add svg icons to your js apps without any dependencies") + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)) + .alignment(Alignment::Center), + main_chunks[1], + ); + + let status_text = main_state.status_message.clone().unwrap_or_default(); + let status_color = if main_state.status_is_error { + crate::views::theme::ERROR + } else { + crate::views::theme::SUBTLE_TEXT + }; + f.render_widget( + Paragraph::new(status_text) + .style(Style::default().fg(status_color)) + .alignment(Alignment::Center), + main_chunks[2], + ); + + let search_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + Constraint::Length(10), + Constraint::Fill(1), + Constraint::Length(8), + ]) + .split(main_chunks[3]); + + f.render_widget( + Paragraph::new("Search /") + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)) + .alignment(Alignment::Left), + search_chunks[0], + ); + + if is_searching { + main_state.search_textarea.set_block(Block::default()); + main_state.search_textarea.set_cursor_style( + Style::default() + .bg(crate::views::theme::ACCENT) + .fg(crate::views::theme::BASE_BG), + ); + main_state + .search_textarea + .set_cursor_line_style(Style::default()); + f.render_widget(&main_state.search_textarea, search_chunks[1]); + f.render_widget( + Paragraph::new("enter") + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)) + .alignment(Alignment::Right), + search_chunks[2], + ); + } else { + let search_display = if main_state.search_items_value.is_empty() { + String::new() + } else { + main_state.search_items_value.clone() + }; + let search_color = if main_state.search_items_value.is_empty() { + crate::views::theme::MUTED_TEXT + } else { + crate::views::theme::TEXT + }; + f.render_widget( + Paragraph::new(search_display) + .style(Style::default().fg(search_color)) + .alignment(Alignment::Left), + search_chunks[1], + ); + } + + let item_list = if main_state.search_items_value.is_empty() { + &app.items + } else { + &app.filtered_items + }; + let show_no_results = !main_state.search_items_value.is_empty() && item_list.is_empty(); + + let header_cells = ["Name", "File"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().fg(crate::views::theme::MUTED_TEXT))); + let header = Row::new(header_cells).style(Style::default().fg(crate::views::theme::TEXT)); + + let rows: Vec = if show_no_results { + vec![Row::new(vec![ + Cell::from("No icons match your search") + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)), + Cell::from(""), + ])] + } else { + item_list + .iter() + .map(|item| { + Row::new(vec![ + Cell::from(item.name.as_str()) + .style(Style::default().fg(crate::views::theme::TEXT)), + Cell::from(item.file_path.as_str()) + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)), + ]) + }) + .collect() + }; + + let has_rows = !rows.is_empty(); + let table = Table::new( + rows, + [Constraint::Percentage(50), Constraint::Percentage(50)], + ) + .header(header) + .block(Block::default()) + .column_spacing(2) + .highlight_symbol(" ") + .row_highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::BASE_BG) + .add_modifier(Modifier::BOLD), + ); + + let mut state = ratatui::widgets::TableState::default(); + if !show_no_results && has_rows { + state.select(Some(app.selected_index)); + } + f.render_stateful_widget(table, main_chunks[5], &mut state); + + let shortcuts = crate::views::theme::shortcut_line(&[ + ("Add", "a"), + ("Iconify", "i"), + ("Delete", "d"), + ("Rename", "r"), + ("Open", "o"), + ("Help", "?"), + ("Quit", "q"), + ]); + let version_label = format!("v{}", env!("CARGO_PKG_VERSION")); + let footer_layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(version_label.chars().count() as u16 + 1), + ]) + .split(main_chunks[6]); + f.render_widget( + Paragraph::new(shortcuts).alignment(Alignment::Left), + footer_layout[0], + ); + f.render_widget( + Paragraph::new(version_label) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)) + .alignment(Alignment::Right), + footer_layout[1], + ); +} + +#[cfg(test)] +mod tests { + use super::fuzzy_filter_home_items; + use crate::utils::IconEntry; + + fn sample_items() -> Vec { + vec![ + IconEntry { + name: "IconMountain".to_string(), + file_path: "./lucide:mountain.svg".to_string(), + }, + IconEntry { + name: "IconHeart".to_string(), + file_path: "./lucide:heart.svg".to_string(), + }, + IconEntry { + name: "IconHouse".to_string(), + file_path: "./lucide:house.svg".to_string(), + }, + ] + } + + #[test] + fn home_search_matches_fuzzy_name_query() { + let filtered = fuzzy_filter_home_items(&sample_items(), "ihrt"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "IconHeart"); + } + + #[test] + fn home_search_matches_fuzzy_file_path_query() { + let filtered = fuzzy_filter_home_items(&sample_items(), "lchrt"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "IconHeart"); + } + + #[test] + fn home_search_keeps_all_items_for_empty_query() { + let items = sample_items(); + let filtered = fuzzy_filter_home_items(&items, " "); + assert_eq!(filtered.len(), items.len()); + assert_eq!(filtered[0].name, items[0].name); + assert_eq!(filtered[1].name, items[1].name); + assert_eq!(filtered[2].name, items[2].name); + } +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..42c09c9 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,7 @@ +pub mod add_popup; +pub mod delete_popup; +pub mod help_popup; +pub mod iconify_search_popup; +pub mod main; +pub mod rename_popup; +pub mod theme; diff --git a/src/views/rename_popup.rs b/src/views/rename_popup.rs new file mode 100644 index 0000000..3a7d5c8 --- /dev/null +++ b/src/views/rename_popup.rs @@ -0,0 +1,197 @@ +use std::path::Path; + +use crate::app_state::{App, AppFocus}; +use crate::utils::popup_area; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::Style; +use ratatui::widgets::{Block, Paragraph, Wrap}; +use tui_textarea::{Input, Key, TextArea}; + +#[derive(Debug)] +pub struct RenamePopupState { + pub item_to_rename: Option, + pub filename_input: TextArea<'static>, + pub status_message: Option, + pub status_is_error: bool, +} + +impl RenamePopupState { + fn is_paste_shortcut(input: &Input) -> bool { + matches!(input.key, Key::Char('v')) && (input.ctrl || input.alt) + } + + fn paste_into_input(&mut self) -> bool { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Ok(text) = clipboard.get_text() { + self.filename_input.insert_str(&text); + return true; + } + } + false + } + + fn clear_status(&mut self) { + self.status_message = None; + self.status_is_error = false; + } + + fn set_status_error(&mut self, message: String) { + self.status_message = Some(message); + self.status_is_error = true; + } +} + +impl App { + pub fn init_rename_popup(&mut self) { + self.app_focus = AppFocus::RenamePopup; + + let item_to_rename = self.filtered_items.get(self.selected_index).cloned(); + let mut filename_input = TextArea::default(); + + if let Some(item) = &item_to_rename { + if let Some(file_name) = Path::new(&item.file_path) + .file_name() + .and_then(|name| name.to_str()) + { + filename_input.insert_str(file_name); + } + } + + filename_input.set_cursor_style( + Style::default() + .bg(crate::views::theme::ACCENT) + .fg(crate::views::theme::BASE_BG), + ); + + self.rename_popup_state = Some(RenamePopupState { + item_to_rename, + filename_input, + status_message: None, + status_is_error: false, + }); + } + + fn close_rename_popup(&mut self) { + self.app_focus = AppFocus::Main; + self.rename_popup_state = None; + } + + fn submit_rename_popup(&mut self) -> Result<(), String> { + let Some(state) = self.rename_popup_state.as_ref() else { + return Err("Rename popup is not initialized".to_string()); + }; + + let Some(item) = state.item_to_rename.as_ref() else { + return Err("No icon selected to rename.".to_string()); + }; + + let new_filename = state.filename_input.lines().join("\n").trim().to_string(); + if new_filename.is_empty() { + return Err("Please enter a new filename.".to_string()); + } + + crate::utils::rename_icon_entry(&self.config.folder, &item.file_path, &new_filename) + .map_err(|error| error.to_string())?; + + self.init_icons(); + self.close_rename_popup(); + Ok(()) + } + + pub fn handlekeys_rename_popup(&mut self, input: Input) { + match input.key { + Key::Esc => { + self.close_rename_popup(); + } + Key::Enter => { + if let Err(error) = self.submit_rename_popup() { + if let Some(state) = self.rename_popup_state.as_mut() { + state.set_status_error(error); + } + } + } + _ => { + if let Some(state) = self.rename_popup_state.as_mut() { + if RenamePopupState::is_paste_shortcut(&input) && state.paste_into_input() { + state.clear_status(); + return; + } + + state.filename_input.input(input); + state.clear_status(); + } + } + } + } +} + +pub fn render_rename_popup(f: &mut Frame, app: &mut App) { + use ratatui::style::Modifier; + + let area = popup_area(f.area(), 74, 16); + let body_area = crate::views::theme::render_popup_shell(f, area, "Rename File"); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(body_area); + + if let Some(state) = app.rename_popup_state.as_mut() { + let status = if let Some(item) = &state.item_to_rename { + format!("Alias: {}\nCurrent file: {}", item.name, item.file_path) + } else { + "No icon selected".to_string() + }; + let status_paragraph = Paragraph::new(status) + .alignment(Alignment::Left) + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)); + f.render_widget(status_paragraph, layout[0]); + + let input_block = Block::default() + .title("New filename") + .title_style( + Style::default() + .fg(crate::views::theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().fg(crate::views::theme::TEXT)); + state.filename_input.set_block(input_block); + state.filename_input.set_cursor_line_style(Style::default()); + f.render_widget(&state.filename_input, layout[2]); + + let tip = Paragraph::new( + "Renames only the file path export target.\nFor alias rename, use your IDE's rename feature via LSP.", + ) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)); + f.render_widget(tip, layout[4]); + + let footer = if let Some(message) = &state.status_message { + let color = if state.status_is_error { + crate::views::theme::ERROR + } else { + crate::views::theme::MUTED_TEXT + }; + Paragraph::new(message.clone()) + .alignment(Alignment::Left) + .style(Style::default().fg(color)) + } else { + Paragraph::new(crate::views::theme::shortcut_line(&[ + ("Rename", "enter"), + ("Cancel", "esc"), + ("Paste", "cmd/ctrl+v"), + ])) + .alignment(Alignment::Left) + }; + f.render_widget(footer, layout[5]); + } +} diff --git a/src/views/theme.rs b/src/views/theme.rs new file mode 100644 index 0000000..882f822 --- /dev/null +++ b/src/views/theme.rs @@ -0,0 +1,107 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph}, +}; + +pub const BASE_BG: Color = Color::Rgb(10, 14, 22); +pub const PANEL_BG: Color = Color::Reset; +pub const INPUT_BG: Color = Color::Reset; +pub const LOGO_GREEN: Color = Color::Rgb(74, 222, 128); +pub const TAB_BG: Color = Color::Reset; +pub const TAB_BG_ACTIVE: Color = LOGO_GREEN; +pub const ROW_HIGHLIGHT_BG: Color = LOGO_GREEN; + +pub const TEXT: Color = Color::Rgb(236, 240, 246); +pub const MUTED_TEXT: Color = Color::Rgb(143, 155, 177); +pub const SUBTLE_TEXT: Color = Color::Rgb(110, 124, 149); +pub const ACCENT: Color = LOGO_GREEN; +pub const ACCENT_SOFT: Color = LOGO_GREEN; +pub const ERROR: Color = Color::Rgb(248, 113, 113); + +pub fn render_popup_shell(f: &mut Frame, area: Rect, title: &str) -> Rect { + f.render_widget(Clear, area); + f.render_widget( + Block::bordered().border_style(Style::default().fg(SUBTLE_TEXT)), + area, + ); + + let frame = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let title_width = title.chars().count().saturating_add(1) as u16; + let header = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Length(title_width), + Constraint::Min(0), + Constraint::Length(3), + Constraint::Length(2), + ]) + .split(frame[1]); + + f.render_widget( + Paragraph::new(title).style(Style::default().fg(TEXT).add_modifier(Modifier::BOLD)), + header[1], + ); + f.render_widget( + Paragraph::new("esc") + .style( + Style::default() + .fg(ACCENT_SOFT) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Right), + header[3], + ); + + let body_with_gap = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(frame[2]); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(2), + ]) + .split(body_with_gap[1])[1] +} + +pub fn shortcut_line(items: &[(&str, &str)]) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + + for (index, (label, key)) in items.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + (*label).to_string(), + Style::default() + .fg(ACCENT_SOFT) + .add_modifier(Modifier::BOLD), + )); + if !key.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + (*key).to_string(), + Style::default().fg(MUTED_TEXT), + )); + } + } + + Line::from(spans) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a3eb1a5..6058bf3 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -12,7 +12,7 @@ fn test_add_command_creates_folder_and_files() { // Run the iconmate add command let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -81,7 +81,7 @@ fn test_add_command_with_existing_folder() { // Run the iconmate add command let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -114,6 +114,58 @@ fn test_add_command_with_existing_folder() { ); } +#[test] +fn test_add_command_appends_after_non_newline_terminated_index() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_folder = temp_dir.path().join("src/assets/icons"); + + std::fs::create_dir_all(&test_folder).expect("Failed to create test folder"); + + let existing_index = test_folder.join("index.ts"); + std::fs::write( + &existing_index, + "export { default as IconExisting } from './existing.svg';", + ) + .expect("Failed to create existing index.ts"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + + let output = Command::new(binary_path) + .args([ + "add", + "--folder", + test_folder.to_str().unwrap(), + "--preset", + "emptysvg", + "--name", + "Fresh", + ]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let index_content = std::fs::read_to_string(&existing_index).expect("Failed to read index.ts"); + + assert!( + index_content.contains("export { default as IconExisting } from './existing.svg';\n"), + "existing export should end with a newline before appending" + ); + assert!( + index_content.contains("export { default as IconFresh } from './fresh.svg';"), + "index.ts should contain the new export" + ); + assert!( + !index_content.contains("existing.svg';export"), + "new export should not be concatenated on the same line" + ); +} + #[test] fn test_add_command_duplicate_icon() { // Test adding the same icon twice @@ -124,7 +176,7 @@ fn test_add_command_duplicate_icon() { // First add let output1 = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -141,7 +193,7 @@ fn test_add_command_duplicate_icon() { // Second add of the same icon let output2 = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -175,7 +227,7 @@ async fn test_add_command_invalid_icon() { let binary_path = env!("CARGO_BIN_EXE_iconmate"); let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -204,7 +256,7 @@ fn test_add_command_preset_empty_svg() { let binary_path = env!("CARGO_BIN_EXE_iconmate"); let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -238,3 +290,65 @@ fn test_add_command_preset_empty_svg() { "SVG should contain the empty SVG template" ); } + +#[test] +fn test_add_command_preset_normal_with_icon() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_folder = temp_dir.path().join("src/assets/icons"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + + let output = Command::new(binary_path) + .args([ + "add", + "--folder", + test_folder.to_str().unwrap(), + "--preset", + "normal", + "--icon", + "heroicons:heart", + "--name", + "Heart", + ]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let svg_file = test_folder.join("heroicons:heart.svg"); + assert!(svg_file.exists(), "heroicons:heart.svg should be created"); +} + +#[test] +fn test_add_command_preset_normal_requires_icon() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let test_folder = temp_dir.path().join("src/assets/icons"); + + let binary_path = env!("CARGO_BIN_EXE_iconmate"); + + let output = Command::new(binary_path) + .args([ + "add", + "--folder", + test_folder.to_str().unwrap(), + "--preset", + "normal", + "--name", + "NoIcon", + ]) + .current_dir(temp_dir.path()) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Command should fail without icon"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("--icon argument is required when --preset is normal"), + "stderr should explain normal preset needs an icon" + ); +}