From 167c88fd09c76379377da663108dc1c9dd059db5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 21 Oct 2025 20:44:54 +0800 Subject: [PATCH 01/28] feat: massive progress on ratatui. --- Cargo.lock | 358 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/app_state.rs | 93 +++++++++++ src/form_input.rs | 94 +++++++++++ src/main.rs | 171 ++------------------ src/tui.rs | 63 ++++++++ src/utils.rs | 174 ++++++++++++++++++++ src/views/add_popup.rs | 185 +++++++++++++++++++++ src/views/main.rs | 113 +++++++++++++ src/views/mod.rs | 2 + 10 files changed, 1095 insertions(+), 162 deletions(-) create mode 100644 src/app_state.rs create mode 100644 src/form_input.rs create mode 100644 src/tui.rs create mode 100644 src/utils.rs create mode 100644 src/views/add_popup.rs create mode 100644 src/views/main.rs create mode 100644 src/views/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7f37fc3..1219fb5 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" @@ -180,6 +186,21 @@ 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 +256,53 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[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" @@ -291,6 +353,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 +381,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.0.7", "signal-hook", "signal-hook-mio", "winapi", @@ -318,6 +396,41 @@ dependencies = [ "winapi", ] +[[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" @@ -377,6 +490,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,6 +521,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[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" @@ -423,6 +552,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" @@ -572,6 +707,11 @@ 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" @@ -704,13 +844,17 @@ dependencies = [ "anyhow", "assert_fs", "clap", + "color-eyre", + "crossterm 0.29.0", "inquire", "predicates", + "ratatui", "regex", "reqwest", "tempfile", "tokio", "tokio-test", + "tui-textarea", ] [[package]] @@ -799,6 +943,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 +986,12 @@ dependencies = [ "winapi-util", ] +[[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 +1002,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 +1015,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 +1057,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 +1082,24 @@ dependencies = [ "wasm-bindgen", ] +[[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 = "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 +1134,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" @@ -1068,6 +1273,12 @@ dependencies = [ "vcpkg", ] +[[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,6 +1302,12 @@ 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" @@ -1178,6 +1395,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" @@ -1276,6 +1514,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 +1536,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -1425,6 +1676,15 @@ dependencies = [ "serde", ] +[[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" @@ -1489,12 +1749,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 +1850,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -1739,6 +2027,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 +2057,17 @@ 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 = "unicode-ident" version = "1.0.18" @@ -1759,11 +2080,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 = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" @@ -1794,6 +2132,12 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 53cc1a0..d979b5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,10 @@ tokio = { version = "1.37", features = ["full"] } anyhow = "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" [dev-dependencies] tempfile = "3.8" diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..ad80dae --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,93 @@ +use crate::utils::IconEntry; +use crossterm::event::KeyEvent; +use std::sync::mpsc::Receiver; +use tui_textarea::Input; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AppFocus { + Main, + AddPopup, + DeletePopup, + HelpPopup, + Search, +} + +pub struct App { + pub rx: Receiver<()>, + + pub should_quit: bool, + + pub selected_index: usize, + pub items: Vec, + + pub app_focus: AppFocus, + + pub add_popup_state: Option, +} + +impl App { + pub fn new() -> Self { + let (_tx, rx) = std::sync::mpsc::channel(); + let mut app = Self { + should_quit: false, + rx: rx, + + selected_index: 0, + items: Vec::new(), + + app_focus: AppFocus::Main, + add_popup_state: None, + }; + + app.init_icons(); + app + } + + fn init_icons(&mut self) { + // TODO, read the current project + self.items = vec![ + IconEntry { + name: "Heart".to_string(), + file_path: "material:heart.svg".to_string(), + }, + IconEntry { + name: "Star".to_string(), + file_path: "material:star.svg".to_string(), + }, + IconEntry { + name: "Home".to_string(), + file_path: "material:home.svg".to_string(), + }, + IconEntry { + name: "User".to_string(), + file_path: "material:user.svg".to_string(), + }, + IconEntry { + name: "Mail".to_string(), + file_path: "material:mail.svg".to_string(), + }, + IconEntry { + name: "Save".to_string(), + file_path: "material:save.svg".to_string(), + }, + ]; + } + + pub fn update(&mut self) {} + + 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::HelpPopup => self.handlekeys_help_popup(key), + AppFocus::Search => self.handlekeys_search(key), + } + } + + fn handlekeys_delete_popup(&mut self, _key: Input) {} + + fn handlekeys_help_popup(&mut self, _key: Input) {} + + fn handlekeys_search(&mut self, _key: Input) {} +} diff --git a/src/form_input.rs b/src/form_input.rs new file mode 100644 index 0000000..988c118 --- /dev/null +++ b/src/form_input.rs @@ -0,0 +1,94 @@ +use crossterm::event::KeyCode; +use ratatui::{ + style::{Color, Style}, + widgets::{Block, Borders}, +}; +use tui_textarea::TextArea; + +#[derive(Debug)] +pub struct FormInput { + label: String, + textarea: TextArea<'static>, + is_focused: bool, +} +impl FormInput { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + textarea: TextArea::default(), + is_focused: false, + } + } + + pub fn with_placeholder(mut self, text: &str) -> Self { + self.textarea.insert_str(text); + self + } + + pub fn set_focused(&mut self, focused: bool) { + self.is_focused = focused; + } + + pub fn handle_key(&mut self, key: KeyCode) { + use tui_textarea::{Input, Key}; + + let input = match key { + KeyCode::Char(c) => Input { + key: Key::Char(c), + ..Default::default() + }, + KeyCode::Backspace => Input { + key: Key::Backspace, + ..Default::default() + }, + KeyCode::Delete => Input { + key: Key::Delete, + ..Default::default() + }, + KeyCode::Left => Input { + key: Key::Left, + ..Default::default() + }, + KeyCode::Right => Input { + key: Key::Right, + ..Default::default() + }, + KeyCode::Home => Input { + key: Key::Home, + ..Default::default() + }, + KeyCode::End => Input { + key: Key::End, + ..Default::default() + }, + _ => return, + }; + + self.textarea.input(input); + } + + pub fn value(&self) -> String { + self.textarea.lines().join("\n") + } + + pub fn clear(&mut self) { + self.textarea = TextArea::default(); + } + + pub fn widget(&mut self) -> impl ratatui::widgets::Widget + '_ { + let border_color = if self.is_focused { + Color::Yellow + } else { + Color::Gray + }; + + self.textarea.set_block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(self.label.clone()), + ); + + &self.textarea + } +} diff --git a/src/main.rs b/src/main.rs index 572c43e..80e6ac4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,32 +1,19 @@ -use clap::{Parser, Subcommand, ValueEnum}; +mod app_state; +mod form_input; +mod tui; +mod utils; +mod views; + +use crate::utils::{ + _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry, + IconSourceType, Preset, +}; +use clap::{Parser, Subcommand}; use reqwest::Url; 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)] @@ -120,129 +107,6 @@ 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, -} - -/// 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, - } -} - -/// 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()); - }; - - 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()); - } - }; - - // -- 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 -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() - }; - - if stem.ends_with(ext) { - (stem.replace(ext, ""), ext) - } else { - (stem, ext) - } -} - /// The main logic of the application. /// Fetches an icon, saves it, and updates the index file. async fn run_app(config: AppConfig) -> anyhow::Result<()> { @@ -593,13 +457,6 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { 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) @@ -791,6 +648,10 @@ async fn main() -> anyhow::Result<()> { } Some(Commands::Prompt {}) => run_prompt_mode(&args).await, Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await, - None => run_prompt_mode(&args).await, + None => { + tui::run().await + // run_prompt_mode(&args).await + // run_prompt_mode(&args).await + } } } diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..5408bd1 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,63 @@ +use crate::{ + app_state::{App, AppFocus}, + views::main::{render_main_view, render_sidebar}, +}; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Frame, Terminal, backend::CrosstermBackend, layout::Constraint}; +use std::io; +use tui_textarea::{Input, Key}; + +pub async fn run() -> 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(); + + loop { + terminal.draw(|f| ui(f, &mut app))?; + + match ratatui::crossterm::event::read()?.into() { + Input { + key: Key::Char('q'), + .. + } => break, + input => app.handlekeys(input), + } + + app.update(); + } + + 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::Horizontal) + .margin(1) + .constraints([Constraint::Max(37), Constraint::Min(0)]) + .split(area); + + render_sidebar(f, layout[0], app); + render_main_view(f, layout[1], app); + + if app.app_focus == AppFocus::AddPopup { + crate::views::add_popup::render_add_popup(f, app); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..192d365 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,174 @@ +use clap::ValueEnum; +use ratatui::layout::{Constraint, Rect}; +use reqwest::Url; + +#[derive(ValueEnum, Clone, Debug, PartialEq)] +pub 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, +} +pub const PRESETS: &[Preset] = &[ + Preset::EmptySvg, + Preset::React, + Preset::Svelte, + Preset::Solid, + Preset::Vue, +]; + +/// helper function to create a centered rect using up certain percentage of the available rect `r` +pub fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = ratatui::layout::Layout::vertical([Constraint::Percentage(percent_y)]) + .flex(ratatui::layout::Flex::Center); + let horizontal = ratatui::layout::Layout::horizontal([Constraint::Percentage(percent_x)]) + .flex(ratatui::layout::Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area +} + +/// Struct to hold icon information for deletion +#[derive(Debug)] +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, +} + +/// 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 => { + // 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()); + } + }; + + // -- 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 icon.clone(), + _ => 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) + } +} diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs new file mode 100644 index 0000000..e97f250 --- /dev/null +++ b/src/views/add_popup.rs @@ -0,0 +1,185 @@ +use std::sync::mpsc::sync_channel; + +use crate::app_state::{App, AppFocus}; +use crate::utils::{IconEntry, popup_area}; +use ratatui::Frame; +use ratatui::crossterm::cursor::SetCursorStyle; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use tui_textarea::{Input, Key, TextArea}; + +#[derive(Debug)] +pub struct AddPopupState { + pub inputs: Vec>, + pub current_input: usize, +} +impl AddPopupState { + 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(Color::White)); + continue; + } + // Remove + textarea.set_cursor_style(Style::default()); + } + } +} + +impl App { + pub fn init_add_popup(&mut self) { + self.app_focus = AppFocus::AddPopup; + self.add_popup_state = Some(AddPopupState { + inputs: vec![ + TextArea::default(), // folder + TextArea::default(), // preset + TextArea::default(), // icon + TextArea::default(), // filename + TextArea::default(), // name + ], + current_input: 0, + }); + + // Set default value for folder input + self.add_popup_state.as_mut().unwrap().inputs[0].insert_str("src/assets/icons"); + self.add_popup_state.as_mut().unwrap().sync_cursor(0); + } + + pub fn handlekeys_add_popup(&mut self, input: Input) { + if let Some(state) = self.add_popup_state.as_mut() { + match input.key { + Key::Enter => { + // Save current input + let current_text = state.inputs[state.current_input].lines().join("\n"); + if state.current_input < state.inputs.len() - 1 { + state.current_input += 1; + state.sync_cursor(state.current_input); + } else { + // Submit form + let name = state.inputs[4].lines().join("\n"); + let file_path = state.inputs[2].lines().join("\n"); + self.items.push(IconEntry { name, file_path }); + self.app_focus = AppFocus::Main; + self.add_popup_state = None; + } + } + Key::Tab => { + let mut forwards: i32 = 1; + if input.shift { + forwards = -1; + } + // Cycle + state.current_input = ((state.current_input as i32 + forwards) + .rem_euclid(state.inputs.len() as i32)) + as usize; + state.sync_cursor(state.current_input); + } + Key::Esc => { + self.app_focus = AppFocus::Main; + self.add_popup_state = None; + } + _ => { + state.inputs[state.current_input].input(input); + // state.inputs[state.current_input].input + } + } + } + } +} + +pub fn render_add_popup(f: &mut Frame, app: &mut App) { + let area = popup_area(f.area(), 60, 50); + f.render_widget(ratatui::widgets::Clear, area); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Folder + Constraint::Length(3), // Preset + Constraint::Length(3), // Icon + Constraint::Length(3), // Filename + Constraint::Length(3), // Name + Constraint::Min(0), // Help text + ]) + .split(area); + + let title = Block::bordered() + .title("Add Icon") + .border_type(ratatui::widgets::BorderType::Rounded); + f.render_widget(title, area); + + if let Some(state) = &mut app.add_popup_state { + let labels = [ + "Folder", + "Preset", + "Icon (Iconify name / URL / raw SVG)", + "Filename", + "Name", + ]; + + // Render each field individually with textarea + let folder_block = Block::default() + .borders(Borders::ALL) + .border_style(if state.current_input == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .title(labels[0]); + state.inputs[0].set_block(folder_block); + f.render_widget(state.inputs[0].widget(), layout[1]); + + let preset_block = Block::default() + .borders(Borders::ALL) + .border_style(if state.current_input == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .title(labels[1]); + state.inputs[1].set_block(preset_block); + f.render_widget(state.inputs[1].widget(), layout[2]); + + let icon_block = Block::default() + .borders(Borders::ALL) + .border_style(if state.current_input == 2 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .title(labels[2]); + state.inputs[2].set_block(icon_block); + f.render_widget(state.inputs[2].widget(), layout[3]); + + let filename_block = Block::default() + .borders(Borders::ALL) + .border_style(if state.current_input == 3 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .title(labels[3]); + state.inputs[3].set_block(filename_block); + f.render_widget(state.inputs[3].widget(), layout[4]); + + let name_block = Block::default() + .borders(Borders::ALL) + .border_style(if state.current_input == 4 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }) + .title(labels[4]); + state.inputs[4].set_block(name_block); + f.render_widget(state.inputs[4].widget(), layout[5]); + } + + let help_text = Paragraph::new("Type and press Enter to continue | ESC to cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help_text, layout[6]); +} diff --git a/src/views/main.rs b/src/views/main.rs new file mode 100644 index 0000000..39d8002 --- /dev/null +++ b/src/views/main.rs @@ -0,0 +1,113 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; +use tui_textarea::{Input, Key}; + +use crate::app_state::App; + +impl App { + pub fn handlekeys_main(&mut self, input: Input) { + match input.key { + Key::Char('q') => self.should_quit = true, + Key::Char('a') => { + self.init_add_popup(); + } + Key::Up | Key::Char('k') => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + Key::Down | Key::Char('j') => { + if self.selected_index < self.items.len().saturating_sub(1) { + self.selected_index += 1; + } + } + _ => {} + } + } +} + +pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { + let ascii_art = "░▀█▀░█▀▀░█▀█░█▀█░█▄█░█▀█░▀█▀░█▀▀░\n\ + ░░█░░█░░░█░█░█░█░█░█░█▀█░░█░░█▀▀░\n\ + ░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░▀░▀░░▀░░▀▀▀░"; + let items: Vec = vec![ + ListItem::new("a - Add"), + ListItem::new("d - Delete"), + ListItem::new("↑↓ - Navigate (or k,j)"), + ListItem::new("? - Help"), + ListItem::new("/ - Search"), + ]; + let list = List::new(items).highlight_symbol(">> "); + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded); + + let inner_block = Block::default() + .title("Defaults") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded); + let inner_list = List::new(vec![ + ListItem::new("Folder"), + ListItem::new("src/assets/icons"), + ListItem::new(""), + ListItem::new("Preset"), + ListItem::new("react-tsx"), + ]); + + let vertical_layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Max(10), + ]) + .split(area); + + let ascii_paragraph = Paragraph::new(ascii_art) + .style(Style::default().fg(Color::Rgb(74, 222, 128))) + .alignment(Alignment::Center); + f.render_widget(ascii_paragraph, vertical_layout[0]); + f.render_widget(list.block(list_block), vertical_layout[1]); + f.render_widget(inner_list.block(inner_block), vertical_layout[2]); +} + +pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { + use ratatui::widgets::{Cell, Row, Table}; + + let header_cells = ["Name", "File"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow))); + let header = Row::new(header_cells) + .style(Style::default().fg(Color::White)) + .height(1); + + let rows = app.items.iter().map(|item| { + Row::new(vec![ + Cell::from(item.name.as_str()), + Cell::from(item.file_path.as_str()), + ]) + }); + + let table = Table::new( + rows, + [Constraint::Percentage(50), Constraint::Percentage(50)], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded), + ) + .column_spacing(2) + .highlight_symbol(">> ") + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + let mut state = ratatui::widgets::TableState::default(); + state.select(Some(app.selected_index)); + f.render_stateful_widget(table, area, &mut state); +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..f8da0ac --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,2 @@ +pub mod add_popup; +pub mod main; From adaa2577d54ab582434ec40ba46b9a20d70d760b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 21 Oct 2025 23:30:10 +0800 Subject: [PATCH 02/28] feat: polished add form. --- src/main.rs | 8 +- src/utils.rs | 72 ++++++++++--- src/views/add_popup.rs | 238 ++++++++++++++++++++++++++++++----------- src/views/main.rs | 5 +- 4 files changed, 238 insertions(+), 85 deletions(-) diff --git a/src/main.rs b/src/main.rs index 80e6ac4..b566f04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,14 +132,14 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> { &config.preset, ) { // Case 1: Icon is provided AND the preset is EmptySvg. This is the only mutual exclusivity. - (Some(_), Some(Preset::EmptySvg)) => { + (Some(_), Some(Preset::Svg)) => { 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, Some(Preset::Svg)) => { let content = r#""#.to_string(); let (file_stem, ext) = _make_svg_filename( config.filename.as_ref(), @@ -378,7 +378,7 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { // My rust skill issue doesn't know how to return this as just 1 item. match preset_raw.key { - "emptysvg" => Some(Preset::EmptySvg), + "emptysvg" => Some(Preset::Svg), "react" => Some(Preset::React), "svelte" => Some(Preset::Svelte), "solid" => Some(Preset::Solid), @@ -394,7 +394,7 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { Some(i.clone()) } None => { - if matches!(preset, Some(Preset::EmptySvg)) { + if matches!(preset, Some(Preset::Svg)) { None } else { let icon_raw = Text::new( diff --git a/src/utils.rs b/src/utils.rs index 192d365..921696b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,11 +2,11 @@ use clap::ValueEnum; use ratatui::layout::{Constraint, Rect}; use reqwest::Url; -#[derive(ValueEnum, Clone, Debug, PartialEq)] +#[derive(ValueEnum, Clone, Debug, PartialEq, Hash)] pub enum Preset { /// Use a blank SVG. #[value(name = "emptysvg")] - EmptySvg, + Svg, /// React Component .tsx #[value(name = "react")] @@ -24,23 +24,61 @@ pub enum Preset { #[value(name = "vue")] Vue, } -pub const PRESETS: &[Preset] = &[ - Preset::EmptySvg, - Preset::React, - Preset::Svelte, - Preset::Solid, - Preset::Vue, + +impl Preset { + pub fn to_str(&self) -> &'static str { + match self { + Preset::Svg => "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, +} + +pub const PRESETS_OPTIONS: &[PresetOption] = &[ + PresetOption { + preset: Preset::Svg, + description: "Outputs an svg (.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 percentage of the available rect `r` -pub fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { - let vertical = ratatui::layout::Layout::vertical([Constraint::Percentage(percent_y)]) - .flex(ratatui::layout::Flex::Center); - let horizontal = ratatui::layout::Layout::horizontal([Constraint::Percentage(percent_x)]) - .flex(ratatui::layout::Flex::Center); - let [area] = vertical.areas(area); - let [area] = horizontal.areas(area); - area +/// 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 diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index e97f250..ae57b46 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -1,18 +1,31 @@ use std::sync::mpsc::sync_channel; use crate::app_state::{App, AppFocus}; -use crate::utils::{IconEntry, popup_area}; +use crate::utils::{IconEntry, PRESETS_OPTIONS, Preset, PresetOption, popup_area}; use ratatui::Frame; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, ListItem, Paragraph}; use tui_textarea::{Input, Key, TextArea}; #[derive(Debug)] pub struct AddPopupState { - pub inputs: Vec>, + // 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, } impl AddPopupState { fn sync_cursor(&mut self, index: usize) { @@ -26,19 +39,92 @@ impl AddPopupState { textarea.set_cursor_style(Style::default()); } } + + pub fn handlekeys_preset_input(&mut self, input: Input) { + if self.current_input == 1 { + match input.key { + Key::Tab | Key::Enter => { + 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 == 1 { + self.inputs[1].input(input); + self.preset_filter = self.inputs[1].lines().join("\n"); + return; + } + } + } + } + } + + pub fn handlekeys_text_area(&mut self, input: Input) { + match input.key { + Key::Tab => { + self.current_input = (self.current_input + 1) % self.inputs.len(); + self.sync_cursor(self.current_input); + } + _ => { + self.inputs[self.current_input].input(input); + } + } + } + + pub fn handlekeys_text_input(&mut self, input: Input) { + match input.key { + Key::Tab | Key::Enter => { + self.current_input = (self.current_input + 1) % self.inputs.len(); + self.sync_cursor(self.current_input); + } + _ => { + self.inputs[self.current_input].input(input); + } + } + } } impl App { pub fn init_add_popup(&mut self) { self.app_focus = AppFocus::AddPopup; self.add_popup_state = Some(AddPopupState { + folder: None, + icon: None, + name: None, + preset: None, + filename: None, + + preset_index: 0, + preset_filter: String::new(), inputs: vec![ TextArea::default(), // folder - TextArea::default(), // preset + TextArea::default(), // preset (not used) TextArea::default(), // icon TextArea::default(), // filename TextArea::default(), // name ], + presets_filtered: PRESETS_OPTIONS.to_vec(), current_input: 0, }); @@ -49,48 +135,27 @@ impl App { pub fn handlekeys_add_popup(&mut self, input: Input) { if let Some(state) = self.add_popup_state.as_mut() { + let _input = input.clone(); + + match state.current_input { + 1 => state.handlekeys_preset_input(_input), + 2 => state.handlekeys_text_area(_input), + _ => state.handlekeys_text_input(_input), + } + match input.key { - Key::Enter => { - // Save current input - let current_text = state.inputs[state.current_input].lines().join("\n"); - if state.current_input < state.inputs.len() - 1 { - state.current_input += 1; - state.sync_cursor(state.current_input); - } else { - // Submit form - let name = state.inputs[4].lines().join("\n"); - let file_path = state.inputs[2].lines().join("\n"); - self.items.push(IconEntry { name, file_path }); - self.app_focus = AppFocus::Main; - self.add_popup_state = None; - } - } - Key::Tab => { - let mut forwards: i32 = 1; - if input.shift { - forwards = -1; - } - // Cycle - state.current_input = ((state.current_input as i32 + forwards) - .rem_euclid(state.inputs.len() as i32)) - as usize; - state.sync_cursor(state.current_input); - } Key::Esc => { self.app_focus = AppFocus::Main; self.add_popup_state = None; } - _ => { - state.inputs[state.current_input].input(input); - // state.inputs[state.current_input].input - } + _ => {} } } } } pub fn render_add_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 60, 50); + let area = popup_area(f.area(), 70, 30); f.render_widget(ratatui::widgets::Clear, area); let layout = ratatui::layout::Layout::default() @@ -99,8 +164,8 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .constraints([ Constraint::Length(3), // Title Constraint::Length(3), // Folder - Constraint::Length(3), // Preset - Constraint::Length(3), // Icon + Constraint::Length(7), // Preset + Constraint::Length(5), // Icon Constraint::Length(3), // Filename Constraint::Length(3), // Name Constraint::Min(0), // Help text @@ -113,12 +178,16 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { f.render_widget(title, area); if let Some(state) = &mut app.add_popup_state { - let labels = [ - "Folder", - "Preset", - "Icon (Iconify name / URL / raw SVG)", - "Filename", - "Name", + let labels: Vec = vec![ + String::from(" Folder"), + if state.preset_filter.is_empty() { + format!(" Preset") + } else { + format!(" Preset 🔍 {}", state.preset_filter) + }, + String::from(" Icon (iconify name / URL / raw SVG / empty)"), + String::from(" Filename"), + String::from(" Name"), ]; // Render each field individually with textarea @@ -129,20 +198,56 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { } else { Style::default() }) - .title(labels[0]); + .title(labels[0].clone()); state.inputs[0].set_block(folder_block); - f.render_widget(state.inputs[0].widget(), layout[1]); + f.render_widget(&state.inputs[0], layout[1]); - let preset_block = Block::default() - .borders(Borders::ALL) - .border_style(if state.current_input == 1 { - Style::default().fg(Color::Yellow) - } else { - Style::default() - }) - .title(labels[1]); - state.inputs[1].set_block(preset_block); - f.render_widget(state.inputs[1].widget(), layout[2]); + // Create a selectable list for preset + let mut state_store = ratatui::widgets::ListState::default(); + let mut items: Vec = { + let filtered: Vec<_> = PRESETS_OPTIONS + .iter() + .filter(|p| { + let filter = state.preset_filter.to_lowercase(); + filter.is_empty() + || filter.contains(p.preset.to_str()) + || p.description.to_lowercase().contains(&filter) + }) + .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} - {}", + format!("{:?}", p.preset), + p.description + )) + }) + .collect(); + filtered + }; + if items.is_empty() { + state_store.select(None); + items = + vec![ListItem::new("No presets found").style(Style::default().fg(Color::DarkGray))]; + } else { + state_store.select(Some(state.preset_index)) + }; + let mut list = ratatui::widgets::List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(labels[1].clone()) + .border_style(if state.current_input == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }), + ) + .highlight_symbol("→ "); + if state.current_input == 1 { + list = list.highlight_style(Style::default().bg(Color::DarkGray)) + } + + f.render_stateful_widget(list, layout[2], &mut state_store); let icon_block = Block::default() .borders(Borders::ALL) @@ -151,9 +256,9 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { } else { Style::default() }) - .title(labels[2]); + .title(labels[2].clone()); state.inputs[2].set_block(icon_block); - f.render_widget(state.inputs[2].widget(), layout[3]); + f.render_widget(&state.inputs[2], layout[3]); let filename_block = Block::default() .borders(Borders::ALL) @@ -162,10 +267,11 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { } else { Style::default() }) - .title(labels[3]); + .title(labels[3].clone()); state.inputs[3].set_block(filename_block); - f.render_widget(state.inputs[3].widget(), layout[4]); + f.render_widget(&state.inputs[3], layout[4]); + let name_value = state.inputs[4].lines().join(""); let name_block = Block::default() .borders(Borders::ALL) .border_style(if state.current_input == 4 { @@ -173,12 +279,20 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { } else { Style::default() }) - .title(labels[4]); + .title(format!("{}", labels[4])) + .title( + Line::from(if name_value.is_empty() { + String::from("ex. ") + } else { + format!("ex. ", name_value) + }) + .alignment(Alignment::Right), + ); state.inputs[4].set_block(name_block); - f.render_widget(state.inputs[4].widget(), layout[5]); + f.render_widget(&state.inputs[4], layout[5]); } - let help_text = Paragraph::new("Type and press Enter to continue | ESC to cancel") + let help_text = Paragraph::new("Tab/Enter to Continue | ESC to cancel") .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); f.render_widget(help_text, layout[6]); diff --git a/src/views/main.rs b/src/views/main.rs index 39d8002..853d2de 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -40,8 +40,9 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { ListItem::new("↑↓ - Navigate (or k,j)"), ListItem::new("? - Help"), ListItem::new("/ - Search"), + ListItem::new("q - Quit"), ]; - let list = List::new(items).highlight_symbol(">> "); + let list = List::new(items).highlight_symbol("→ "); let list_block = Block::default() .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded); @@ -104,7 +105,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .border_type(ratatui::widgets::BorderType::Rounded), ) .column_spacing(2) - .highlight_symbol(">> ") + .highlight_symbol("→ ") .row_highlight_style(Style::default().bg(Color::DarkGray)); let mut state = ratatui::widgets::TableState::default(); From 3d9c39f00182c74d9412db3a403b9f8530ed8247 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 22 Oct 2025 11:45:47 +0800 Subject: [PATCH 03/28] feat: better paste --- Cargo.lock | 349 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/views/add_popup.rs | 28 +++- 3 files changed, 373 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1219fb5..a7dd095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,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" @@ -180,6 +200,18 @@ 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" @@ -256,6 +288,15 @@ 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" @@ -328,6 +369,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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" @@ -396,6 +446,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.20.11" @@ -458,6 +514,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[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" @@ -521,6 +587,12 @@ 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" @@ -537,6 +609,45 @@ 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" @@ -630,6 +741,16 @@ dependencies = [ "thread_local", ] +[[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" @@ -702,6 +823,17 @@ 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" @@ -842,6 +974,7 @@ name = "iconmate" version = "1.0.1" dependencies = [ "anyhow", + "arboard", "assert_fs", "clap", "color-eyre", @@ -986,6 +1119,20 @@ 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" @@ -1162,6 +1309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1176,6 +1324,16 @@ dependencies = [ "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" @@ -1208,6 +1366,79 @@ 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 = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "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]] name = "object" version = "0.36.7" @@ -1332,6 +1563,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" @@ -1380,6 +1624,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" @@ -1721,6 +1980,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" @@ -1869,6 +2134,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" @@ -2259,6 +2538,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" @@ -2296,13 +2581,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", ] @@ -2313,7 +2604,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]] @@ -2322,7 +2613,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]] @@ -2495,6 +2786,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" @@ -2519,6 +2827,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" @@ -2578,3 +2906,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 d979b5f..b6324cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ ratatui = "0.29.0" crossterm = "0.29.0" color-eyre = "0.6.5" tui-textarea = "0.7.0" +arboard = "3.6.1" [dev-dependencies] tempfile = "3.8" diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index ae57b46..8e2f3ac 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -86,6 +86,14 @@ impl AddPopupState { self.current_input = (self.current_input + 1) % self.inputs.len(); self.sync_cursor(self.current_input); } + Key::Char('v') if input.ctrl || input.alt => { + // Cmd+V on macOS (alt+v in crossterm), Ctrl+V on Linux/Windows . ⌘ + if let Ok(mut ctx) = arboard::Clipboard::new() { + if let Ok(text) = ctx.get_text() { + self.inputs[self.current_input].insert_str(&text); + } + } + } _ => { self.inputs[self.current_input].input(input); } @@ -252,11 +260,27 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { let icon_block = Block::default() .borders(Borders::ALL) .border_style(if state.current_input == 2 { - Style::default().fg(Color::Yellow) + Style::default().fg(Color::Green) } else { Style::default() }) - .title(labels[2].clone()); + .title(labels[2].clone()) + .title_bottom( + Line::from(if state.current_input == 2 { + "Tab to continue" + } else { + "" + }) + .alignment(Alignment::Right), + ) + .title_bottom( + Line::from(if state.current_input == 2 { + "ctrl+v to paste" + } else { + "" + }) + .alignment(Alignment::Left), + ); state.inputs[2].set_block(icon_block); f.render_widget(&state.inputs[2], layout[3]); From f2eabaea481d5146be9457eb2bf2df6e43a8bccb Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 22 Oct 2025 21:24:32 +0800 Subject: [PATCH 04/28] feat: added inferred name in filename. --- src/utils.rs | 29 +++++++++++++++ src/views/add_popup.rs | 84 +++++++++++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 921696b..5d36d88 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -210,3 +210,32 @@ pub fn _make_svg_filename( (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::Svg => "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() +} diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index 8e2f3ac..1b52559 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -3,7 +3,6 @@ use std::sync::mpsc::sync_channel; use crate::app_state::{App, AppFocus}; use crate::utils::{IconEntry, PRESETS_OPTIONS, Preset, PresetOption, popup_area}; use ratatui::Frame; -use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; use ratatui::text::Line; @@ -44,6 +43,13 @@ impl AddPopupState { if self.current_input == 1 { 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); } @@ -73,6 +79,17 @@ impl AddPopupState { if self.current_input == 1 { self.inputs[1].input(input); self.preset_filter = self.inputs[1].lines().join("\n"); + + self.presets_filtered = PRESETS_OPTIONS + .iter() + .filter(|opt| { + let filter = self.preset_filter.to_lowercase(); + filter.is_empty() + || filter.contains(opt.preset.to_str()) + || opt.description.to_lowercase().contains(&filter) + }) + .cloned() + .collect(); return; } } @@ -83,6 +100,8 @@ impl AddPopupState { pub fn handlekeys_text_area(&mut self, input: Input) { match input.key { Key::Tab => { + // Save the icon value + self.icon = Some(self.inputs[2].lines().join("\n")); self.current_input = (self.current_input + 1) % self.inputs.len(); self.sync_cursor(self.current_input); } @@ -103,6 +122,14 @@ impl AddPopupState { pub fn handlekeys_text_input(&mut self, input: Input) { 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 + 3 => self.filename = Some(value), // filename + 4 => self.name = Some(value), // name + _ => {} + } self.current_input = (self.current_input + 1) % self.inputs.len(); self.sync_cursor(self.current_input); } @@ -170,13 +197,14 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .direction(ratatui::layout::Direction::Vertical) .margin(2) .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Folder - Constraint::Length(7), // Preset - Constraint::Length(5), // Icon - Constraint::Length(3), // Filename - Constraint::Length(3), // Name - Constraint::Min(0), // Help text + Constraint::Length(3), // Title + Constraint::Length(3), // Folder + Constraint::Length(7), // Preset + Constraint::Length(5), // Icon + Constraint::Length(3), // Filename + Constraint::Length(3), // Name + Constraint::Min(0), // Help text + Constraint::Length(20), // ]) .split(area); @@ -198,6 +226,20 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { String::from(" Name"), ]; + // Debug block - shows all saved values for development + let debug_text = format!( + "folder: {:?}\npreset: {:?}\nicon: {:?}\nfilename: {:?}\nname: {:?}", + state.folder, state.preset, state.icon, state.filename, state.name + ); + let debug_block = Block::default() + .borders(Borders::ALL) + .title("Debug Values") + .style(Style::default().fg(Color::DarkGray)); + let debug_paragraph = Paragraph::new(debug_text) + .block(debug_block) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(debug_paragraph, layout[7]); + // Render each field individually with textarea let folder_block = Block::default() .borders(Borders::ALL) @@ -208,19 +250,15 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }) .title(labels[0].clone()); state.inputs[0].set_block(folder_block); + state.inputs[0].set_cursor_line_style(Style::default()); f.render_widget(&state.inputs[0], layout[1]); // Create a selectable list for preset let mut state_store = ratatui::widgets::ListState::default(); let mut items: Vec = { - let filtered: Vec<_> = PRESETS_OPTIONS + let filtered: Vec<_> = state + .presets_filtered .iter() - .filter(|p| { - let filter = state.preset_filter.to_lowercase(); - filter.is_empty() - || filter.contains(p.preset.to_str()) - || p.description.to_lowercase().contains(&filter) - }) .map(|p| { ListItem::new(format!( // https://stackoverflow.com/questions/50458144/what-is-the-easiest-way-to-pad-a-string-with-0-to-the-left @@ -282,6 +320,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .alignment(Alignment::Left), ); state.inputs[2].set_block(icon_block); + state.inputs[2].set_cursor_line_style(Style::default()); f.render_widget(&state.inputs[2], layout[3]); let filename_block = Block::default() @@ -291,8 +330,16 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { } else { Style::default() }) - .title(labels[3].clone()); + .title(labels[3].clone()) + .title( + Line::from(crate::utils::filename_from_preset( + Some(state.inputs[3].lines().join("")), + state.preset.clone(), + )) + .alignment(Alignment::Right), + ); state.inputs[3].set_block(filename_block); + state.inputs[3].set_cursor_line_style(Style::default()); f.render_widget(&state.inputs[3], layout[4]); let name_value = state.inputs[4].lines().join(""); @@ -306,13 +353,14 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .title(format!("{}", labels[4])) .title( Line::from(if name_value.is_empty() { - String::from("ex. ") + String::from("usage: ") } else { - format!("ex. ", name_value) + format!("usage: ", name_value) }) .alignment(Alignment::Right), ); state.inputs[4].set_block(name_block); + state.inputs[4].set_cursor_line_style(Style::default()); f.render_widget(&state.inputs[4], layout[5]); } From 56a1c954755c6d2312f13822b732dbe756e42332 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 23 Oct 2025 01:32:41 +0800 Subject: [PATCH 05/28] feat: Add config, search, delete popup, load icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce `AppConfig` to pass folder, preset, and template from CLI. - Load existing icons from `index.ts` and support live filtering. - Implement search mode with incremental filtering of the icon list. - Add a delete‑popup UI and logic to remove icons from disk and index. - Update `.gitignore`, main entry point, and UI rendering for the new features. --- .gitignore | 1 + src/app_state.rs | 93 ++++++++++++++++--------- src/main.rs | 59 ++++------------ src/tui.rs | 15 ++-- src/utils.rs | 140 +++++++++++++++++++++++++++++++++++++- src/views/delete_popup.rs | 126 ++++++++++++++++++++++++++++++++++ src/views/main.rs | 75 ++++++++++++++++++-- src/views/mod.rs | 1 + 8 files changed, 419 insertions(+), 91 deletions(-) create mode 100644 src/views/delete_popup.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..f4a9dad 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +src/assets/icons diff --git a/src/app_state.rs b/src/app_state.rs index ad80dae..1dee36b 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,7 +1,7 @@ use crate::utils::IconEntry; use crossterm::event::KeyEvent; use std::sync::mpsc::Receiver; -use tui_textarea::Input; +use tui_textarea::{Input, Key}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum AppFocus { @@ -12,65 +12,63 @@ pub enum AppFocus { Search, } +#[derive(Debug, Clone)] +pub struct AppConfig { + pub folder: String, + pub preset: Option, + pub template: Option, +} + pub struct App { + pub config: AppConfig, + + // App state pub rx: Receiver<()>, pub should_quit: bool, + // Main State (actually.. could move it there too) pub selected_index: usize, + pub search_items_value: String, pub items: Vec, - + pub filtered_items: Vec, pub app_focus: AppFocus, + // Deeper states pub add_popup_state: Option, + pub delete_popup_state: Option, } impl App { - pub fn new() -> Self { + pub fn new(config: AppConfig) -> Self { let (_tx, rx) = std::sync::mpsc::channel(); let mut app = Self { + config, + should_quit: false, rx: rx, selected_index: 0, + search_items_value: String::from(""), + filtered_items: Vec::new(), items: Vec::new(), app_focus: AppFocus::Main, add_popup_state: None, + delete_popup_state: None, }; app.init_icons(); app } - fn init_icons(&mut self) { - // TODO, read the current project - self.items = vec![ - IconEntry { - name: "Heart".to_string(), - file_path: "material:heart.svg".to_string(), - }, - IconEntry { - name: "Star".to_string(), - file_path: "material:star.svg".to_string(), - }, - IconEntry { - name: "Home".to_string(), - file_path: "material:home.svg".to_string(), - }, - IconEntry { - name: "User".to_string(), - file_path: "material:user.svg".to_string(), - }, - IconEntry { - name: "Mail".to_string(), - file_path: "material:mail.svg".to_string(), - }, - IconEntry { - name: "Save".to_string(), - file_path: "material:save.svg".to_string(), - }, - ]; + pub fn init_icons(&mut self) { + // Try to read the current project's export file + self.items = match crate::utils::get_existing_icons(&self.config.folder) { + Ok(icons) => icons, + Err(_) => Vec::new(), + }; + self.filtered_items = self.items.clone(); } pub fn update(&mut self) {} @@ -85,9 +83,36 @@ impl App { } } - fn handlekeys_delete_popup(&mut self, _key: Input) {} - fn handlekeys_help_popup(&mut self, _key: Input) {} - fn handlekeys_search(&mut self, _key: Input) {} + fn handlekeys_search(&mut self, input: Input) { + match input.key { + Key::Esc => { + self.app_focus = AppFocus::Main; + self.search_items_value.clear(); + } + Key::Char(c) => { + self.search_items_value.push(c); + self.update_filtered_items(); + } + Key::Backspace => { + self.search_items_value.pop(); + self.update_filtered_items(); + } + Key::Enter => { + self.app_focus = AppFocus::Main; + } + _ => {} + } + } + + fn update_filtered_items(&mut self) { + let filter = self.search_items_value.to_lowercase(); + self.filtered_items = self + .items + .iter() + .filter(|entry| entry.name.to_lowercase().contains(&filter)) + .cloned() + .collect() + } } diff --git a/src/main.rs b/src/main.rs index b566f04..38b2bbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,45 +462,6 @@ impl std::fmt::Display for IconEntry { 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(), - }); - } - } - } - } - None -} /// Interactive mode: deleting an icon from a select list of icons. async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { @@ -534,7 +495,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); } } @@ -586,7 +547,7 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { } // Remove the export line from index.ts content - if let Some(line_to_remove) = _parse_export_line(&contents) { + if let Some(line_to_remove) = crate::utils::parse_export_line_ts(&contents) { if line_to_remove.name == icon_to_delete.name && line_to_remove.file_path == icon_to_delete.file_path { @@ -649,9 +610,19 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Prompt {}) => run_prompt_mode(&args).await, Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await, None => { - tui::run().await - // run_prompt_mode(&args).await - // run_prompt_mode(&args).await + let config = app_state::AppConfig { + folder: args + .folder + .unwrap_or_else(|| PathBuf::from("src/assets/icons")) + .display() + .to_string(), + preset: match args.preset { + Some(p) => Some(format!("{:?}", p)), + None => None, + }, + template: Some(args.output_line_template), + }; + tui::run(config).await } } } diff --git a/src/tui.rs b/src/tui.rs index 5408bd1..78bb23c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,5 +1,5 @@ use crate::{ - app_state::{App, AppFocus}, + app_state::{App, AppConfig, AppFocus}, views::main::{render_main_view, render_sidebar}, }; use crossterm::{ @@ -11,14 +11,14 @@ use ratatui::{Frame, Terminal, backend::CrosstermBackend, layout::Constraint}; use std::io; use tui_textarea::{Input, Key}; -pub async fn run() -> Result<(), anyhow::Error> { +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(); + let mut app = App::new(config); loop { terminal.draw(|f| ui(f, &mut app))?; @@ -54,10 +54,15 @@ fn ui(f: &mut Frame, app: &mut App) { .constraints([Constraint::Max(37), Constraint::Min(0)]) .split(area); + // Pages render_sidebar(f, layout[0], app); render_main_view(f, layout[1], app); - if app.app_focus == AppFocus::AddPopup { - crate::views::add_popup::render_add_popup(f, 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), + _ => {} } } diff --git a/src/utils.rs b/src/utils.rs index 5d36d88..c863d32 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -82,7 +82,7 @@ pub fn popup_area(area: Rect, max_width: u16, max_height: u16) -> Rect { } /// Struct to hold icon information for deletion -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct IconEntry { pub name: String, pub file_path: String, @@ -239,3 +239,141 @@ pub fn filename_from_preset(file_name: Option, preset: Option) - "".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 name and absolute 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; + } + + if let Some(icon_entry) = parse_export_line_ts(&line) { + icons.push(icon_entry); + } + } + + Ok(icons) +} + +/// For parsing a single export line in typescript. +pub fn parse_export_line_ts(line: &str) -> Option { + use std::path::Path; + + // Log the line + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.trim_start().starts_with("//") { + return None; + } + + // Example line: export { default as IconGitHub } from "./devicon:github.svg"; + // We look for: export { default as } from ""; + let parts: Vec<&str> = line.splitn(5, ' ').collect(); + if parts.len() >= 5 + && parts[0] == "export" + && parts[1] == "{" + && parts[3] == "as" + && parts[2] == "default" + { + if let Some(name_end) = line.find('}') { + let name = line["export { default as ".len()..name_end].trim(); + if let Some(from_start) = line[name_end..].find("from \"") { + let from_start = name_end + from_start + "from \"".len(); + if let Some(path_end) = line[from_start..].find('"') { + let relative_path = &line[from_start..from_start + path_end]; + let abs_path = Path::new(relative_path) + .canonicalize() + .unwrap_or_else(|_| Path::new(relative_path).to_path_buf()); + + return Some(IconEntry { + name: name.to_string(), + file_path: abs_path.to_string_lossy().to_string(), + }); + } + } + } + } + None +} + +// 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 + if path.exists() { + fs::remove_file(path)?; + println!("Deleted icon file: {}", path.display()); + } else { + eprintln!("Icon file not found: {}", path.display()); + return Ok(()); + } + + // 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 = if file_path.starts_with(parent.to_string_lossy().as_ref()) { + &file_path[parent.to_string_lossy().len() + 1..] + } else { + file_path + }; + + // Create the normalized relative path for comparison + let normalized_relative_path = relative_path.replace('\\', "/"); + let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + + // Remove all lines that export this file + let mut lines_to_keep = Vec::new(); + let mut found_export = false; + + for line in contents.lines() { + // Check if this line exports our file + if line.contains(&normalized_relative_path) + || (line.contains(file_name) && line.contains("export")) + { + found_export = true; + continue; // Skip this line (remove it) + } + lines_to_keep.push(line); + } + + if found_export { + // Write the updated content back + let updated_content = lines_to_keep.join("\n"); + fs::write(&index_path, updated_content)?; + println!("Updated index.ts"); + } + } + } + + Ok(()) +} diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs new file mode 100644 index 0000000..f1ca671 --- /dev/null +++ b/src/views/delete_popup.rs @@ -0,0 +1,126 @@ +use crate::app_state::{App, AppFocus}; +use crate::utils::popup_area; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, ListItem}; +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, + }); + } + } + + 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') | Key::Enter => { + // Perform delete action + self.app_focus = AppFocus::Main; + + // 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 file_path = item.file_path.clone(); + if let Err(e) = crate::utils::delete_icon_entry(&file_path) { + eprintln!("Failed to delete icon file: {}", e); + } + } + } + + // Re-initialize icons from disk to ensure consistency + self.init_icons(); + self.delete_popup_state = None; + } + Key::Char('n') | Key::Esc => { + // Cancel + self.app_focus = AppFocus::Main; + self.delete_popup_state = None; + } + Key::Up | Key::Char('k') => { + state.selected_index = state.selected_index.saturating_sub(1); + } + 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) { + let area = popup_area(f.area(), 60, 12); + f.render_widget(ratatui::widgets::Clear, area); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Min(0), // List + Constraint::Length(1), // Help + ]) + .split(area); + + let title = Block::bordered() + .title(format!("Delete Icon")) + .border_type(ratatui::widgets::BorderType::Rounded); + f.render_widget(title, area); + + if let Some(state) = &mut app.delete_popup_state { + let items = vec![ + // ListItem::new(format!("y Delete this icon ({})", state.selected_item.name)), + ListItem::new(format!( + "y Delete the icon '{}'", + state + .item_to_delete + .as_ref() + .map(|item| item.name.as_str()) + .unwrap_or("Name") + )), + ListItem::new("n Cancel"), + ]; + + let mut list_state = ratatui::widgets::ListState::default(); + list_state.select(Some(state.selected_index)); + + let list_block = ratatui::widgets::List::new(items) + .block(Block::default()) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("→ "); + + f.render_stateful_widget(list_block, layout[0], &mut list_state); + } + + let help_text = + ratatui::widgets::Paragraph::new("y/n or j/k to select | Enter to confirm | Esc to cancel") + .alignment(ratatui::layout::Alignment::Center) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help_text, layout[1]); +} diff --git a/src/views/main.rs b/src/views/main.rs index 853d2de..f858597 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -6,23 +6,49 @@ use ratatui::{ }; use tui_textarea::{Input, Key}; -use crate::app_state::App; - +use crate::app_state::{App, AppFocus}; impl App { pub fn handlekeys_main(&mut self, input: Input) { + match self.app_focus { + AppFocus::Search => {} // Dirty, but uh it's fine + _ => self.handlekeys_normal(input), + } + } + + fn handlekeys_normal(&mut self, input: Input) { match input.key { Key::Char('q') => self.should_quit = true, Key::Char('a') => { self.init_add_popup(); } + Key::Char('d') => { + self.init_delete_popup(); + } + Key::Char('/') => { + self.app_focus = AppFocus::Search; + } Key::Up | Key::Char('k') => { + let item_count = if !self.filtered_items.is_empty() { + self.filtered_items.len() + } else { + self.items.len() + }; if self.selected_index > 0 { self.selected_index -= 1; + } else { + self.selected_index = item_count.saturating_sub(1); } } Key::Down | Key::Char('j') => { - if self.selected_index < self.items.len().saturating_sub(1) { + let item_count = if !self.filtered_items.is_empty() { + self.filtered_items.len() + } else { + self.items.len() + }; + if self.selected_index < item_count.saturating_sub(1) { self.selected_index += 1; + } else { + self.selected_index = 0; } } _ => {} @@ -53,10 +79,13 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { .border_type(ratatui::widgets::BorderType::Rounded); let inner_list = List::new(vec![ ListItem::new("Folder"), - ListItem::new("src/assets/icons"), + ListItem::new(_app.config.folder.as_str()), ListItem::new(""), ListItem::new("Preset"), - ListItem::new("react-tsx"), + ListItem::new(match &_app.config.preset { + Some(p) => p.as_str(), + None => "", + }), ]); let vertical_layout = ratatui::layout::Layout::default() @@ -87,7 +116,39 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .style(Style::default().fg(Color::White)) .height(1); - let rows = app.items.iter().map(|item| { + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(area); + + let search_display = if app.search_items_value.is_empty() && app.app_focus != AppFocus::Search { + String::new() + } else { + let prefix = if app.app_focus == AppFocus::Search { + "🔍 Search: " + } else { + "🔍 " + }; + format!("{}{}", prefix, app.search_items_value) + }; + let mut search_style = Style::default().fg(Color::White); + if app.app_focus == AppFocus::Search { + search_style = search_style.bg(Color::DarkGray); + } + let search_paragraph = Paragraph::new(search_display.as_str()) + .style(search_style) + .alignment(Alignment::Left); + f.render_widget(search_paragraph, main_chunks[0]); + + let item_list = if app.filtered_items.is_empty() && !app.search_items_value.is_empty() { + &app.filtered_items + } else if app.search_items_value.is_empty() { + &app.items + } else { + &app.filtered_items + }; + + let rows = item_list.iter().map(|item| { Row::new(vec![ Cell::from(item.name.as_str()), Cell::from(item.file_path.as_str()), @@ -110,5 +171,5 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { let mut state = ratatui::widgets::TableState::default(); state.select(Some(app.selected_index)); - f.render_stateful_widget(table, area, &mut state); + f.render_stateful_widget(table, main_chunks[1], &mut state); } diff --git a/src/views/mod.rs b/src/views/mod.rs index f8da0ac..1d4901a 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,2 +1,3 @@ pub mod add_popup; +pub mod delete_popup; pub mod main; From c72c9be0ba0c21e5b485c55071e06e15bd36fcc3 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 26 Oct 2025 14:30:47 +0800 Subject: [PATCH 06/28] feat: massive progress in putting state into views/main.rs + better text input for search. --- src/app_state.rs | 43 ++-------- src/main.rs | 5 +- src/tui.rs | 17 +++- src/utils.rs | 6 +- src/views/add_popup.rs | 87 +++++++++++--------- src/views/delete_popup.rs | 73 +++++++++++------ src/views/main.rs | 167 ++++++++++++++++++++++++++++---------- 7 files changed, 247 insertions(+), 151 deletions(-) diff --git a/src/app_state.rs b/src/app_state.rs index 1dee36b..98f70f9 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,4 +1,4 @@ -use crate::utils::IconEntry; +use crate::{utils::IconEntry, views::main::MainState}; use crossterm::event::KeyEvent; use std::sync::mpsc::Receiver; use tui_textarea::{Input, Key}; @@ -9,7 +9,6 @@ pub enum AppFocus { AddPopup, DeletePopup, HelpPopup, - Search, } #[derive(Debug, Clone)] @@ -27,14 +26,16 @@ pub struct App { pub should_quit: bool, - // Main State (actually.. could move it there too) pub selected_index: usize, - pub search_items_value: String, + 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, } @@ -49,13 +50,13 @@ impl App { rx: rx, selected_index: 0, - search_items_value: String::from(""), filtered_items: Vec::new(), items: Vec::new(), app_focus: AppFocus::Main, add_popup_state: None, delete_popup_state: None, + main_state: MainState::new(), }; app.init_icons(); @@ -79,40 +80,8 @@ impl App { AppFocus::AddPopup => self.handlekeys_add_popup(key), AppFocus::DeletePopup => self.handlekeys_delete_popup(key), AppFocus::HelpPopup => self.handlekeys_help_popup(key), - AppFocus::Search => self.handlekeys_search(key), } } fn handlekeys_help_popup(&mut self, _key: Input) {} - - fn handlekeys_search(&mut self, input: Input) { - match input.key { - Key::Esc => { - self.app_focus = AppFocus::Main; - self.search_items_value.clear(); - } - Key::Char(c) => { - self.search_items_value.push(c); - self.update_filtered_items(); - } - Key::Backspace => { - self.search_items_value.pop(); - self.update_filtered_items(); - } - Key::Enter => { - self.app_focus = AppFocus::Main; - } - _ => {} - } - } - - fn update_filtered_items(&mut self) { - let filter = self.search_items_value.to_lowercase(); - self.filtered_items = self - .items - .iter() - .filter(|entry| entry.name.to_lowercase().contains(&filter)) - .cloned() - .collect() - } } diff --git a/src/main.rs b/src/main.rs index 38b2bbd..42e077c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -540,10 +540,10 @@ 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()); + eprintln!("File not found: {}", full_path.display()); } // Remove the export line from index.ts content @@ -579,7 +579,6 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { // Write the updated index.ts fs::write(&index_ts_path, updated_index_content)?; - println!("Updated index.ts"); Ok(()) } diff --git a/src/tui.rs b/src/tui.rs index 78bb23c..2c34362 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -47,11 +47,24 @@ pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { fn ui(f: &mut Frame, app: &mut App) { let area = f.area(); + let width = area.width; + + let (direction, constraints) = if width < 80 { + ( + ratatui::layout::Direction::Vertical, + vec![Constraint::Min(3), Constraint::Min(0)], + ) + } else { + ( + ratatui::layout::Direction::Horizontal, + vec![Constraint::Max(37), Constraint::Min(0)], + ) + }; let layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) + .direction(direction) .margin(1) - .constraints([Constraint::Max(37), Constraint::Min(0)]) + .constraints(constraints) .split(area); // Pages diff --git a/src/utils.rs b/src/utils.rs index c863d32..e7194a6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -326,9 +326,9 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { // Delete the icon file if path.exists() { fs::remove_file(path)?; - println!("Deleted icon file: {}", path.display()); + // eprintln!("Deleted icon file: {}", path.display()); } else { - eprintln!("Icon file not found: {}", path.display()); + // eprintln!("Icon file not found: {}", path.display()); return Ok(()); } @@ -370,7 +370,7 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { // Write the updated content back let updated_content = lines_to_keep.join("\n"); fs::write(&index_path, updated_content)?; - println!("Updated index.ts"); + // println!("Updated index.ts"); } } } diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index 1b52559..d84a87b 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -9,6 +9,15 @@ use ratatui::text::Line; use ratatui::widgets::{Block, Borders, ListItem, Paragraph}; use tui_textarea::{Input, Key, TextArea}; +// Constants +const FOLDER_FIELD_IDX: usize = 0; +const PRESET_FIELD_IDX: usize = 1; +const ICON_FIELD_IDX: usize = 2; +const FILENAME_FIELD_IDX: usize = 3; +const NAME_FIELD_IDX: usize = 4; +const HELP_FIELD_IDX: usize = 5; +const DEBUG_FIELD_IDX: usize = 6; + #[derive(Debug)] pub struct AddPopupState { // Saved values @@ -164,7 +173,7 @@ impl App { }); // Set default value for folder input - self.add_popup_state.as_mut().unwrap().inputs[0].insert_str("src/assets/icons"); + self.add_popup_state.as_mut().unwrap().inputs[0].insert_str(&self.config.folder); self.add_popup_state.as_mut().unwrap().sync_cursor(0); } @@ -173,8 +182,8 @@ impl App { let _input = input.clone(); match state.current_input { - 1 => state.handlekeys_preset_input(_input), - 2 => state.handlekeys_text_area(_input), + PRESET_FIELD_IDX => state.handlekeys_preset_input(_input), + ICON_FIELD_IDX => state.handlekeys_text_area(_input), _ => state.handlekeys_text_input(_input), } @@ -197,14 +206,13 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .direction(ratatui::layout::Direction::Vertical) .margin(2) .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Folder - Constraint::Length(7), // Preset - Constraint::Length(5), // Icon - Constraint::Length(3), // Filename - Constraint::Length(3), // Name - Constraint::Min(0), // Help text - Constraint::Length(20), // + Constraint::Length(3), // Folder + Constraint::Length(7), // Preset + Constraint::Length(5), // Icon + Constraint::Length(3), // Filename + Constraint::Length(3), // Name + Constraint::Min(0), // Help text + Constraint::Length(5), // Debug ]) .split(area); @@ -238,20 +246,20 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { let debug_paragraph = Paragraph::new(debug_text) .block(debug_block) .style(Style::default().fg(Color::DarkGray)); - f.render_widget(debug_paragraph, layout[7]); + f.render_widget(debug_paragraph, layout[DEBUG_FIELD_IDX]); // Render each field individually with textarea let folder_block = Block::default() .borders(Borders::ALL) - .border_style(if state.current_input == 0 { + .border_style(if state.current_input == FOLDER_FIELD_IDX { Style::default().fg(Color::Yellow) } else { Style::default() }) - .title(labels[0].clone()); - state.inputs[0].set_block(folder_block); - state.inputs[0].set_cursor_line_style(Style::default()); - f.render_widget(&state.inputs[0], layout[1]); + .title(labels[FOLDER_FIELD_IDX].clone()); + state.inputs[FOLDER_FIELD_IDX].set_block(folder_block); + state.inputs[FOLDER_FIELD_IDX].set_cursor_line_style(Style::default()); + f.render_widget(&state.inputs[FOLDER_FIELD_IDX], layout[FOLDER_FIELD_IDX]); // Create a selectable list for preset let mut state_store = ratatui::widgets::ListState::default(); @@ -282,29 +290,29 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { Block::default() .borders(Borders::ALL) .title(labels[1].clone()) - .border_style(if state.current_input == 1 { + .border_style(if state.current_input == PRESET_FIELD_IDX { Style::default().fg(Color::Yellow) } else { Style::default() }), ) .highlight_symbol("→ "); - if state.current_input == 1 { + if state.current_input == PRESET_FIELD_IDX { list = list.highlight_style(Style::default().bg(Color::DarkGray)) } - f.render_stateful_widget(list, layout[2], &mut state_store); + f.render_stateful_widget(list, layout[PRESET_FIELD_IDX], &mut state_store); let icon_block = Block::default() .borders(Borders::ALL) - .border_style(if state.current_input == 2 { + .border_style(if state.current_input == ICON_FIELD_IDX { Style::default().fg(Color::Green) } else { Style::default() }) - .title(labels[2].clone()) + .title(labels[ICON_FIELD_IDX].clone()) .title_bottom( - Line::from(if state.current_input == 2 { + Line::from(if state.current_input == ICON_FIELD_IDX { "Tab to continue" } else { "" @@ -312,7 +320,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .alignment(Alignment::Right), ) .title_bottom( - Line::from(if state.current_input == 2 { + Line::from(if state.current_input == ICON_FIELD_IDX { "ctrl+v to paste" } else { "" @@ -321,36 +329,39 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { ); state.inputs[2].set_block(icon_block); state.inputs[2].set_cursor_line_style(Style::default()); - f.render_widget(&state.inputs[2], layout[3]); + f.render_widget(&state.inputs[ICON_FIELD_IDX], layout[ICON_FIELD_IDX]); let filename_block = Block::default() .borders(Borders::ALL) - .border_style(if state.current_input == 3 { + .border_style(if state.current_input == FILENAME_FIELD_IDX { Style::default().fg(Color::Yellow) } else { Style::default() }) - .title(labels[3].clone()) + .title(labels[FILENAME_FIELD_IDX].clone()) .title( Line::from(crate::utils::filename_from_preset( - Some(state.inputs[3].lines().join("")), + Some(state.inputs[FILENAME_FIELD_IDX].lines().join("")), state.preset.clone(), )) .alignment(Alignment::Right), ); - state.inputs[3].set_block(filename_block); - state.inputs[3].set_cursor_line_style(Style::default()); - f.render_widget(&state.inputs[3], layout[4]); + 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], + layout[FILENAME_FIELD_IDX], + ); - let name_value = state.inputs[4].lines().join(""); + let name_value = state.inputs[NAME_FIELD_IDX].lines().join(""); let name_block = Block::default() .borders(Borders::ALL) - .border_style(if state.current_input == 4 { + .border_style(if state.current_input == NAME_FIELD_IDX { Style::default().fg(Color::Yellow) } else { Style::default() }) - .title(format!("{}", labels[4])) + .title(format!("{}", labels[NAME_FIELD_IDX])) .title( Line::from(if name_value.is_empty() { String::from("usage: ") @@ -359,13 +370,13 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }) .alignment(Alignment::Right), ); - state.inputs[4].set_block(name_block); - state.inputs[4].set_cursor_line_style(Style::default()); - f.render_widget(&state.inputs[4], layout[5]); + 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], layout[NAME_FIELD_IDX]); } let help_text = Paragraph::new("Tab/Enter to Continue | ESC to cancel") .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[6]); + f.render_widget(help_text, layout[HELP_FIELD_IDX]); } diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index f1ca671..ee19b04 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -30,39 +30,57 @@ impl App { } } + 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') | Key::Enter => { + Key::Char('y') => { // Perform delete action - self.app_focus = AppFocus::Main; - - // 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 file_path = item.file_path.clone(); - if let Err(e) = crate::utils::delete_icon_entry(&file_path) { - eprintln!("Failed to delete icon file: {}", e); - } - } - } - - // Re-initialize icons from disk to ensure consistency - self.init_icons(); - self.delete_popup_state = None; + self.perform_delete_action(); + self.close_delete_popup(); } Key::Char('n') | Key::Esc => { // Cancel - self.app_focus = AppFocus::Main; - self.delete_popup_state = None; + 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::Up | Key::Char('k') => { state.selected_index = state.selected_index.saturating_sub(1); @@ -89,7 +107,8 @@ pub fn render_delete_popup(f: &mut Frame, app: &mut App) { .split(area); let title = Block::bordered() - .title(format!("Delete Icon")) + .title(format!("🗑 Delete Icon")) + .title_style(Style::default().fg(Color::White)) .border_type(ratatui::widgets::BorderType::Rounded); f.render_widget(title, area); @@ -112,7 +131,11 @@ pub fn render_delete_popup(f: &mut Frame, app: &mut App) { let list_block = ratatui::widgets::List::new(items) .block(Block::default()) - .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_style(if state.selected_index == 0 { + Style::default().bg(Color::Red) + } else { + Style::default().bg(Color::DarkGray) + }) .highlight_symbol("→ "); f.render_stateful_widget(list_block, layout[0], &mut list_state); diff --git a/src/views/main.rs b/src/views/main.rs index f858597..ed9a83f 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -4,56 +4,123 @@ use ratatui::{ style::{Color, Style}, widgets::{Block, Borders, List, ListItem, Paragraph}, }; -use tui_textarea::{Input, Key}; +use tui_textarea::{Input, Key, TextArea}; -use crate::app_state::{App, AppFocus}; -impl App { - pub fn handlekeys_main(&mut self, input: Input) { - match self.app_focus { - AppFocus::Search => {} // Dirty, but uh it's fine - _ => self.handlekeys_normal(input), +use crate::{ + app_state::{App, AppFocus}, + utils::IconEntry, +}; + +#[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 search_textarea: TextArea<'static>, +} + +impl MainState { + pub fn new() -> Self { + Self { + main_state_focus: MainStateFocus::Normal, + search_items_value: String::new(), + search_textarea: TextArea::default(), + } + } + + pub fn handlekeys_search(&mut self, input: &Input, app: &mut App) { + match input.key { + Key::Esc => { + self.main_state_focus = MainStateFocus::Normal; + app.app_focus = AppFocus::Main; + self.search_textarea = TextArea::default(); + self.search_items_value = String::from(""); + } + Key::Enter => { + app.app_focus = AppFocus::Main; + self.main_state_focus = MainStateFocus::Normal; + } + _ => { + self.search_textarea.input(input.clone()); + self.search_items_value = self.search_textarea.lines().join(""); + self.update_filtered_items(app); + } } } - fn handlekeys_normal(&mut self, input: Input) { + fn handlekeys_normal(&mut self, input: &Input, app: &mut App) { match input.key { - Key::Char('q') => self.should_quit = true, + Key::Char('q') => app.should_quit = true, Key::Char('a') => { - self.init_add_popup(); + app.init_add_popup(); } Key::Char('d') => { - self.init_delete_popup(); + app.init_delete_popup(); } Key::Char('/') => { - self.app_focus = AppFocus::Search; + self.main_state_focus = MainStateFocus::Search; } Key::Up | Key::Char('k') => { - let item_count = if !self.filtered_items.is_empty() { - self.filtered_items.len() + let item_count = if !app.filtered_items.is_empty() { + app.filtered_items.len() } else { - self.items.len() + app.items.len() }; - if self.selected_index > 0 { - self.selected_index -= 1; + if app.selected_index > 0 { + app.selected_index -= 1; } else { - self.selected_index = item_count.saturating_sub(1); + app.selected_index = item_count.saturating_sub(1); } } Key::Down | Key::Char('j') => { - let item_count = if !self.filtered_items.is_empty() { - self.filtered_items.len() + let item_count = if !app.filtered_items.is_empty() { + app.filtered_items.len() } else { - self.items.len() + app.items.len() }; - if self.selected_index < item_count.saturating_sub(1) { - self.selected_index += 1; + if app.selected_index < item_count.saturating_sub(1) { + app.selected_index += 1; } else { - self.selected_index = 0; + app.selected_index = 0; } } _ => {} } } + pub fn update_filtered_items(&mut self, app: &mut App) { + let filter = self.search_items_value.to_lowercase(); + app.filtered_items = app + .items + .iter() + .filter(|entry| { + let case1 = entry.name.to_lowercase().contains(&filter); + let case2 = entry.file_path.contains(&filter); + + case1 || case2 + }) + .cloned() + .collect() + } +} + +impl App { + pub fn handlekeys_main(&mut self, input: Input) { + let main_state_ptr = &mut self.main_state as *mut MainState; // Replace MainState with your actual type + match self.main_state.main_state_focus { + MainStateFocus::Search => { + unsafe { (*main_state_ptr).handlekeys_search(&input, self) }; + } + MainStateFocus::Normal => { + unsafe { (*main_state_ptr).handlekeys_normal(&input, self) }; + } + } + } } pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { @@ -90,11 +157,11 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { let vertical_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .margin(2) + .margin(0) .constraints([ Constraint::Length(3), - Constraint::Min(10), - Constraint::Max(10), + Constraint::Min(8), + Constraint::Length(7), ]) .split(area); @@ -109,6 +176,9 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { use ratatui::widgets::{Cell, Row, Table}; + let main_state = &app.main_state; + let is_searching = main_state.main_state_focus == MainStateFocus::Search; + let header_cells = ["Name", "File"] .iter() .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow))); @@ -121,28 +191,39 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .constraints([Constraint::Length(1), Constraint::Min(0)]) .split(area); - let search_display = if app.search_items_value.is_empty() && app.app_focus != AppFocus::Search { - String::new() + if is_searching { + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + Constraint::Length(11), + Constraint::Fill(1), + Constraint::Min(0), + ]) + .split(main_chunks[0]); + let search_label = Paragraph::new("🔍 Search:") + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left); + let search_enter_paragraph = Paragraph::new("[Enter]") + .style(Style::default().fg(Color::Green)) + .alignment(Alignment::Right); + f.render_widget(search_label, chunks[0]); + f.render_widget(&main_state.search_textarea, chunks[1]); + f.render_widget(search_enter_paragraph, chunks[2]); } else { - let prefix = if app.app_focus == AppFocus::Search { - "🔍 Search: " + let search_display = if main_state.search_items_value.is_empty() { + String::new() } else { - "🔍 " + format!("🔍 {}", main_state.search_items_value) }; - format!("{}{}", prefix, app.search_items_value) - }; - let mut search_style = Style::default().fg(Color::White); - if app.app_focus == AppFocus::Search { - search_style = search_style.bg(Color::DarkGray); + let search_paragraph = Paragraph::new(search_display.as_str()) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left); + f.render_widget(search_paragraph, main_chunks[0]); } - let search_paragraph = Paragraph::new(search_display.as_str()) - .style(search_style) - .alignment(Alignment::Left); - f.render_widget(search_paragraph, main_chunks[0]); - let item_list = if app.filtered_items.is_empty() && !app.search_items_value.is_empty() { + let item_list = if app.filtered_items.is_empty() && !main_state.search_items_value.is_empty() { &app.filtered_items - } else if app.search_items_value.is_empty() { + } else if main_state.search_items_value.is_empty() { &app.items } else { &app.filtered_items From 2d266a7b94afca346106ede4579c5568e8f4d666 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 5 Feb 2026 11:20:27 +0800 Subject: [PATCH 07/28] fix: iconmate conditionally install if not available. --- npm/bin.js | 44 ++++++++++++++++++++++++++++++++------------ npm/install.js | 41 ++++++++++++++++++++++++++++------------- 2 files changed, 60 insertions(+), 25 deletions(-) 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 }; From 1d30eec5b34ee9fc8dd359c5284a7188c35b772f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 5 Feb 2026 11:40:09 +0800 Subject: [PATCH 08/28] feat: better improvements. - centering of logo and tagline. - better postinstall of the npm cli. downloading the binary on demand if it doesnt exist. - better layout just vertical, easy. --- src/app_state.rs | 5 +- src/tui.rs | 29 ++++------ src/views/add_popup.rs | 90 +++++++++++++++++-------------- src/views/delete_popup.rs | 7 +-- src/views/help_popup.rs | 80 ++++++++++++++++++++++++++++ src/views/main.rs | 109 +++++++++++++++++--------------------- src/views/mod.rs | 1 + 7 files changed, 195 insertions(+), 126 deletions(-) create mode 100644 src/views/help_popup.rs diff --git a/src/app_state.rs b/src/app_state.rs index 98f70f9..63b5e63 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,7 +1,6 @@ use crate::{utils::IconEntry, views::main::MainState}; -use crossterm::event::KeyEvent; use std::sync::mpsc::Receiver; -use tui_textarea::{Input, Key}; +use tui_textarea::Input; #[derive(Debug, Clone, Copy, PartialEq)] pub enum AppFocus { @@ -82,6 +81,4 @@ impl App { AppFocus::HelpPopup => self.handlekeys_help_popup(key), } } - - fn handlekeys_help_popup(&mut self, _key: Input) {} } diff --git a/src/tui.rs b/src/tui.rs index 2c34362..fdb4e6a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,6 @@ use crate::{ app_state::{App, AppConfig, AppFocus}, - views::main::{render_main_view, render_sidebar}, + views::main::render_main_view, }; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, @@ -27,6 +27,11 @@ pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { Input { key: Key::Char('q'), .. + } + | Input { + key: Key::Char('c'), + ctrl: true, + .. } => break, input => app.handlekeys(input), } @@ -47,35 +52,21 @@ pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { fn ui(f: &mut Frame, app: &mut App) { let area = f.area(); - let width = area.width; - - let (direction, constraints) = if width < 80 { - ( - ratatui::layout::Direction::Vertical, - vec![Constraint::Min(3), Constraint::Min(0)], - ) - } else { - ( - ratatui::layout::Direction::Horizontal, - vec![Constraint::Max(37), Constraint::Min(0)], - ) - }; - let layout = ratatui::layout::Layout::default() - .direction(direction) + .direction(ratatui::layout::Direction::Vertical) .margin(1) - .constraints(constraints) + .constraints([Constraint::Min(0)]) .split(area); // Pages - render_sidebar(f, layout[0], app); - render_main_view(f, layout[1], app); + 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::HelpPopup => crate::views::help_popup::render_help_popup(f, app), _ => {} } } diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index d84a87b..6690c8b 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -10,18 +10,18 @@ use ratatui::widgets::{Block, Borders, ListItem, Paragraph}; use tui_textarea::{Input, Key, TextArea}; // Constants -const FOLDER_FIELD_IDX: usize = 0; -const PRESET_FIELD_IDX: usize = 1; -const ICON_FIELD_IDX: usize = 2; -const FILENAME_FIELD_IDX: usize = 3; -const NAME_FIELD_IDX: usize = 4; -const HELP_FIELD_IDX: usize = 5; -const DEBUG_FIELD_IDX: usize = 6; +// 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 HELP_FIELD_IDX: usize = 4; +const DEBUG_FIELD_IDX: usize = 5; #[derive(Debug)] pub struct AddPopupState { // Saved values - folder: Option, + // folder: Option, preset: Option, icon: Option, filename: Option, @@ -49,7 +49,7 @@ impl AddPopupState { } pub fn handlekeys_preset_input(&mut self, input: Input) { - if self.current_input == 1 { + if self.current_input == PRESET_FIELD_IDX { match input.key { Key::Tab | Key::Enter => { // Save the selected preset @@ -85,9 +85,9 @@ impl AddPopupState { self.preset_index = new_index; } _ => { - if self.current_input == 1 { - self.inputs[1].input(input); - self.preset_filter = self.inputs[1].lines().join("\n"); + if self.current_input == PRESET_FIELD_IDX { + self.inputs[PRESET_FIELD_IDX].input(input); + self.preset_filter = self.inputs[PRESET_FIELD_IDX].lines().join("\n"); self.presets_filtered = PRESETS_OPTIONS .iter() @@ -110,7 +110,7 @@ impl AddPopupState { match input.key { Key::Tab => { // Save the icon value - self.icon = Some(self.inputs[2].lines().join("\n")); + self.icon = Some(self.inputs[ICON_FIELD_IDX].lines().join("\n")); self.current_input = (self.current_input + 1) % self.inputs.len(); self.sync_cursor(self.current_input); } @@ -134,9 +134,9 @@ impl AddPopupState { // 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 - 3 => self.filename = Some(value), // filename - 4 => self.name = Some(value), // name + // 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(); @@ -153,7 +153,7 @@ impl App { pub fn init_add_popup(&mut self) { self.app_focus = AppFocus::AddPopup; self.add_popup_state = Some(AddPopupState { - folder: None, + // folder: None, icon: None, name: None, preset: None, @@ -162,6 +162,7 @@ impl App { preset_index: 0, preset_filter: String::new(), inputs: vec![ + // TextArea::default(), // folder TextArea::default(), // folder TextArea::default(), // preset (not used) TextArea::default(), // icon @@ -172,9 +173,12 @@ impl App { current_input: 0, }); - // Set default value for folder input - self.add_popup_state.as_mut().unwrap().inputs[0].insert_str(&self.config.folder); - self.add_popup_state.as_mut().unwrap().sync_cursor(0); + // 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 handlekeys_add_popup(&mut self, input: Input) { @@ -206,7 +210,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .direction(ratatui::layout::Direction::Vertical) .margin(2) .constraints([ - Constraint::Length(3), // Folder + // Constraint::Length(3), // Folder Constraint::Length(7), // Preset Constraint::Length(5), // Icon Constraint::Length(3), // Filename @@ -217,13 +221,15 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .split(area); let title = Block::bordered() - .title("Add Icon") - .border_type(ratatui::widgets::BorderType::Rounded); + .title("󱐱 Add Icon") + .border_type(ratatui::widgets::BorderType::Rounded) + .title_style(Style::default().fg(Color::White)) + .title_alignment(Alignment::Center); f.render_widget(title, area); if let Some(state) = &mut app.add_popup_state { let labels: Vec = vec![ - String::from(" Folder"), + // String::from(" Folder"), if state.preset_filter.is_empty() { format!(" Preset") } else { @@ -236,8 +242,12 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { // Debug block - shows all saved values for development let debug_text = format!( - "folder: {:?}\npreset: {:?}\nicon: {:?}\nfilename: {:?}\nname: {:?}", - state.folder, state.preset, state.icon, state.filename, state.name + "preset: {:?}\nicon: {:?}\nfilename: {:?}\nname: {:?}", + // state.folder, + state.preset, + state.icon, + state.filename, + state.name ); let debug_block = Block::default() .borders(Borders::ALL) @@ -249,17 +259,17 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { f.render_widget(debug_paragraph, layout[DEBUG_FIELD_IDX]); // Render each field individually with textarea - let folder_block = Block::default() - .borders(Borders::ALL) - .border_style(if state.current_input == FOLDER_FIELD_IDX { - Style::default().fg(Color::Yellow) - } else { - Style::default() - }) - .title(labels[FOLDER_FIELD_IDX].clone()); - state.inputs[FOLDER_FIELD_IDX].set_block(folder_block); - state.inputs[FOLDER_FIELD_IDX].set_cursor_line_style(Style::default()); - f.render_widget(&state.inputs[FOLDER_FIELD_IDX], layout[FOLDER_FIELD_IDX]); + // let folder_block = Block::default() + // .borders(Borders::ALL) + // .border_style(if state.current_input == FOLDER_FIELD_IDX { + // Style::default().fg(Color::Yellow) + // } else { + // Style::default() + // }) + // .title(labels[FOLDER_FIELD_IDX].clone()); + // state.inputs[FOLDER_FIELD_IDX].set_block(folder_block); + // state.inputs[FOLDER_FIELD_IDX].set_cursor_line_style(Style::default()); + // f.render_widget(&state.inputs[FOLDER_FIELD_IDX], layout[FOLDER_FIELD_IDX]); // Create a selectable list for preset let mut state_store = ratatui::widgets::ListState::default(); @@ -289,7 +299,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .block( Block::default() .borders(Borders::ALL) - .title(labels[1].clone()) + .title(labels[PRESET_FIELD_IDX].clone()) .border_style(if state.current_input == PRESET_FIELD_IDX { Style::default().fg(Color::Yellow) } else { @@ -327,8 +337,8 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }) .alignment(Alignment::Left), ); - state.inputs[2].set_block(icon_block); - state.inputs[2].set_cursor_line_style(Style::default()); + 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], layout[ICON_FIELD_IDX]); let filename_block = Block::default() diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index ee19b04..c6df651 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -1,7 +1,7 @@ use crate::app_state::{App, AppFocus}; use crate::utils::popup_area; use ratatui::Frame; -use ratatui::layout::Constraint; +use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, ListItem}; use tui_textarea::{Input, Key}; @@ -107,9 +107,10 @@ pub fn render_delete_popup(f: &mut Frame, app: &mut App) { .split(area); let title = Block::bordered() - .title(format!("🗑 Delete Icon")) + .title(format!("󰗨 Delete Icon")) .title_style(Style::default().fg(Color::White)) - .border_type(ratatui::widgets::BorderType::Rounded); + .border_type(ratatui::widgets::BorderType::Rounded) + .title_alignment(Alignment::Center); f.render_widget(title, area); if let Some(state) = &mut app.delete_popup_state { diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs new file mode 100644 index 0000000..21c85fc --- /dev/null +++ b/src/views/help_popup.rs @@ -0,0 +1,80 @@ +use crate::app_state::{App, AppFocus}; +use crate::utils::popup_area; +use ratatui::layout::{Alignment, Constraint}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::Frame; +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) { + let area = popup_area(f.area(), 64, 16); + f.render_widget(ratatui::widgets::Clear, area); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(6), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let title = Block::bordered() + .title("Help") + .title_style(Style::default().fg(Color::White)) + .border_type(ratatui::widgets::BorderType::Rounded) + .title_alignment(Alignment::Center); + f.render_widget(title, area); + + let defaults_items = vec![ + ListItem::new(format!("Folder: {}", app.config.folder)), + ListItem::new(format!( + "Preset: {}", + app.config.preset.as_deref().unwrap_or("") + )), + ]; + let defaults = List::new(defaults_items).block( + Block::default() + .title("Defaults") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded), + ); + f.render_widget(defaults, layout[0]); + + let key_items = vec![ + ListItem::new("a Add icon"), + ListItem::new("d Delete icon"), + ListItem::new("/ Search"), + ListItem::new("? Help"), + ListItem::new("q Quit"), + ListItem::new("Up/Down or j/k Navigate"), + ]; + let keys = List::new(key_items).block( + Block::default() + .title("Keybinds") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded), + ); + f.render_widget(keys, layout[1]); + + let help_text = Paragraph::new("Esc or ? to close") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help_text, layout[2]); +} diff --git a/src/views/main.rs b/src/views/main.rs index ed9a83f..ae949e9 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,15 +1,12 @@ use ratatui::{ - Frame, layout::{Alignment, Constraint, Rect}, style::{Color, Style}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, Paragraph}, + Frame, }; use tui_textarea::{Input, Key, TextArea}; -use crate::{ - app_state::{App, AppFocus}, - utils::IconEntry, -}; +use crate::app_state::{App, AppFocus}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum MainStateFocus { @@ -66,6 +63,9 @@ impl MainState { Key::Char('/') => { self.main_state_focus = MainStateFocus::Search; } + Key::Char('?') => { + app.init_help_popup(); + } Key::Up | Key::Char('k') => { let item_count = if !app.filtered_items.is_empty() { app.filtered_items.len() @@ -123,56 +123,6 @@ impl App { } } -pub fn render_sidebar(f: &mut Frame, area: Rect, _app: &App) { - let ascii_art = "░▀█▀░█▀▀░█▀█░█▀█░█▄█░█▀█░▀█▀░█▀▀░\n\ - ░░█░░█░░░█░█░█░█░█░█░█▀█░░█░░█▀▀░\n\ - ░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░▀░▀░░▀░░▀▀▀░"; - let items: Vec = vec![ - ListItem::new("a - Add"), - ListItem::new("d - Delete"), - ListItem::new("↑↓ - Navigate (or k,j)"), - ListItem::new("? - Help"), - ListItem::new("/ - Search"), - ListItem::new("q - Quit"), - ]; - let list = List::new(items).highlight_symbol("→ "); - let list_block = Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded); - - let inner_block = Block::default() - .title("Defaults") - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded); - let inner_list = List::new(vec![ - ListItem::new("Folder"), - ListItem::new(_app.config.folder.as_str()), - ListItem::new(""), - ListItem::new("Preset"), - ListItem::new(match &_app.config.preset { - Some(p) => p.as_str(), - None => "", - }), - ]); - - let vertical_layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .margin(0) - .constraints([ - Constraint::Length(3), - Constraint::Min(8), - Constraint::Length(7), - ]) - .split(area); - - let ascii_paragraph = Paragraph::new(ascii_art) - .style(Style::default().fg(Color::Rgb(74, 222, 128))) - .alignment(Alignment::Center); - f.render_widget(ascii_paragraph, vertical_layout[0]); - f.render_widget(list.block(list_block), vertical_layout[1]); - f.render_widget(inner_list.block(inner_block), vertical_layout[2]); -} - pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { use ratatui::widgets::{Cell, Row, Table}; @@ -188,9 +138,30 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { let main_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) .split(area); + let ascii_art = "░▀█▀░█▀▀░█▀█░█▀█░█▄█░█▀█░▀█▀░█▀▀░\n\ + ░░█░░█░░░█░█░█░█░█░█░█▀█░░█░░█▀▀░\n\ + ░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░▀░▀░░▀░░▀▀▀░"; + let ascii_paragraph = Paragraph::new(ascii_art) + .style(Style::default().fg(Color::Rgb(74, 222, 128))) + .alignment(Alignment::Center); + f.render_widget(ascii_paragraph, main_chunks[0]); + + let tagline = "Add svg icons to your js apps without any dependencies"; + let tagline_paragraph = Paragraph::new(tagline) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + f.render_widget(tagline_paragraph, main_chunks[1]); + if is_searching { let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) @@ -199,7 +170,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { Constraint::Fill(1), Constraint::Min(0), ]) - .split(main_chunks[0]); + .split(main_chunks[3]); let search_label = Paragraph::new("🔍 Search:") .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); @@ -218,7 +189,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { let search_paragraph = Paragraph::new(search_display.as_str()) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); - f.render_widget(search_paragraph, main_chunks[0]); + f.render_widget(search_paragraph, main_chunks[3]); } let item_list = if app.filtered_items.is_empty() && !main_state.search_items_value.is_empty() { @@ -252,5 +223,23 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { let mut state = ratatui::widgets::TableState::default(); state.select(Some(app.selected_index)); - f.render_stateful_widget(table, main_chunks[1], &mut state); + f.render_stateful_widget(table, main_chunks[4], &mut state); + + let instructions = "a Add | d Delete | / Search | ? Help | q Quit | Up/Down (j/k)"; + let version_label = format!("iconmate 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[5]); + let instructions_paragraph = Paragraph::new(instructions) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Left); + let version_paragraph = Paragraph::new(version_label) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + f.render_widget(instructions_paragraph, footer_layout[0]); + f.render_widget(version_paragraph, footer_layout[1]); } diff --git a/src/views/mod.rs b/src/views/mod.rs index 1d4901a..2fd50f8 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,3 +1,4 @@ pub mod add_popup; pub mod delete_popup; +pub mod help_popup; pub mod main; From 77ad73c6b2cbd3dc5174cc809cdb5f31214cda76 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 5 Feb 2026 14:40:31 +0800 Subject: [PATCH 09/28] feat: better search and up and down experience. --- src/views/main.rs | 91 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/src/views/main.rs b/src/views/main.rs index ae949e9..7176e5d 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -38,11 +38,40 @@ impl MainState { app.app_focus = AppFocus::Main; self.search_textarea = TextArea::default(); self.search_items_value = String::from(""); + self.update_filtered_items(app); } Key::Enter => { app.app_focus = AppFocus::Main; self.main_state_focus = MainStateFocus::Normal; } + Key::Up => { + let item_count = if self.search_items_value.is_empty() { + app.items.len() + } else { + app.filtered_items.len() + }; + if item_count == 0 { + app.selected_index = 0; + } else if app.selected_index > 0 { + app.selected_index -= 1; + } else { + app.selected_index = item_count - 1; + } + } + Key::Down => { + let item_count = if self.search_items_value.is_empty() { + app.items.len() + } else { + app.filtered_items.len() + }; + if item_count == 0 { + app.selected_index = 0; + } else if app.selected_index < item_count.saturating_sub(1) { + app.selected_index += 1; + } else { + app.selected_index = 0; + } + } _ => { self.search_textarea.input(input.clone()); self.search_items_value = self.search_textarea.lines().join(""); @@ -67,24 +96,28 @@ impl MainState { app.init_help_popup(); } Key::Up | Key::Char('k') => { - let item_count = if !app.filtered_items.is_empty() { - app.filtered_items.len() - } else { + let item_count = if self.search_items_value.is_empty() { app.items.len() + } else { + app.filtered_items.len() }; - if app.selected_index > 0 { + if item_count == 0 { + app.selected_index = 0; + } else if app.selected_index > 0 { app.selected_index -= 1; } else { - app.selected_index = item_count.saturating_sub(1); + app.selected_index = item_count - 1; } } Key::Down | Key::Char('j') => { - let item_count = if !app.filtered_items.is_empty() { - app.filtered_items.len() - } else { + let item_count = if self.search_items_value.is_empty() { app.items.len() + } else { + app.filtered_items.len() }; - if app.selected_index < item_count.saturating_sub(1) { + if item_count == 0 { + app.selected_index = 0; + } else if app.selected_index < item_count.saturating_sub(1) { app.selected_index += 1; } else { app.selected_index = 0; @@ -105,7 +138,12 @@ impl MainState { case1 || case2 }) .cloned() - .collect() + .collect(); + if app.filtered_items.is_empty() { + app.selected_index = 0; + } else if app.selected_index >= app.filtered_items.len() { + app.selected_index = app.filtered_items.len().saturating_sub(1); + } } } @@ -192,21 +230,30 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { f.render_widget(search_paragraph, main_chunks[3]); } - let item_list = if app.filtered_items.is_empty() && !main_state.search_items_value.is_empty() { - &app.filtered_items - } else if main_state.search_items_value.is_empty() { + 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 rows: Vec = if show_no_results { + vec![Row::new(vec![ + Cell::from("No results").style(Style::default().fg(Color::DarkGray)), + Cell::from(""), + ])] + } else { + item_list + .iter() + .map(|item| { + Row::new(vec![ + Cell::from(item.name.as_str()), + Cell::from(item.file_path.as_str()), + ]) + }) + .collect() + }; - let rows = item_list.iter().map(|item| { - Row::new(vec![ - Cell::from(item.name.as_str()), - Cell::from(item.file_path.as_str()), - ]) - }); - + let has_rows = !rows.is_empty(); let table = Table::new( rows, [Constraint::Percentage(50), Constraint::Percentage(50)], @@ -222,7 +269,9 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .row_highlight_style(Style::default().bg(Color::DarkGray)); let mut state = ratatui::widgets::TableState::default(); - state.select(Some(app.selected_index)); + if !show_no_results && has_rows { + state.select(Some(app.selected_index)); + } f.render_stateful_widget(table, main_chunks[4], &mut state); let instructions = "a Add | d Delete | / Search | ? Help | q Quit | Up/Down (j/k)"; From 5bd3fc021d913f86e40d1bbf519aa2b735382052 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 05:34:40 +0800 Subject: [PATCH 10/28] feat: added iconify api as a cli (for agents to use) --- Cargo.lock | 2 + Cargo.toml | 2 + README.md | 28 +++ src/iconify.rs | 409 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 240 ++++++++++++++++++++++- src/utils.rs | 12 +- src/views/help_popup.rs | 2 +- src/views/main.rs | 2 +- 8 files changed, 684 insertions(+), 13 deletions(-) create mode 100644 src/iconify.rs diff --git a/Cargo.lock b/Cargo.lock index a7dd095..fe4d267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,8 @@ dependencies = [ "ratatui", "regex", "reqwest", + "serde", + "serde_json", "tempfile", "tokio", "tokio-test", diff --git a/Cargo.toml b/Cargo.toml index b6324cb..91d0788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ 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" diff --git a/README.md b/README.md index 6c1df90..46e2165 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,34 @@ 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`. + ### Package.json Scripts Best practice: Add sensible defaults to your script runner. diff --git a/src/iconify.rs b/src/iconify.rs new file mode 100644 index 0000000..b5fe6af --- /dev/null +++ b/src/iconify.rs @@ -0,0 +1,409 @@ +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 { + self.get_json("collections", &[]).await + } + + pub async fn collection( + &self, + prefix: &str, + ) -> Result { + self.get_json("collection", &[("prefix".to_string(), prefix.to_string())]) + .await + } + + 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) + } +} + +#[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 { + #[serde(default)] + 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>, +} + +#[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#" + { + "collections": { + "mdi": { + "name": "Material Design Icons", + "total": 7447 + }, + "heroicons": { + "title": "Heroicons", + "total": 292 + } + } + } + "#; + + let response: IconifyCollectionsResponse = + serde_json::from_str(fixture).expect("fixture should deserialize"); + + let mdi = response.collections.get("mdi").expect("mdi should exist"); + assert_eq!(mdi.display_name("mdi"), "Material Design Icons"); + assert_eq!(mdi.total, Some(7447)); + + let heroicons = response + .collections + .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()])); + } +} diff --git a/src/main.rs b/src/main.rs index 42e077c..3c9b5cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,17 @@ mod app_state; mod form_input; +mod iconify; mod tui; mod utils; mod views; +use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse}; use crate::utils::{ _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry, IconSourceType, Preset, }; -use clap::{Parser, Subcommand}; -use reqwest::Url; +use clap::{Parser, Subcommand, ValueEnum}; +use serde::Serialize; use std::fs; use std::io::Write; use std::path::PathBuf; @@ -81,7 +83,7 @@ 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, }, @@ -95,6 +97,76 @@ enum Commands { #[arg(long, global = true)] 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. @@ -107,6 +179,167 @@ struct AppConfig { preset: Option, } +#[derive(Serialize)] +struct SearchJsonOutput { + icons: Vec, + total: u32, + limit: u32, + start: u32, + #[serde(skip_serializing_if = "Option::is_none")] + collections: Option>, +} + +#[derive(Serialize)] +struct CollectionsJsonOutput { + prefix: String, + name: String, + total: u32, +} + +#[derive(Serialize)] +struct CollectionJsonOutput { + prefix: String, + icons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + uncategorized: Option>, +} + +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, + } => { + 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), + } +} + +fn into_collection_output(response: IconifyCollectionResponse) -> CollectionJsonOutput { + CollectionJsonOutput { + prefix: response.prefix, + icons: response.icons, + uncategorized: response.uncategorized, + } +} + +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"); + } + + 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 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. /// Fetches an icon, saves it, and updates the index file. async fn run_app(config: AppConfig) -> anyhow::Result<()> { @@ -608,6 +841,7 @@ async fn main() -> anyhow::Result<()> { } Some(Commands::Prompt {}) => run_prompt_mode(&args).await, Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await, + Some(Commands::Iconify { command }) => run_iconify_command(command).await, None => { let config = app_state::AppConfig { folder: args diff --git a/src/utils.rs b/src/utils.rs index e7194a6..212ec01 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,6 +2,8 @@ use clap::ValueEnum; use ratatui::layout::{Constraint, Rect}; use reqwest::Url; +use crate::iconify::IconifyClient; + #[derive(ValueEnum, Clone, Debug, PartialEq, Hash)] pub enum Preset { /// Use a blank SVG. @@ -134,14 +136,8 @@ pub async fn _icon_source_to_svg( 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? + let client = IconifyClient::from_env()?; + client.svg(icon_source).await? } IconSourceType::Url => { // Already a full URL diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs index 21c85fc..82ad79e 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -1,9 +1,9 @@ use crate::app_state::{App, AppFocus}; use crate::utils::popup_area; +use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; -use ratatui::Frame; use tui_textarea::{Input, Key}; impl App { diff --git a/src/views/main.rs b/src/views/main.rs index 7176e5d..8b3d1a3 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,8 +1,8 @@ use ratatui::{ + Frame, layout::{Alignment, Constraint, Rect}, style::{Color, Style}, widgets::{Block, Borders, Paragraph}, - Frame, }; use tui_textarea::{Input, Key, TextArea}; From da0c8e258903df78356fc052627a141897b60274 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 05:56:11 +0800 Subject: [PATCH 11/28] fix: iconify cli fixes. --- src/iconify.rs | 116 +++++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 26 ++++++++--- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/iconify.rs b/src/iconify.rs index b5fe6af..c7218ea 100644 --- a/src/iconify.rs +++ b/src/iconify.rs @@ -41,15 +41,30 @@ impl IconifyClient { } pub async fn collections(&self) -> Result { - self.get_json("collections", &[]).await + let collections: HashMap = + self.get_json("collections", &[]).await?; + Ok(IconifyCollectionsResponse { collections }) } pub async fn collection( &self, prefix: &str, ) -> Result { - self.get_json("collection", &[("prefix".to_string(), prefix.to_string())]) - .await + 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( @@ -185,6 +200,35 @@ impl IconifyClient { } } +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 { @@ -268,7 +312,6 @@ pub struct IconifySearchResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IconifyCollectionsResponse { - #[serde(default)] pub collections: HashMap, } @@ -307,6 +350,17 @@ pub struct IconifyCollectionResponse { 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::*; @@ -362,30 +416,25 @@ mod tests { fn parse_collections_response() { let fixture = r#" { - "collections": { - "mdi": { - "name": "Material Design Icons", - "total": 7447 - }, - "heroicons": { - "title": "Heroicons", - "total": 292 - } + "mdi": { + "name": "Material Design Icons", + "total": 7447 + }, + "heroicons": { + "title": "Heroicons", + "total": 292 } } "#; - let response: IconifyCollectionsResponse = + let response: HashMap = serde_json::from_str(fixture).expect("fixture should deserialize"); - let mdi = response.collections.get("mdi").expect("mdi should exist"); + 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 - .collections - .get("heroicons") - .expect("heroicons should exist"); + let heroicons = response.get("heroicons").expect("heroicons should exist"); assert_eq!(heroicons.display_name("heroicons"), "Heroicons"); } @@ -406,4 +455,33 @@ mod tests { 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 3c9b5cb..70fad97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,23 +24,23 @@ 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. @@ -48,7 +48,6 @@ struct CliArgs { /// 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, @@ -94,7 +93,7 @@ enum Commands { /// 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, }, @@ -216,6 +215,13 @@ fn iconify_error_to_anyhow(error: crate::iconify::IconifyError) -> anyhow::Error 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')." + ); + } + let body = body.trim(); if body.is_empty() { anyhow::anyhow!("Iconify request failed ({status}) for {endpoint}") @@ -305,6 +311,12 @@ async fn run_iconify_command(command: IconifyCommands) -> anyhow::Result<()> { } } 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 From 17ce8d2611114964f257f9ff9e108fbcb5cadc7d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 05:58:52 +0800 Subject: [PATCH 12/28] feat: prompt is now called tui. --- README.md | 29 +++++++++++++++++++++++++++++ src/main.rs | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46e2165..1fae420 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,35 @@ iconmate --folder src/components/Icons/ --folder src/components/icons iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart ``` +## Configuration (Planned) + +This config system is planned and documented in `folder-system-plan.md`. + +We are introducing two config scopes: + +- Local config (project): `iconmate.config.json` +- Global config (user): OS-specific config file under your user profile + +Planned keys: + +- `folder` (local only) + - default: `src/assets/icons` +- `preset` (local only) + - default: empty (`""`), meaning plain `.svg` +- `output_line_template` (local only) + - default: `export { default as Icon%name% } from './%icon%%ext%';` + - variables: `%name%`, `%icon%`, `%ext%` +- `svg_view_cmd` (local + global) + - default: Quick Look on macOS, browser on Linux/Windows + - custom examples: `zed %filename%`, `code %filename%` + +`svg_view_cmd` supports `%filename%` token replacement with the SVG path. + +Planned TUI behavior: + +- Press `o` on a local icon to open it immediately. +- Press `o` on an Iconify search result to download into cache and open it. + ## Installation ### NPM 🦖 diff --git a/src/main.rs b/src/main.rs index 70fad97..0ca69b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,7 +88,7 @@ enum Commands { }, /// Start an interactive prompt to add icons. - Prompt {}, + Tui {}, /// Delete an icon from your collection of icons Delete { @@ -851,7 +851,7 @@ async fn main() -> anyhow::Result<()> { }; run_app(config).await } - Some(Commands::Prompt {}) => run_prompt_mode(&args).await, + Some(Commands::Tui {}) => run_prompt_mode(&args).await, Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await, Some(Commands::Iconify { command }) => run_iconify_command(command).await, None => { From 2cb92772ba6bad23a8b4dee608dfd0833c1ad71e Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 06:10:22 +0800 Subject: [PATCH 13/28] feat: added config maintenance setup. --- .gitignore | 1 + Justfile | 7 +- README.md | 77 ++++++++++++++----- TODOS.md | 20 +++++ config-gen/bun.lock | 15 ++++ config-gen/package.json | 12 +++ config-gen/src/generate.ts | 44 +++++++++++ config-gen/src/schema.ts | 61 +++++++++++++++ config-gen/src/types.ts | 41 ++++++++++ config-gen/tsconfig.json | 11 +++ folder-system-plan.md | 129 +++++++++++++++++++++++++++++++ iconify-cli-plan.md | 152 +++++++++++++++++++++++++++++++++++++ iconmateglobal.schema.json | 21 +++++ iconmatelocal.schema.json | 61 +++++++++++++++ 14 files changed, 631 insertions(+), 21 deletions(-) create mode 100644 TODOS.md create mode 100644 config-gen/bun.lock create mode 100644 config-gen/package.json create mode 100644 config-gen/src/generate.ts create mode 100644 config-gen/src/schema.ts create mode 100644 config-gen/src/types.ts create mode 100644 config-gen/tsconfig.json create mode 100644 folder-system-plan.md create mode 100644 iconify-cli-plan.md create mode 100644 iconmateglobal.schema.json create mode 100644 iconmatelocal.schema.json diff --git a/.gitignore b/.gitignore index f4a9dad..6af1d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target src/assets/icons +config-gen/node_modules 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 1fae420..bdae628 100644 --- a/README.md +++ b/README.md @@ -49,34 +49,71 @@ iconmate --folder src/components/Icons/ --folder src/components/icons iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart ``` -## Configuration (Planned) +## Configuration -This config system is planned and documented in `folder-system-plan.md`. +Iconmate now includes a config schema + TS type definitions in the repo: -We are introducing two config scopes: +- Local config schema: `iconmatelocal.schema.json` +- Global config schema: `iconmateglobal.schema.json` +- Schema source: `config-gen/src/schema.ts` +- TS type definitions: `config-gen/src/types.ts` -- Local config (project): `iconmate.config.json` -- Global config (user): OS-specific config file under your user profile +Regenerate schemas from project root: -Planned keys: +```bash +just config-schema +``` + +`just config-schema` installs `config-gen` deps and generates both schema files. + +### Local Config (`iconmate.config.json`) + +```json +{ + "$schema": "./iconmatelocal.schema.json", + "folder": "src/assets/icons", + "preset": "", + "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';", + "svg_view_cmd": "zed %filename%" +} +``` + +Local config keys: + +- `folder` (default: `src/assets/icons`) +- `preset` (default: `""`, meaning plain `.svg` mode) +- `output_line_template` (default: `export { default as Icon%name% } from './%icon%%ext%';`) +- `svg_view_cmd` (supports `%filename%` token) -- `folder` (local only) - - default: `src/assets/icons` -- `preset` (local only) - - default: empty (`""`), meaning plain `.svg` -- `output_line_template` (local only) - - default: `export { default as Icon%name% } from './%icon%%ext%';` - - variables: `%name%`, `%icon%`, `%ext%` -- `svg_view_cmd` (local + global) - - default: Quick Look on macOS, browser on Linux/Windows - - custom examples: `zed %filename%`, `code %filename%` +Allowed `preset` values: -`svg_view_cmd` supports `%filename%` token replacement with the SVG path. +- `""` (plain SVG mode) +- `react` +- `svelte` +- `solid` +- `vue` +- `emptysvg` -Planned TUI behavior: +### Global Config (user-level) -- Press `o` on a local icon to open it immediately. -- Press `o` on an Iconify search result to download into cache and open it. +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 +{ + "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 diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..c37e481 --- /dev/null +++ b/TODOS.md @@ -0,0 +1,20 @@ +- [ ] Enter to submit during add. +- [ ] Allow cmd+v to paste (not just as a stream of characters) +- [x] ~Help popup: status instead of (defaults)~ +- [ ] Default names from iconify urls and names +- [ ] iconify urls, iconesjs urls, make sure to parse them all. +- [x] Homepage: press up and down even during search. Maintain focus as well. +- [ ] Improve the look of the add, make it less lines and borders. Too noisy. (Inspire from crabcode). Like less lines. +- [ ] Delete popup - make it less noisy (inspire from crabcode), But keep the easy `y` or `n`. I think the instructions are what make it noisy. j and k? Just make it assumed. But less lines as well. +- [ ] Rename popup - renames the file name, but not the icon. Just recommend that the user can rename it via the editor. Using `cd` refactor via the lsp or something. That's much faster. +- [ ] Guess what, we need iconmate.jsonc. +- [ ] global system prefs `~/.config/iconmate.jsonc` or `~/iconmate.jsonc` + - [ ] Press `o` to open the file in my editor and recommend defaults.. i.e. on zed. opens this... + - [ ] svg_viewer_cmd= (quick view on mac), browser on others, use webapp as fallback. can be configed as ur code editor. +- [ ] During 'add'... + - Allow me to search for icons using the ratatui api. + - But is that good UX? +- [ ] Make an icon search interface? basically icones.js.org as a TUI. By using iconify api. + - [ ] Also make that a CLI + - [ ] . So AI can use them. +- [ ] local config file setup, or inside a package.json file. 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..be96d86 --- /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 = ""; +export const DEFAULT_OUTPUT_LINE_TEMPLATE = + "export { default as Icon%name% } from './%icon%%ext%';"; + +export const PRESET_VALUES = ["", "react", "svelte", "solid", "vue", "emptysvg"] as const; + +export const PresetSchema = z.enum(PRESET_VALUES).meta({ + title: "Preset", + description: + "Icon output preset. Empty string means plain SVG mode. Other values are framework or emptysvg presets.", + default: DEFAULT_PRESET, + examples: ["", "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..3207a05 --- /dev/null +++ b/config-gen/src/types.ts @@ -0,0 +1,41 @@ +export type IconmatePreset = "" | "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. Empty string means plain `.svg` mode. + * Default: `""`. + */ + 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/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..413f55d --- /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": [ + "", + "react", + "svelte", + "solid", + "vue", + "emptysvg" + ], + "title": "Preset", + "description": "Icon output preset. Empty string means plain SVG mode. Other values are framework or emptysvg presets.", + "default": "", + "examples": [ + "", + "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`)." +} From 0ce9954b9e48653b0a780a88063c060a3ea4c11b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 06:27:44 +0800 Subject: [PATCH 14/28] feat: lots of good changes by gpt 5.2 --- TODOS.md | 91 +++++++--- src/main.rs | 26 ++- src/utils.rs | 215 ++++++++++++++++++++++- src/views/add_popup.rs | 353 ++++++++++++++++++++++++++++---------- src/views/delete_popup.rs | 67 ++++---- src/views/help_popup.rs | 59 +++---- 6 files changed, 615 insertions(+), 196 deletions(-) diff --git a/TODOS.md b/TODOS.md index c37e481..841a67a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,20 +1,71 @@ -- [ ] Enter to submit during add. -- [ ] Allow cmd+v to paste (not just as a stream of characters) -- [x] ~Help popup: status instead of (defaults)~ -- [ ] Default names from iconify urls and names -- [ ] iconify urls, iconesjs urls, make sure to parse them all. -- [x] Homepage: press up and down even during search. Maintain focus as well. -- [ ] Improve the look of the add, make it less lines and borders. Too noisy. (Inspire from crabcode). Like less lines. -- [ ] Delete popup - make it less noisy (inspire from crabcode), But keep the easy `y` or `n`. I think the instructions are what make it noisy. j and k? Just make it assumed. But less lines as well. -- [ ] Rename popup - renames the file name, but not the icon. Just recommend that the user can rename it via the editor. Using `cd` refactor via the lsp or something. That's much faster. -- [ ] Guess what, we need iconmate.jsonc. -- [ ] global system prefs `~/.config/iconmate.jsonc` or `~/iconmate.jsonc` - - [ ] Press `o` to open the file in my editor and recommend defaults.. i.e. on zed. opens this... - - [ ] svg_viewer_cmd= (quick view on mac), browser on others, use webapp as fallback. can be configed as ur code editor. -- [ ] During 'add'... - - Allow me to search for icons using the ratatui api. - - But is that good UX? -- [ ] Make an icon search interface? basically icones.js.org as a TUI. By using iconify api. - - [ ] Also make that a CLI - - [ ] . So AI can use them. -- [ ] local config file setup, or inside a package.json file. +# 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 + +- [ ] Rename flow UX + - Add a lightweight rename action that only renames the file path export target. + - Keep icon component alias unchanged and show a warning for alias rename in-editor. + - Add explicit messaging to recommend IDE rename refactor for symbol-level rename. + - Acceptance: rename updates file + `index.ts` export path safely. + +- [ ] Global system prefs (`~/.config/iconmate.jsonc` or `~/iconmate.jsonc`) + - Confirm final search paths and precedence for macOS/Linux/Windows. + - Implement robust loader with unknown-key warnings (non-fatal). + - Add schema validation and friendly error output with file path context. + - Acceptance: startup logs/source reflect applied global values. + +- [ ] `o` key to open selected icon + - Add keybind in main list and Iconify search list. + - Resolve viewer command from local -> global -> OS default. + - Add `%filename%` token substitution and command escaping behavior. + - Acceptance: selected icon opens in configured app/editor. + +- [ ] `svg_viewer_cmd` defaults and fallback behavior + - macOS: Quick Look/open behavior. + - Linux/Windows: browser/system default fallback. + - Fallback to web preview when local command fails. + - Acceptance: command works cross-platform with graceful fallback. + +## Add-flow product direction + +- [ ] 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. + +- [ ] Dedicated icon search interface (TUI) + - Build a collection picker + icon search + preview + select-to-fill flow. + - Reuse `iconify` module for data fetch and pagination. + - Wire selected result back into add form. + +- [ ] 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. + +## Config surfaces + +- [ ] Local config file support (or package.json key) + - Decide one canonical local format first, then optional package.json bridge. + - Implement precedence with CLI flags and global config. + - Document with copy/paste examples. + +## Notes + +- Keep TUI minimal-first: less chrome, more keyboard speed. +- Prefer defaults inferred from icon source whenever possible. diff --git a/src/main.rs b/src/main.rs index 0ca69b8..4e34de2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod views; use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse}; use crate::utils::{ _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry, - IconSourceType, Preset, + IconSourceType, Preset, default_name_and_filename_from_icon_source, }; use clap::{Parser, Subcommand, ValueEnum}; use serde::Serialize; @@ -678,17 +678,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 { diff --git a/src/utils.rs b/src/utils.rs index 212ec01..1b79907 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -103,6 +103,147 @@ pub enum IconSourceType { 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 { @@ -140,14 +281,19 @@ pub async fn _icon_source_to_svg( client.svg(icon_source).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? + 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()); @@ -193,7 +339,11 @@ pub fn _make_svg_filename( } else if let Some(icon) = icon_source { // Only use icon_source if it's a plain iconify name (no http/https, no icon.clone(), + IconSourceType::IconifyName => { + 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 { @@ -373,3 +523,50 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[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())) + ); + } +} diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index 6690c8b..d83abbf 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -1,7 +1,7 @@ -use std::sync::mpsc::sync_channel; +use std::process::Command; use crate::app_state::{App, AppFocus}; -use crate::utils::{IconEntry, PRESETS_OPTIONS, Preset, PresetOption, popup_area}; +use crate::utils::{PRESETS_OPTIONS, Preset, PresetOption, popup_area}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; @@ -15,8 +15,8 @@ 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 HELP_FIELD_IDX: usize = 4; -const DEBUG_FIELD_IDX: usize = 5; +const FOOTER_FIELD_IDX: usize = 4; +const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = "export { default as Icon%name% } from './%icon%%ext%';"; #[derive(Debug)] pub struct AddPopupState { @@ -34,8 +34,68 @@ pub struct AddPopupState { 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 @@ -50,6 +110,10 @@ impl AddPopupState { 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 @@ -86,7 +150,9 @@ impl AddPopupState { } _ => { if self.current_input == PRESET_FIELD_IDX { - self.inputs[PRESET_FIELD_IDX].input(input); + 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 @@ -94,11 +160,15 @@ impl AddPopupState { .filter(|opt| { let filter = self.preset_filter.to_lowercase(); filter.is_empty() - || filter.contains(opt.preset.to_str()) + || 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; } } @@ -107,28 +177,32 @@ impl AddPopupState { } 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); } - Key::Char('v') if input.ctrl || input.alt => { - // Cmd+V on macOS (alt+v in crossterm), Ctrl+V on Linux/Windows . ⌘ - if let Ok(mut ctx) = arboard::Clipboard::new() { - if let Ok(text) = ctx.get_text() { - self.inputs[self.current_input].insert_str(&text); - } - } - } _ => { 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 @@ -141,9 +215,11 @@ impl AddPopupState { } 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(); } } } @@ -151,26 +227,41 @@ impl AddPopupState { impl App { pub fn init_add_popup(&mut self) { + let configured_preset = self.config.preset.as_ref().and_then(|preset_str| { + PRESETS_OPTIONS + .iter() + .find(|option| option.preset.to_str() == preset_str) + .map(|option| option.preset.clone()) + }); + let selected_index = configured_preset + .as_ref() + .and_then(|preset| { + PRESETS_OPTIONS + .iter() + .position(|option| option.preset == *preset) + }) + .unwrap_or(0); + self.app_focus = AppFocus::AddPopup; self.add_popup_state = Some(AddPopupState { // folder: None, icon: None, name: None, - preset: None, + preset: configured_preset, filename: None, - preset_index: 0, + preset_index: selected_index, preset_filter: String::new(), inputs: vec![ - // TextArea::default(), // folder - TextArea::default(), // folder - TextArea::default(), // preset (not used) + 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 @@ -181,7 +272,129 @@ impl App { .sync_cursor(PRESET_FIELD_IDX); // The first. } + 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(); + + if state.preset.is_none() + && !state.presets_filtered.is_empty() + && state.preset_index < state.presets_filtered.len() + { + state.preset = Some(state.presets_filtered[state.preset_index].preset.clone()); + } + + let preset = state.preset.clone(); + 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(), + ); + } + + if icon.is_empty() && preset.is_none() { + return Err("Choose a preset or provide an icon source 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), + ); + + if let Some(preset) = preset { + 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(); @@ -203,25 +416,23 @@ impl App { } pub fn render_add_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 70, 30); + let area = popup_area(f.area(), 70, 24); f.render_widget(ratatui::widgets::Clear, area); let layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .margin(2) .constraints([ - // Constraint::Length(3), // Folder - Constraint::Length(7), // Preset - Constraint::Length(5), // Icon + Constraint::Length(6), // Preset + Constraint::Length(4), // Icon Constraint::Length(3), // Filename Constraint::Length(3), // Name - Constraint::Min(0), // Help text - Constraint::Length(5), // Debug + Constraint::Min(0), // Footer ]) .split(area); let title = Block::bordered() - .title("󱐱 Add Icon") + .title("Add Icon") .border_type(ratatui::widgets::BorderType::Rounded) .title_style(Style::default().fg(Color::White)) .title_alignment(Alignment::Center); @@ -229,48 +440,16 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { if let Some(state) = &mut app.add_popup_state { let labels: Vec = vec![ - // String::from(" Folder"), if state.preset_filter.is_empty() { - format!(" Preset") + String::from("Preset") } else { - format!(" Preset 🔍 {}", state.preset_filter) + format!("Preset filter: {}", state.preset_filter) }, - String::from(" Icon (iconify name / URL / raw SVG / empty)"), - String::from(" Filename"), - String::from(" Name"), + String::from("Icon source (name, URL, SVG, or empty)"), + String::from("Filename"), + String::from("Name"), ]; - // Debug block - shows all saved values for development - let debug_text = format!( - "preset: {:?}\nicon: {:?}\nfilename: {:?}\nname: {:?}", - // state.folder, - state.preset, - state.icon, - state.filename, - state.name - ); - let debug_block = Block::default() - .borders(Borders::ALL) - .title("Debug Values") - .style(Style::default().fg(Color::DarkGray)); - let debug_paragraph = Paragraph::new(debug_text) - .block(debug_block) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(debug_paragraph, layout[DEBUG_FIELD_IDX]); - - // Render each field individually with textarea - // let folder_block = Block::default() - // .borders(Borders::ALL) - // .border_style(if state.current_input == FOLDER_FIELD_IDX { - // Style::default().fg(Color::Yellow) - // } else { - // Style::default() - // }) - // .title(labels[FOLDER_FIELD_IDX].clone()); - // state.inputs[FOLDER_FIELD_IDX].set_block(folder_block); - // state.inputs[FOLDER_FIELD_IDX].set_cursor_line_style(Style::default()); - // f.render_widget(&state.inputs[FOLDER_FIELD_IDX], layout[FOLDER_FIELD_IDX]); - // Create a selectable list for preset let mut state_store = ratatui::widgets::ListState::default(); let mut items: Vec = { @@ -298,10 +477,10 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { let mut list = ratatui::widgets::List::new(items) .block( Block::default() - .borders(Borders::ALL) + .borders(Borders::TOP) .title(labels[PRESET_FIELD_IDX].clone()) .border_style(if state.current_input == PRESET_FIELD_IDX { - Style::default().fg(Color::Yellow) + Style::default().fg(Color::Green) } else { Style::default() }), @@ -314,35 +493,19 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { f.render_stateful_widget(list, layout[PRESET_FIELD_IDX], &mut state_store); let icon_block = Block::default() - .borders(Borders::ALL) + .borders(Borders::TOP) .border_style(if state.current_input == ICON_FIELD_IDX { Style::default().fg(Color::Green) } else { Style::default() }) - .title(labels[ICON_FIELD_IDX].clone()) - .title_bottom( - Line::from(if state.current_input == ICON_FIELD_IDX { - "Tab to continue" - } else { - "" - }) - .alignment(Alignment::Right), - ) - .title_bottom( - Line::from(if state.current_input == ICON_FIELD_IDX { - "ctrl+v to paste" - } else { - "" - }) - .alignment(Alignment::Left), - ); + .title(labels[ICON_FIELD_IDX].clone()); 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], layout[ICON_FIELD_IDX]); let filename_block = Block::default() - .borders(Borders::ALL) + .borders(Borders::TOP) .border_style(if state.current_input == FILENAME_FIELD_IDX { Style::default().fg(Color::Yellow) } else { @@ -365,7 +528,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { let name_value = state.inputs[NAME_FIELD_IDX].lines().join(""); let name_block = Block::default() - .borders(Borders::ALL) + .borders(Borders::TOP) .border_style(if state.current_input == NAME_FIELD_IDX { Style::default().fg(Color::Yellow) } else { @@ -383,10 +546,20 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { 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], layout[NAME_FIELD_IDX]); - } - let help_text = Paragraph::new("Tab/Enter to Continue | ESC to cancel") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[HELP_FIELD_IDX]); + let footer_text = if let Some(message) = &state.status_message { + message.clone() + } else { + String::from("Tab to move, Enter on Name to submit, Esc to cancel, Cmd/Ctrl+V to paste") + }; + let footer_color = if state.status_is_error { + Color::Red + } else { + Color::DarkGray + }; + let footer = Paragraph::new(footer_text) + .alignment(Alignment::Center) + .style(Style::default().fg(footer_color)); + f.render_widget(footer, layout[FOOTER_FIELD_IDX]); + } } diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index c6df651..976ec91 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -3,7 +3,7 @@ use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, ListItem}; +use ratatui::widgets::{Block, Paragraph}; use tui_textarea::{Input, Key}; #[derive(Debug)] @@ -94,57 +94,52 @@ impl App { } pub fn render_delete_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 60, 12); + let area = popup_area(f.area(), 56, 10); f.render_widget(ratatui::widgets::Clear, area); let layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .margin(1) + .margin(2) .constraints([ - Constraint::Min(0), // List + Constraint::Length(2), // Prompt + Constraint::Length(1), // Actions Constraint::Length(1), // Help + Constraint::Min(0), ]) .split(area); let title = Block::bordered() - .title(format!("󰗨 Delete Icon")) + .title("Delete Icon") .title_style(Style::default().fg(Color::White)) .border_type(ratatui::widgets::BorderType::Rounded) .title_alignment(Alignment::Center); f.render_widget(title, area); if let Some(state) = &mut app.delete_popup_state { - let items = vec![ - // ListItem::new(format!("y Delete this icon ({})", state.selected_item.name)), - ListItem::new(format!( - "y Delete the icon '{}'", - state - .item_to_delete - .as_ref() - .map(|item| item.name.as_str()) - .unwrap_or("Name") - )), - ListItem::new("n Cancel"), - ]; - - let mut list_state = ratatui::widgets::ListState::default(); - list_state.select(Some(state.selected_index)); - - let list_block = ratatui::widgets::List::new(items) - .block(Block::default()) - .highlight_style(if state.selected_index == 0 { - Style::default().bg(Color::Red) - } else { - Style::default().bg(Color::DarkGray) - }) - .highlight_symbol("→ "); - - f.render_stateful_widget(list_block, layout[0], &mut list_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::Center) + .style(Style::default().fg(Color::White)); + f.render_widget(prompt, layout[0]); + + let actions = if state.selected_index == 0 { + Paragraph::new("[y] Delete n Cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Red)) + } else { + Paragraph::new("y Delete [n] Cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)) + }; + f.render_widget(actions, layout[1]); } - let help_text = - ratatui::widgets::Paragraph::new("y/n or j/k to select | Enter to confirm | Esc to cancel") - .alignment(ratatui::layout::Alignment::Center) - .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[1]); + let help_text = Paragraph::new("Enter to confirm, Esc to cancel") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(help_text, layout[2]); } diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs index 82ad79e..9316f38 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -3,7 +3,7 @@ use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::widgets::{Block, Paragraph}; use tui_textarea::{Input, Key}; impl App { @@ -22,16 +22,19 @@ impl App { } pub fn render_help_popup(f: &mut Frame, app: &App) { - let area = popup_area(f.area(), 64, 16); + let area = popup_area(f.area(), 62, 12); f.render_widget(ratatui::widgets::Clear, area); let layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .margin(2) .constraints([ - Constraint::Length(6), - Constraint::Min(0), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), ]) .split(area); @@ -42,39 +45,27 @@ pub fn render_help_popup(f: &mut Frame, app: &App) { .title_alignment(Alignment::Center); f.render_widget(title, area); - let defaults_items = vec![ - ListItem::new(format!("Folder: {}", app.config.folder)), - ListItem::new(format!( - "Preset: {}", - app.config.preset.as_deref().unwrap_or("") - )), - ]; - let defaults = List::new(defaults_items).block( - Block::default() - .title("Defaults") - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded), - ); - f.render_widget(defaults, layout[0]); + let status = Paragraph::new(format!( + "Folder: {}\nPreset: {}", + app.config.folder, + app.config.preset.as_deref().unwrap_or("") + )) + .alignment(Alignment::Left) + .style(Style::default().fg(Color::White)); + f.render_widget(status, layout[0]); - let key_items = vec![ - ListItem::new("a Add icon"), - ListItem::new("d Delete icon"), - ListItem::new("/ Search"), - ListItem::new("? Help"), - ListItem::new("q Quit"), - ListItem::new("Up/Down or j/k Navigate"), - ]; - let keys = List::new(key_items).block( - Block::default() - .title("Keybinds") - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded), - ); - f.render_widget(keys, layout[1]); + let divider = Paragraph::new("a Add | d Delete | / Search | ? Help | q Quit") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(divider, layout[2]); + + let nav = Paragraph::new("Up/Down or j/k to navigate") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Gray)); + f.render_widget(nav, layout[3]); let help_text = Paragraph::new("Esc or ? to close") .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[2]); + f.render_widget(help_text, layout[4]); } From 3787118e0c47bd183d42479a690ba1510eb32027 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 13:51:54 +0800 Subject: [PATCH 15/28] feat: super huge updates - added rename. - added config parsing. svg_view_cmd specifically also works. - adding help and see status of configs with `?` --- Cargo.lock | 202 ++++++++++++++++++++ Cargo.toml | 3 + README.md | 44 +++-- TODOS.md | 46 ++--- _docs/iconmate_banner.jpg | Bin 0 -> 65730 bytes _docs/iconmate_logo.png | Bin 0 -> 280835 bytes iconmate.jsonc | 4 + src/app_state.rs | 8 + src/config.rs | 393 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 44 +++-- src/tui.rs | 3 +- src/utils.rs | 211 +++++++++++++++++++- src/viewer.rs | 200 +++++++++++++++++++ src/views/help_popup.rs | 59 ++++-- src/views/main.rs | 69 ++++++- src/views/mod.rs | 1 + src/views/rename_popup.rs | 188 ++++++++++++++++++ 17 files changed, 1409 insertions(+), 66 deletions(-) create mode 100644 _docs/iconmate_banner.jpg create mode 100644 _docs/iconmate_logo.png create mode 100644 iconmate.jsonc create mode 100644 src/config.rs create mode 100644 src/viewer.rs create mode 100644 src/views/rename_popup.rs diff --git a/Cargo.lock b/Cargo.lock index fe4d267..facbea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,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" @@ -369,6 +378,15 @@ 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" @@ -452,6 +470,16 @@ 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" @@ -514,6 +542,37 @@ 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" @@ -741,6 +800,16 @@ 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" @@ -979,13 +1048,16 @@ dependencies = [ "clap", "color-eyre", "crossterm 0.29.0", + "dirs", "inquire", + "json5", "predicates", "ratatui", "regex", "reqwest", "serde", "serde_json", + "shlex", "tempfile", "tokio", "tokio-test", @@ -1231,6 +1303,17 @@ 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" @@ -1243,6 +1326,16 @@ 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" @@ -1506,6 +1599,12 @@ 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" @@ -1547,6 +1646,49 @@ 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" @@ -1686,6 +1828,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" @@ -1937,6 +2090,17 @@ 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" @@ -2127,6 +2291,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" @@ -2349,6 +2533,18 @@ dependencies = [ "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" @@ -2425,6 +2621,12 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 91d0788..c6ac604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ 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" [dev-dependencies] tempfile = "3.8" diff --git a/README.md b/README.md index bdae628..4085e01 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,39 @@ -# iconmate 💚 +

+ iconmate logo +

-Your new favorite way to manage icons for your Vite, NextJS projects without icon libraries! +

iconmate

-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. +

Add SVG icons to your JS apps without icon libraries.

-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. +

+ iconmate banner +

-**What Makes It Special ✨** +

+ Quick Start + · + Install + · + Presets + · + CLI Commands + · + Configuration +

-- **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 +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? + +- **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 follow guided prompts +- **Multiple sources** 🌐: Pull icons from Iconify names, URLs, or raw SVG +- **Fast workflow** ⚡: Generate file + export line in one step +- **Prototype-friendly** 🏗️: Create empty SVG placeholders when needed ## Quick Start @@ -30,7 +50,7 @@ iconmate > 💎 Name # Heart ``` -✨ That's it. The interactive CLI guides you through adding icon to your project! +✨ That's it. The interactive CLI guides you through adding icons to your project. ```tsx // 👇 Then, you can just easily use any icon on your project like this! diff --git a/TODOS.md b/TODOS.md index 841a67a..d732fb7 100644 --- a/TODOS.md +++ b/TODOS.md @@ -17,29 +17,29 @@ ## Next high-priority slice -- [ ] Rename flow UX - - Add a lightweight rename action that only renames the file path export target. - - Keep icon component alias unchanged and show a warning for alias rename in-editor. - - Add explicit messaging to recommend IDE rename refactor for symbol-level rename. - - Acceptance: rename updates file + `index.ts` export path safely. - -- [ ] Global system prefs (`~/.config/iconmate.jsonc` or `~/iconmate.jsonc`) - - Confirm final search paths and precedence for macOS/Linux/Windows. - - Implement robust loader with unknown-key warnings (non-fatal). - - Add schema validation and friendly error output with file path context. - - Acceptance: startup logs/source reflect applied global values. - -- [ ] `o` key to open selected icon - - Add keybind in main list and Iconify search list. - - Resolve viewer command from local -> global -> OS default. - - Add `%filename%` token substitution and command escaping behavior. - - Acceptance: selected icon opens in configured app/editor. - -- [ ] `svg_viewer_cmd` defaults and fallback behavior - - macOS: Quick Look/open behavior. - - Linux/Windows: browser/system default fallback. - - Fallback to web preview when local command fails. - - Acceptance: command works cross-platform with graceful fallback. +- [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 diff --git a/_docs/iconmate_banner.jpg b/_docs/iconmate_banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7d14bda68d0ee5cd8db06a2bfee53e169964785d GIT binary patch literal 65730 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�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 TVVuQ^ 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�@~ek?2fbp9 z@ZeSc$#_!*Mf{|4bA_~!%aU`Fr%rsFhzIOKHFq96Wv-%+*8;%a=V)jS&1?&G*6YB* z^HN`W=Rdmf=DRtMK66G6G9T3M4?4j!Uy6Pt)sx}BN--bFIrja*!sV_`j&H z)kNm)CD@bWey`6;q3{1la{vGk07*naR8nN2Fm>AR#CdQ~>wBDvj_@$7gSN-5ZaZ~C z*Q`wd*sJu>w?A1Q1-O3rwKwVGZ+)gd^2R&#qU)Zbm)!hJ-M)R5o_6vEJ>#m|^qf<- z>LYG?hCb@H579^5a)(}c!|nQzQ`hUJ?UUN7$Eq{V!}-|?o%YPtSr&0Cm=~%=9}eeA z>buN+g^tmW0y-S&#P&71;hHCDYyE0v7RbcHwzYA!ZU+319lcJ&W*q}oIJ(mcIWyxD z3+Lm`zamT7B^V9_0#FPc59Ms4Y))oD-j5Y1BIKRp8GsUimLwShe^+%t`aI<|F_ny}4t%bf0TtT9IcV*@Y?pN{9p>%5Ad&t7;~I|q;G(R1(7 ziiOB5DkDpb6QuK%GCWd#%JaQKPI;cFo{O~Oo1zUC>1 zK-vL5$x3@rBv#}l^G-pHPOV*|fu`(6oV-j^ z+(}bjSdWKeRpvB~rdb`NWU8v6M$`*CKYVGS-+0e?z3yF)=%;@7U3%@iFX-1F?)2{6 zsP|mzwA(p>RqQR2ymRW%(;msmqS!K23ea8=ze|n|niw^xf@&5Vmrc&n;i5Y4Or2|p z8`G7!)1$jd_dIr>w{R~0&PR52|G5LeI%y@R=u8bhF(951OU{Ku9FZLNQL8R1Ns5)1 zLr*pSR<0d@eYk5y4=&F&YlEg7U5u(qlY!T4+RDr`GGoVHg?517GPc-6j7#V)c&{b$ zaKb{Og~ojl;U<*oR=w*l_sjKmTzx5obmmJjh(5R<_rWB0SNXT zFn+g?8?`+~ssN0ER#dPj%G5vbqJHYmB3}?Rj(y;yZsT>)KO#?)BH{Lr!0%XP&xRPd;`^w^Od0ZRy$#fMl|! zlannS8^TXD)i448z1I?16DYjG@scD9UcEDUa{#s}5&*`$;)D3;)XC&!7UX;-nNv^j zmp_5((|~R6HLBxC-kY{JPRggTx@Cn;qNE@n8vtS|=ObXZr#|mOE1@@2&)fSIIw|(^ zVW0q?9`MXIlyhTzyMS%Sm;-d^m5rjuODMnc1#O3YGYd_Y!@5~LfE&V0eac#P2h3Fh z2zvB$R`^__5B&ALKqwWygRki4C7A?$Ptcp6N^v4b@o^}Fzdo5JoYB^CUA5mv?g4Y| z!RuV!ygVT1&_lK$=hqaNGoypTn>pIO(`Yy(+XFAIZnsJ8IHRq>Ek#k_|L z8vbW;%vq}iI0kjAV5Zlh&cU~)wJ@)2%nzINQJA+}E z=muT_5B*GF9khZ5$kYaOlT31su~~_)l4P{Qk({sOk!RS>q#Pv$CHM-i$2Og;4rIzZ zddqtr)T`hAcD?pp_vlw2d_?zM*i*-GO=SX%S8}OWLu|;rF7NItvk+D!P~anTghEy2 z_nnbu% z+RT((koP0GG7riMu2%G|vUz3N67FY&2@mQ4aU=UQ4^PT+hMtkf(a)UJvHfkD8C&yp z4F|_4Yf8x^m(|l(o=#TDIbS4{_e0d!P#l`|a?O_N<_P^g+0?$WIXO2|tR_sKlx!tq zF6UaA(T28V2CWVn@GGYtJ@weDBO5IXU)RsoBZCWTig8AJq2r*rE<)cWI^qlXa3GANpPCHh;a6}fkDsB`$qZ`^;6e&L=s>n-OW(Jr=_jHAY_ zmk&{5oR1N^IP8(tR8HPWo`uHauP*oU6j@{u44{&yp?xI!8Pgu+Fj;dE)sSg=-i$o)(pEu1z)R)y{fR3I)mq){!(T^ z8PpYv2YsMLlU7WKisdXF>fr#q@!SR1sDpA)%nn+ksXOy&XP!E{`|i7Ys!boH4{UsJ zD3LuM(t6g{y3ed)^NjiaypO^8x{iv$YlpL^b31b-3o6^MRRK?o8vukVWX*<^hP9Jd zQj9f9b4tbVom5As=^$#gV7zmOEB>W}bht$_Unv1%PmEbpOG=e(SMaz586J9)6dcZ}sp#=QzQVDRGPv z2|@>#4mm;yN-1l}LNcd>$}7Zxedqx%j?xaMswiPN){cvMJ1>=f?w#+{5B}zx^aF4F zMg8ns-lSi?=bd_l^XH6HuaVWS*F&7t<4jH&c~Wok%(rwa-FNoAdegh!s!NMK+J!E? zIw)C(3jG4S-CSiZ$9+yaN(o-sW}+!|T>In| zU}knJmBc@DL~f4_v36I3M6x#JZ;A8 zR)mxTsFDhy+&LZFI<3vM6H=;ZVT|}#xn?DTlPA8p$EL~&Dv5sOxl@AYV@Iyjbh3?a zdWi+VIegEUgVG!PqLWSl@@jSO8^p@4HES*QJji{SZ==Gc8Y+|*T@PgB!x;TK--J9{5?$@FuK4f4$GJXebN z^vu~gQ(F%-@~sYn(_7#Bkbe7}?;w`WNwk&h*jbjh^mrWFQo;B5vb5|HG^0HHgI54? zhNYnL`}~+#j;}e{Xl9-m&;z4ZAax=x_wkRO?7wFxI&GXbMnUUCr&~{N>*gaHN{o4M zZ%>cyUeKwvN-w&3Q!l)3O~)Ir%GK0&`VEqq_gy@%B8Owa2<8;~tVm2QNuvu-o_Pbs zL$CO-_g=AJKy0*2+@HO;%X@*OUE*}X(M8+T8djC+vd~zCpNRz{BWu>8%lM6xI@5}y zR+k)@(kKv>rl^LomG3mFvs%|X?^gY2(%d}Rsy|PB?)o6UFoX*z{t3U&oRPbmL-M=6 z;>Hu5pMQSxd#vy>0e;aZR%Fv+zNd?T>Ou1A%?sNpdTGen&}0-*R#N#-TN}U{?P!4_ z_m};l!UnYmpdHR*3aoIk9qUoR{=)7`WKESm7QOLZZ`WJSKCDLx!nw<`P|4#EmLoKy%74Z6o`)VFU-A;pYq*R6Q5VJt-7*bD z1N6}764`l=9Cb5LreB5!Gn3$3)bt$D%qE)}w^P)OGyln(?_D9cpFWn^^FPl+0HjQ8@4NfMyi zAy7rXN@iZpn~cH`W%H7mveIFRKFafj=qhrWM6**)l~VEFTqp(=e}j-*fG7CM79|&D zlo(oh7vuqr5Q3cH^T@`gQvGUIN2hn{#*;5pg&q<+GBcBN9`G}jm#fZWQ~cG;m&dyT5bnpB0;H6!iSR3lj>rd$U z*B#e&>%+tThCc<%auDj#zmMqDd%!g5fEX&4a|locSYFQM(OV$)kbg|+ zuHGK#IEr2@J6+iqTMfy_7U3d_l!iFTpbm<@=iaHjaUGP^sf~abD$ZFkWF|3Y!KrYx z{Z%4^E*g}AHx%6yUXMS|7YFLl_7cZgu+1sI16_coOtw$2Jp#sDg_}4naN?D(b!pLQ z7lY5W)N`*rqQCys8&C1f-~a#+07*naRP}teT_?%ZGLCw%JU|+0LGU{Oe}%)Eb86dx z5=L*DfrL|d@8ro)gaee|E?lp=PD`Akr(db-misJ5OKok8z*nzSH`;^l`T2p~^qzO< zKmXP*>y7ul6N95H=MNVMv!IRfwr)KAWIg4EXR7?)dqJ(LY9zS=77nMWC3AfIjiuOkfB@cE9WI8Q%|*-Bxuk@1VTn1vW_}*3rHmNnh)jZ>CYJY zWptX@9$V>8ALrl&zB6>pO%&q>sv$G9iK@6IA6j%)Xqs{v56?xPOF-AKO$fED(D3k) z=m729&jGf%9I^@?QeLpI?yAQsSrt_S#*CD(r{D~RHy!4Be#rvIXcbLLgtbnEx~)n;T&`$w4#fu4`U(% zqM|+N*la8-x6A<< zZ0J*em_Y5ZsLk=U5?kg?D|U)bJ$zOw;YSsE$eNQlrJR+`RIF8OnzP9_Q4JEuqq=qw`Na1`Z2QV$sKEOxX5uAJNJbz!elsA^UC=a2=b>w-P_$bNreZ$4_L z_RsV|{J1|ctt`%-)qmKT&Eu6D{k_l(^k^Pw9?irYS1P*=ag}vW$Gk?pp8MnxPQOG(W9Em4vjLvNvCbA zb&eAn9Hb!=s>niDDM?6LN!l8(%UC6A!fS0f)z02h=XVaY^g%n_uAY0tx^6qQsVkS3 z+8Pb@oF`qQ?SWSXjXkp9{bf=WqFiy5gSi%qrFvG^Z0>LsHXJ%({DiZVIeVP8{050@ zY8;v%WL#M+5*b0wHmY(_MNloQMJ5iT#>b93vzy>$Bzi2%Gqz>SEAkpnVEX{ zO7JA+&d8c`rruK|nfL_B%5&g?tk~eN4~>anafXg_l9_{oq1`C4TX1C93N`o0hCQ;R z|-JWrXYM&3%nPZi_3d>AqY z%>(9USstBsEaWOmVqr4#a=w;x19`@nYZ>n-Uf$rhFem0^{!IU(qs()U-^qfUZ_qLQ zxzD*pl1Sm3ab`}w=u)N(_(x=>x-iU`4w?59-B33kf03>`{u~7|XA%4kp3TM*JQ+aj zi*K0&3|DxNN!F;meD!gt6@KP@kmQvC{DSTC{atnC{{u{AUdfpkoXA<}2d~cS5)1He z-urg_=DUAKD>UexW3gK)1J0#*<}))bh>HVuJJFc}=}X}1oixBt+vqeRINP?CaX&~) zJ^iX{bn~g>_^q%PGGfyON!SWH9c8gUR~5X3KOP@7Iy$NZlEQ(>VTS{J_##J%6&d@< z?TJp0yegC4b#6z$_r8m|G|w6V@+Zf&Hi*GV6J%(micyEYN8bkCD)@0M6mBcFXXdx`m=QIqqiw@2En6S+C@e9>-Lw5fZ-I`>JT)?xi7(! z;5*|u7daw&SY`}{#X`&3+?H=?>0I&}jbKk_Q4u>2tBHzZS2ou6 z;+t}CqrdUbZ*G&d{kiZ4Djd-SsCABclp3PEKJCdQiP9Mz4Kfdd$@2_@}ZJ! zML9sPjcTg|>-NZLF9vXuy$&BTfV# zHU9GCYmaMM!(+}WG8?y#5nGwX`EX7a`L(rHN7rgK!SwF)yL!_@J6gn%*hnY=h86Th zzeCW)b_aWNrEGG@Q!&P&Hv>DCq~}##ONaGAv!+!*Gv<*-K?9OF{Xc`ZOC|(*>K}XkyI=a5DbB`V9?3IOjJSEvecV2y5A9C#} zz2m))=s`fzYSTjpm-NW)CH38bVpuXR>eObVBhw*HAq7^EfYUq65>DM)jU#!LOg>Om zRiinpuW^q>Xzl+BxVE)zJDk-NLk59uT7o+8f2eaa9zB znH+#x4UnrorDk|q!^vs+Y74oiEKIdRy(S+@nW`6Zp|8w~z6|)~T*H4QvsT$ExbYNZ ziS$Qj-bZCED03A@N+s#wIbn0>>5IKsmsLK_dEw5AHq^*(Yf_!iRa;NjO-G)saoi>b zIwf?u>i4OiN?6@G#o$`2tTN3B@R;H#Esqe5a{53u_%l$lYS zoDUMR$(%at^_F|yrU%a7rz#=?O!^SfVWRZ#iGA?jU|zL{E>{!dS%em<(6Mo(*K40e zwj3>#Uj@DE8PCUaUNxdWw046}VXD-W03*!Cs`b=wsGVeke z9rx_&61*jOc=js3)&Z=BF19BFZH*dLRnWt{xPSLUXEn#C%+;!0B@gXYd2iGw?H(+( zPh#Z&-svc6M3&v-Gc|G5gC7ZeC@=O;jK{iedy8qZYFjBd$GD2)jfI*ht>DoycZ5z` z8{WEnT#k}Gm?^}dG6!C+;G=L{Es3x5i_H1=N>^=Hy5{&uib^sj&7=RNnf zGiUzfS+vui;B(jA$ijFx=>KNU^Q%HUNcAAO5NedSqg__rhyg2vT7pcMcY1|jD#~1+ z)Q4M#VcANIYO(FaDa2m3jyIv9iP{m5oSK8w&?X_?I z6+LkAtQ-!5!sJpdN06q&U|#~LJ>cV%EXai|)#YEF#Vuu<%EgH+P8FJ?3hhz3=9qkQ zT)y1b#}%4Wss^VO+7rrgTdqE$P#=}AH`TG7?=31JKTDwu(9X;he1yp!nG(8;>fqNQ zbHcGZfHZSnJp95#3tv8N zDpnh+>n&CAR3VRZjY4Pz!`N)fwPLQ7q(D9Lk~Pa})y-JZj}(w!HkNE%u{z2eN94mc zWky{M|5@e&I<3@~pfwf>Ug#y{G5~@``sG7ey&2cUoGW=3p+A*rq)cCjZkz_%nB1gm zIL??1PN`ct6`dJ2PPF%Bf!0z5sQUu=_z}b;xkLR5(q@_G$7?gdboju$GH6-k(!%># z=7-q6lL36bryfpJOZS|;SMPk}w`JBVLTf~#Lei%gq9S0|FEa^+li1lg=d{O>-9CI? zUN~LaU8p2L8&#t_PF$i293)r&ae(RbaysTwnkTW z=1S(}yqD1Gu#KvasK!y7Dv7w2`6M5`GQ`Sw?}N|*Pn{aT>f-)N@4LLz+a5TtNB358 z3@X1hk+VZbPD>11wrHcKR`gB*XA8lzGIlBem~sBnwvy@B-f^!UT`d$FuOWbW=T)b4 z>u{V!p07*naRMp}3r_d2x%~=34bdz-|XHu;Wbk%yL=iYKm z&%f=oJ_Nvi#x=)u+vY_3duMfNaY=Gn4nEAH5`j}&(%PWX4Ebx5Y9F=7+4lj4cFwB= zb{(B+K(HfY&+tYUW#&viYeji9UD9vg_hy}+Us9&8(381ZLeGg5J>xDO;PP=zu9`5m zm4%@~HAKgiQa1JIDy4EYnFUFawiQmitDY^XcLUY&2)rMW#gROq7OE{}w;>;oGG;92 z4O&S>t^lw=Prsh???l+8-o%8Ga|#@kDtb9HnObbtE4hX0Wd2JEl?C6&fy@t-;VD$;&5s$sRKQ19yv*%!#G(V!CjHhF(TCZDK2lPz4M`W={Mi?8=B)!^1Z|&*!2ha_JNix zoV{Br`>Z)HiTBx|Ebuu;_8G9__Fz-DZeFdwcH1-cDbIePUV6)ub;J6GTKL%|oBlQ4 zi|jDQ1w*!lhS4+u*d|qt7M&sv-bZLJ_w>B$uGRI#g~v~vkIML-w<3>I#j{CT5|15n zdmm`0g6C=Nb$nx{x*h5F9(qLYJAXx0J5cZyp=BB{SIvEuTKUcwYZ5!qMa~sPE+A`Q zM=m{d?vgHF*;TA-UD^$L&)JpsB&8Vey`aspQ*P3`2IDMrtHPTb1O)OA^X zXPWm;Ww$= +{_G~uKmr-jUQ^7QVsqu%ZPjhBA<+Vt+1-Q7zv{V_l8zmNMP-kkZ@ zk5BqZ-*DnXmgervIH60<6(O3okt0CN0!}c{xhwlRK4i_eR)6!3JM_Hcr?mwSMu0B~+7)sqo0Bc>D|sBD z^}0d^vB-u~!5>a50!y5urY;~;4}h&?*-05@iu0OO`=}jW3cc$+_aaYL4nbWGGB+Ve zr*Z^ph+N%B4q)n$xqye6!|}6HaojxTejx{_X13IsEH1%|qpy|4AT1?ZiX9PrEGd%n zV^#Gw^fxK%>dTyABKZyViWDiZ5IEnUub%dregL_v;oorTAYreL;CI%_xuMJq85atE z%Kf^s1v%eKu2&Z0HG#)go}h&xc<;E-Zb>G|IewIx;aOY_-w`A8p&T^woY8%z&N*K? zbH`ZbE&U3Biu*1znqsaZ0He$eByZ?7%ZFMaju=_vS%Ig=#070pTmY( z%*S_r|BXpzo^f6w4waB0dB^-t0ll4AKdoDiKUrIYV_K`%xo+ynXk8N)rExXUtX6wTFpL`OAI23dHh`-uB*mRK-dm zI4wETR!V9~7-ns)XhBWgAla^-eccH?>&DX>!As4Y9)D;RQ`y+;rC`Hhn`J_ojd3*51(uJDH<>Q z8T#&sS0`y$n})-YUWwozd zP>&O5QD>b7p;t67PEGXK#g%%TdNgP>83c`@G;M3ODAGeO0XQAt?qZOZ2_sZW(3z9% zgsRbiV0qTDYjk9?re-ox(*hX1QwRZ0gyFrqCzs4ZT=ZidC(mhY%ZuSSf@kkL+T>NZ2b8a0zjpNda1B z9*0f9V1~X^vY}kuRNY>s*lf%Eklqn{iu*!)DsvHQAy>_m!@5fU08lsa zh*Ci{{6v;zwl43-5J|Aoi5i)__(91+Aim4hQRR@IM;0%^vvP)nA?JYlN-6>{!jh-H zl39>*fvd?1Ws(Fba$iwTALA*CmZT6T)VFfTWvwaxAH~jp_VzPz{2SL z7Ql3r@ln|z#_`MJb2>>5_6AbX1^sh|MwU|XfB0|)9aZ#kaGIQ=x;cJdZovwl)jKac@J3w*n%*E_G|y_Pb){oeQJ zfNO@YJ$AOM+-QX!Gs#4!Ub_I}@?fP8yYW^%_qyZi$Jv>+MP0l6xm1!M-z2^8tUXRm)5{c#2{ z#?G8MlV11_w~qF{|2U3kbYK;LYA_7;#krIkWQgtZbis7-2 zL+G;7Wq_f_m`l!$%{wJ?DqEciTjowd&aX5iQ!qEy!G2b8Yysk>Uyr3tD9SknUzF=m z-bxFUdx@-hk8CE=HYTfj`tcLGY5Rx<095Lk2b~vwB3Zy>(8}R}4&DMzIRwB4A*!h; zJg+wcl`)#(U!CGZnaeFE8TqJYTdZKt!uW{bZhzB8@Jq`kNc44>+ikr zW%>ut{S^J(=YEnt;prc#zj4Rk&__S{uj{3^ew_aLv;USp`i@W1$KG+5KJi(fqK|s= zC+MS}_BZtL&;A>F@l#%^JFb7Wo^;iRXnTAOJdPwcz%g1GI+MH%xy#l2T9JiF(Q8i& zJ-WD{dmg)AZ+Y-+CFlpTId^gEoO9f;PsH>h>7(&eRi>o=n>Ni4R|9veF z&O<876Ds^2J8dUiuf(I}5`t5u6^@iWRqSwFal78iPFY1@yd``b} z?;ctHzTW!YJ-ze6m0tBb59t@)@m_^0s9Oc~i(VU}g`RxvRO@l2VUU`x_GIsjRB7MY03d3@VH+5{dh@uZ0Cq)VaiXp9OpSw2oXv3!m6!8HW~Z1NZA_0Vp@*`7 zWoUSrHm4i9=IB+r@zjmF@#GD9^3^x-66JQvlXUa(tM%louGjU)PwND89a}r1_2D`W zG*V5l=RA@R>yn?!)ib$fO%{jDS&2&8qTHa?`GZ~k=DqLGPyF)h^))a5etp(=f3-g5 zTmP|c|JF~|^S|w7dggcipL*u^e5#)QZJ(@9_^yAfPyVhi(Es%vU#@@tegBVs^p{_$ zU%ltedf)OfWdsc^@}(6!r4H_$y6j{w;-pzCKp_FzR(J!Te7&g6BP(_^Ax<8IUf%z2c0yxOm@7 z9rKkdgOpf;OaG!1d8CG3;VYuA25>bI2h97}g^OBQLLW?v!W{R(!zsWAx@&Vd)W<#d z#d_K;*U9rmi!upN&s+|gsn6<)Ud*e)?mcl6k!jXUbaLaEuG&7O3I4n}n(6rV303IQ z;|qC}Rp*t4RjspR!oT^>$3giuzq6~~cu%i~_PjD+KO5DW93E>#4JTu@qltQSK2MEy z7ZvC2L5um0POSI3<@88xp0hwyI=VHJ_a;-u23`)C<|LRMi*uIboRc>b#zLMf^Il-Q zE*u`+_NtmzHAzkJr6n>hm)TC5YW@HK5CBO;K~yq(?p3nwZr^=4B=pv|LcZMm5sUjH z_CM@?+igg~U1;YYC2n!*oKjMUb0-(ILz=(Bi8`dnB~!MAV$299gf*E%NMmHCjxjxB zS9ng}^|Zqh^*v|Ok*uqw@97QGi=T2#+heC)R)7^jaWFUS9%NnKH9h#=x!(Pt=@;Iz z)N5HufAx1S>&@@HtOp-X+V5LsH_(akww{0UZ8|*~$<5OtuYKwXDgv5-T){&|@d>${ z^Eh}uZ0ikPy(!T5nyn++p02CKQEFdlYc$i**@h8Ga^z4n>bj{!fJ!|bY zJ#BEao;tcozKgoNAmiA%M?d_Y|Ej-r_vh=*Z~EK%```Ul`ud;z4`d^6l_$f+Td;Gv-4i~b!@zZN zu9C^C^idgO9=eauDd8Km6x}Gyd4w*(XN+)g=Q#El~RH}YXu*jiag#0g+O1SLl8D0 zX9$%ld?N884yRSHN-`(!xd3p=wK963tD+w%_@N{k`DwHV^n**%M{*YA;3=WYq@bQ_ zfR9W22YS!hhnS~QHe+_F%UCJ*^1P3E%GEdM-JT~}L=Da?uf zTe8lUc&VyZj=39nUjs-7@USsj*Q6OM@WOjGnCaBk32luxil$XnL6?b(Uwy{|y6@bQ zbStVHiB1})QPZnxm~U9g)ltnbz~6!7I+4<=s+|meR`aEYOv^B%C43({5&R&NI@~v0|ASZAqf0{1-ZdBKRT!&Pg|%9?Qr`J%%3C zwT8nQYEk?Gl$0kcKU(deKzXN514-Yfw}HEo3uH-O6!5_WjWU>5On4+qGZm= znU{A?W+oqk%G_lOM}>NLN&v|#7gsOzLfkQ6FX}x2Iz4BDaTLhL=)0nG$_Nz|k!5{+A!qr+&wm>2JOKU+ayJzFALSyGc*qxJgf2yFr`H6nk_kFFVCt z`v>R%VC;x^#3LYrHmjMguTSdfv)gp%v8U<2M<3J|{K|Lg|N7yt)DOJz)w+M@f@0Ii zxp?T5DSPx(LvA3)xp9VHta@m>5)3d%JKq_Zi2lr6LL%pp%N+f5_;K&od!}E;=Auu| z;)9KNHX$>9BRTYGvNBde|J;{)%W?$b&hDeEZRcfX*sP2-6}x8WTH*DuuO#@$nF&lX zzRJ(XpW(^8%%a4c!aI6`Q-GEEih4UNjy@`Vuyt0XmzSVz=47;)07FUylmf>==34nU zlmO;zNy~0e79zgxsh`LI%q)}4)bq6>Q%u;@tdr6ld(Y)Nhx=dOUzZt`5Jzul)RiPc3}>AWkA@6$L&(adgP4&XhwIvoQ%c) zO1IDjcz5!uSWL*J0JTb2Zync%KjQ^@;gg=Kqs>HVZz-RJ4Ki~%iT;T!+B3K3=!Z2< zEeo8tN`ZMcX6tCt$yQ0eH`Sq$F>hXG;-=Avz9+Yil3*Rt)^MgV;O=}-U7O4;*jXfr zV~gF(S?4b9X_cJlGSME8)N8$hOqw%yPLX^P)$qHEdcIh*npw>>z8FSwO#N$$J+HkCnUg zWBvu_o3x(!*D@}Dq;K~}>P_bl9B+i-{xgyfrz+=iqt1;Gstz8bvKl~< zC66|n*l)`i6qDIw7N7jyLI@wl+W=~oi zaQb1preQs04x_)*A}_SNxT1tJm?dStN)`kQk^%*N5il&;+&g(+Gp?1nT7oTN61lt< zjr8m9eV4xV=l`=_`tAQfU;2h`BNIN*GmhM@jlooXlvV^LFK|mZYiD<%C1_lJy^0u6 zDfK8S#iTh$K>OIonyCFeKP&^@7d&lvt6F&fzhCv``n2!(68*%lzFJon7lpX+UpRMg zZ18d+vO+{T(Csl!whYyDI65;?E>HMosVFm+fD`owRfDP+ z$1urZ3j)yjp=9y#9<<3E&!)o<7juR#c*-6MQD%-VTshr!$apw2b(y3>(CU1)bV06rB05yyRKpmd;ng04Ok#s$&;14iLzglL91sW5%US) zOSx4!R!Q^~I_`;?lnXeIwM4i>6M&T7VH+OG9L3kmd}T+|qnJ#+Ij>}mCM43 zML5E}*R98{(~EDvQ!l#h4qeCbMAcW=qLKo<;m1WAsi3$s8S+Q?r~&cK$iQ87SHx0lApOb;SlAR_snMdF~Nijlz%2vc_?V3og{;eYGsx zt7Yb`aRwB6$_j<1$N8!%y=U)=E;3&Uguolz;tU?69P)r84RDIqH}5w@U~H7|)b;VY zZom3QjjcX>7K@CRSA&Cvx{^oMitKjSPFab^+FU;&5uB+6qyg3VuwKY9~A#0hO zF5>LTGWVHmgF>B?=Q+p`prshWYS2vtexfD0AT#<@LFaih{$sCsjh^+dKUM$o6P zuHUBH*KUQrSNVq^dyBaiY%}){7FdTdD6n;{eCMRjI9BNo%aW~FCUc3Q(9T*d_UYfN zUoEs^0bKP9Emym`bM_=%Szgp9{_sE5SNza7>(MI@iC9twK*UQ|Y5x9u$@o%|irs`< zR(Lk!S+c`>cu;wChgp^~_Y{80jDECnqfA{o1kzr`7fQSMlzQj9NV$}4M_qqKnMHUq zzE15_a7l}?OnOyM)^d=XW%S44_ z%$>Oy<*;*>6@SHkf=)iHqfG&pO;WGYY>ab$q|x|<3OI*~vuWa|V`YwUO5Iq_aU?<# zkg!(*P_rA!*IRPcQTgVmtlm^;wkbzsaYH_CDb%#p+j7wEb0Y!TRZBnGD~eO2uFJ?Z zQVpG|p2<953O`Av42QRa&v5%g##3$-I|` zR*!Bx_nChCED{XU4JV1Sa8V;-|C)8L=iXH5&L>4Z`4*>Br?QS5^;%y~+N2!a^qRFR zP3jIUthCXNw6#`#r(f!Hpb@b&t_Sko)No&e2SpDnk~hPS5uKEhzztYHR}P+g)MAIF z?5-Q{*h#Onfd9VuTq*FK`<3dT<8}{4+bQ#5drX zl*nR=0p`3YyoR!5B;xIReSGq z&O3ZVw|Y{mrIuP~9*~SAY%|!HYsdxLiAmxR+Zf^yV!*Ktcz6sJ*EUY-*f$y3dpSz4h+Bt5&Uf zty)$4ZK)5^&_{`cMt0aKp&;Zzmv;$v=eQ_huXDZN)&o8N_D;{{-o?efR;=I^jzT{udcqkdRVs;UrUQ;G4%mtY z53mubV2W-&xKUs7oR{h_?ki&0hs^~1^2qGy$blPBeG6ecKhHuVEh6v z1NW6mSSZIR4;R!gZj|CVdAt=Gw<#^}P+Z=oN1TXudjA?LFoc7Xr@5CBO;K~!i(tSP{)OhL=VIqk1*(BAT*Qd&xOz`}J=?(k}zTpjpKjTbe2Gb={dlC}CPpMRUa;Y;r% z?mNBU_Mn^2SH(y((|mS@zZOh`P6(PtEm~FTk}hvHTF*rXsnfw?iHt#O3YlwsIBhXF zM1u-3P^6Sxedls3O7zDVh=I!S?W~b_C_oB-TtnlCBb~ie)kkE-PkQ|BU=cNt*A7y& zCA+!Uhd=nIzw2eI*S{Wh)HYd9`|_s^c^%N&?GOI9cid8|z1W$Z^XTxe&<&h%8bZ(N zgKI#@OyH;8UD~!xjzIXeGAnvUP{Re5Lyxi$xd{g%2WfMN=!Ovo1t@m(w^e-T2c4Xu z=te6x-!|xVFTGK}@2*3=_@<iNl)M_3Jt3Z`RA6^*rt4EVWb#SPMZ-*!G%9yrtrZVWmLzo#0L0 zAyLAZQf=eN%R~$vb@}ltI(hO)^U+2N8zj8iEGl>eHS&kT+{$y3^F`Q#6L?ulNk*dW zL)3@w{fxfthyR{_?!mX~r8m7$sRsrfWM!o;;xk3_ST&wys^QGLiN$}ngDKf{b~bCf zE)smTGqu(-6?NAQI|ob9Ff*#=%#qZr7<-ul$^R4_qpqxv^al=Js(<;3U)B%&yMLz7 zUVBIm4^J@2}S)tcdN+kf1t@7G_umzwJ!6cix&+=f3VUP)% zP^A?WRo^HZi4Qzx4xwg8m-A>ovf!Sm)UC)ADl8h}s%nFVZVP?J9C+0VQY)T~zOv)j z6l6-^iZXo9LlT3)!3(r?ok2Oo8tzhOwW6f)TS-)ARR(&uAYprPS)+|?qE(t?suI5e zY40Q-91c9LKyr@h2F4EM;Xr}J36v^ytFUFwOe~CFZw2~B@~B>zPhagw>U%Q74ipoJ z0DP~myG{LY3v_4f`ncx#DbTvi7K-75I*NeM-Jr}OS3Rh)Y~Xa{3?$o1HE(>hJ6N69 zqT6fr)6TC7GFDoXd(5XgSwEqp<0pi|pk-9VQiw~rFJ;bRfzK6UJQLi7>N>_wPaHj_ zYo{mp*`UfU$So>)Pg}qSF_XCkTgJV#0AyP#)ZSn1rbR*GlJ1<#shnYs3tD-Fa; zPrm4aiyvlG8UK#YPIc7&j+!H|1?PK*ivumtafGH}i#?L|9`hYMg4e+0h<>LLe`*pL zsJMsb7r-dy3xbJBBe+0%1xU+6#m17EB!f622*j8zkgEYJ^5LMu|i7!?B<&jZTuQ1~6^o}riA z{u~{!`c+n4gQt)>xhKsex~!m>_SE+`$O2A?!RO+t-tpdvUkaYuEEx`v!GG@D4LHz6 z^|nX#F~~x%3`~Y@wTymdO;n>HEF=PAZUIPd#F=exPjCJBNA-38%U{%k$4}{{2QT8> zc&j;7O*9Xtw%`R1yNWSWF&jK~@DB8B zk!2cz=iRVrMQ1M4Q`50tb?`#{(0l)x{>(rAZasSGF?F$5F(`UO?hYu7!h6Y?z?+n* ztg4{X5_Oea0T@%|+04~0ho5#Qb*pGpRc4$q#x0Bj9V`Yw&@*@nM=O=P90k%VbZi!6 zG%-gqeloZSFq?QQGK}2FSjcKkHcRlOh6j2AS_GIHUyVmI?dar@#8iNlDoi_U3HVHZ zEm^{T*DL%6V+#1ub$Q?cX+<+#|0ifP87p*ZF-Z|}mBzg^A6v=rfr<>J7MU?t%8o8< zX{N9#xvI@9#6`1N3Y{X!)6znTZM;&{jP3>2!U7z$!Uk~6xpuu7vZTl~DD18d{>XdpowFexQf95f_XMh6pP-Qt`U=}`M&pCXF9fL}Iy zrWBNkofup^$jYpUt%JJ)eV9y+j!rjQ)Me^C>D;idgMMENe0R}x${gvzy@Ht>KTh!4 z#h}H|E4I}RdywpGB%63xBSdOfl=pJc&Rp}WQT&c;a@ zN-;$ZVbZM!QRh-t#c(@ink0J4e7esMt|?klEVDJ~^WxMTxR&8c`LS=5R=v`TMt?&aQrp8ukk>yEo#q`2HyMsJ02 z_oz$~&diA{Lb52wL4zR={MJ6{P49ibzWRs1QzJBPNav`IkylaVDETB2FW@Mes3cIf zggshvozO!|tgQ)JvC$;JH|S8vQVCud(YT&f+771*TxHkhx$N+b?Z^szm1?h)imswY z{=M$-C3@#mAJC7z>1VVlr*dIg;KaQ(Lmo$W6+fBhQ3Aq|m2Oq5=sU>aN5Gqz%#25; z1%e1Z@E`fCs;w#l2J02HP(TN5o*D3rdRw(EOX$NHw^q%oSM-IPyAi|XsD*CuHCS53;|hJv?w@a^C2z*s zz4aAn*M@|?mUzufF(if1@Dx?6GM;mVJ7hL9Y#{~yxdq8wA#@5P1VSGxY*f3V<8q=W z)|d6*rAKw|;}7cLD^KXkcugB0wT&C)epXMRGmwxBxiWsGP|!l&9pzPQ>rP^>dkTqU zoet1PVjMXgRhY@pL6xb|ZqEh&s-(~oDreuWed5=Z=QAc`b+MP>#|1w#U_2~9?8mP>uE*G|J#qS&&gN6?v(p{Yl7gl{ z!{EWuQ;YAyb5IIFZi1hQd(#;&IKeRqs>*h2`XVOn4FhedW$JafT4+H|(o7DjZSJg* z>e*DmDWw#VJt!l0&NDtx7%`nuuuaXW(Uu1Xw$o-4bmhp|QJ}w?>ez60^UL-B$K_wh zoX`LLa~kHCSu}0OLW0cNkYI{j^LA83GtD6j49l8gDo#+_n<8@-$<@=G%BSoCq$+j2 z=H`{P?54~`I=}3+zZet~cfPiE7A01vNkJI}HN^=PwXJ=t7Ayp*xT1A%5|!GyRJTE4 zRV(OOoi!uv3=MCU064Cl9_y5q+~7H*#B*$W?!5WgN+bh;rQwaNGBl)wSvtEzA)|v>k2tfYVN3Ebtgro43P< z35AlxI`9pB0KVwtnBX8mney9OFSB_;c~TH6|yv=ziWA|SMR?_ zum8;-(R)7eepxhC=8Lwwn|5}Z3E1FBi+)5E1TsVhN!9LZi7Ue=(-`WIV~KwpDx%-^ z9}F0ZFlFWXBq{Y?K7xK4e|iWqmsG-IoGX4(>-;fI`lM#NU)dg@7`P9KpOEYroEyno z+sg1cen}*8)dgcK$yVH#av!+&l7iU7^Nh2|6}t#7o{gp|6PFpmJ0tJ)=O#l%&#f_; z&h}G)ZVg$jsNjFKRyCWH_N-aCY*k4dDv^7elU1;0AEg_v(9o<{#_zKl@MggTMGA z`kCMUMZNd_yY&>sPL8t@3CY1%1#d37CB@LIqU%huu7036+#n0*;0xYlh?RgtM~w$9 zcn|ajp;zaFfhJFmSj63yX+Nd9REUU0pwpUZs;wi=i`##khbG z^15{TgdTk2K3zV0Tw6QU==dtSYOvR~N6eeZ4e)GPR;3oZ14oQ81hvaU-1#VkRaqnv zQ7tc>w1gX@g1ho|BV%XRiphkpSBi+$vR8+_9m_S}AG(Arh>G0`c#QrSTufIJyb{A( z67P?sFphtMaB@h(Ik-tEbb(T_CcU6 z2yP}zt}$U<##c#$Ym>8vGS` zDokXOzJuSnYHa`6y8<|?h93MTl4+K5pxQ3V=(X{kNg{L3 zvIdKMg6CGv)Tm>^b%saaC27^A#8MAAe=SQ`f?$PP?BYe~NAnmtx zntzS$@Ede>^K+bo|GbX2Z`8^3t6I;$uCw`VI-TC4YxJGYZv^&D+UjjOnct%0>CN2V z49wegI=)LM+uvYudk@e5Ufc3sReKPAIFp602l!Hew_Z*XF$6+UBN0l)_X z_Y@D5*&TX+Jy92rD!?|L&5{CkZFAgVOs)`3tK~w41?KqVlG8`Xo1#i{=ed*ngunyY)nWAorZ%qqC|einPkEVt>&o zS=AZ2c+ZrCm6LUQTMV^bfF0TtY&q(;Q8b}CLjrU;{wCt0u+Mfp6 z*&To2+n#k^*Dt$%`@Mhr?+x<*3Gqk1{q|?16rZ)f>UFr6EXJ&qg3_>1t2XkqhAD|i z(}jbjE}mmMKT95>auxg?+TUk;j0$m(;k+$i+qO5uS4{hgI8Lcm)o9?5xeQs&F)N9$CBUj6 zYe|TpHL5qWpklB?!d9YMTs}kQd*stz4(%ZXJ-R;D|NQemrSI)k@@U(825U`-X?-J`k`sb(kLx%W){^H2SfKJ=-NiGxA8ZINZfu^a3H-LK@K z7oCimTxo~L>`XO0NwWM1!TRs9?XT;~>Cfxx=4W+0{tWN`M%OlP&^G@LbUq{ZV@34R z;|uPSf}f$)t$G&lo~kae14!;dtH4%Oo!~1E>2&ic<#I1Feq3|?u*Uj+aQ+rS_*-g8 z{OaZxb#40=+S_$Jy-TO_?`WzIs8F#>Yen?Z{&QwGlUWD;paOQY3LVJ}9nx>=kvTFl zLvJG-@N;8}Djx<^0q_J6By`saU9 z-}a{O(_0_NOlU-7-8A4m$MFtK*~Ug?K@C_(i>{yF;(J@M66Z z*!LgZtMC4upVl}2%-_&o|F{2GKmG6Dt|!MUN}b6=l(18epBMax^`8REWM!eWxIvXg zE92*dSar+ro)mz0UvMW5d|#!Y1NeXR@dtGI$|Xf`#jsM7*)6KUnwzekKBbHeV~A=; zEtgK7keDatcVVf%+eeR8p+?ypBo?Wrpk>#I=_+;79*coh{287EfrfXQXSE`u6!d8g zWyI%;+7YUSps~!VAPCh}B|UQWn(n^$v%0b!mG<{EQ`j90y*l_hTW>Xzi$Y8+#!GeF zo2{E?F_j-zXo11gmZ$UQI$c-|>Qj(|Jwz8ZmJa*Q&bK_Hdh3n*H{1-kUjKSoyFZ=g zPY0;My#Dpqk#>9CEh>Itvso)RVv_8-R1u)4-S?Kgn(`uJEtp3vy&Wf24BPjv1&K0s zNn2LJlTAUPQm|&cC|TvFAmobGZqg%{PP8Uj3}H|rnLTy&h~S#ECGm{3c|zf=f_>kq zxwoTy14rtD1{5ufw^}T%h@>-xzL{*qpD{)HqfbWQc#0UyzwBWDZj)+zxWz+7j?!EnOOtj4?Q z%z`+|!K-#L-;k#9iT?#CQDWexF z+P7Xm^Uhx&86GKSs;IiB8N@A+9r=arnr?D^jPvRbkObeMYnxvoA^(!Du76P{Ja6;6 zmGxQWdQ~1An&%XCgJL+Z7zau4Vy=`rCG3QnfSom?Z;wG9;_g}SEiN;DF(-yz=1Jag zcqdsz=GNyV+DT(RQnAN0mAh&0MTWQO>gE@8H2so}Howf`@f#fBeS{qGX%&A6TpKy^ zTC3(PsH$DFWTS)xl~OlSdCKrj>cG}#)}Tz*e2t@_k(O&4ui!E{Hry@n=J05pge0NL7knADss)x zu2i7et0s5}B;h4`_fkKlmO!v3^P+6<~|R&L)hm zh^{NTttG1Ydey8dFr7 zF-_12w5ToywWPeqhc_=lZoNVrv{lInYm=9Ork3!j^-F9-bs$zaifA`Yj?Q}x`FCDwm{FOCR3m) zPs5;he)J>yj-UGhz3kSPi+m{$t{EXG3|;|TuQ%GFTY+297qnW1T)|U{F*YgUz*YtC zt{$hG;mO56*KUgO-0+!6!Ci)qHtSh4JRlrE{8Uqh_lcd1HCjd33K$%6+>E3h~a}ep5Ce}n}4UPXKx@--mAHOQnH7X;s#D! zNh@_MdeM8$Ea)>IgUpWzw#Nvn%bN2Udf)PXra}cZL-%ZRs*L=IDI=Ji7FpbC%_8Xd=0CNo~qfgx3UYJ8M>q6)7u^y=b5F$}Wile7Y+Em{+f z)nUK0Zr0Dd@7MJ8Kl0z}zy7(url;1I^!xf3NORN#+=fc80Nd>19+3%x$k(2yN!pT7 z9RKer;exwn63WajX)}#F;|QY0svCW!FFAan-gxPE^|e3sJ^G73`uFwrkAFmCj3nbd zWqf_26`ZJ8MvR1B4ix)~aKJb{g{RUM8Y|dk#HLl&1r3W9D+zHNeRQ&9%JG`6Za+oU z`nxKAq7=Yq1zn-HF6xQX`}Nr?@7DuYKBkj;RqBc+=+4->u=o}HH!7&_dKv#Z3ySz> zKzChAx_(t?k}r@Ugdm6a7>v7N|Dq8cTxqf6K@UE48C#Cp-@j3+QCE)6bc)`pOQ|<4 z2h-jnVW%L;H4_Ks#<`(ShNeU4`z3Teej1%;u zT%%UNr4FYf*>1L3hGGkd4d**SktdVuk)uorCM!{ z=0Z>4dW>`NA9};jO6i=&?MCx<)ZSt#1Yn+2(G{Rd$X`m4nM2#)N$Sz3!JCDIPT-5A zD|RXk20zv~ZDlyEnb1!LP0~meG0|Ax+M*AB1I8I^&Q>5Oa23X`KQo;%se)vhW|HYF zakQdq(x)H%3|sWaRqRum%6oM@zF9}(&*)@+qt^4km;5R^*pu6yLRgU4me}x+whvu+ zCiPXDdL0=nm z$OMf9`NU#_`gbyj-CvCG56zsn;BU`{suyx4A*76ZutRt#n(Dne-n<*R-lF5_jXIg$ ztkeAKTIUZl_cHQ0)o_z*keFF@{W24J8iA3FgzN@xl8tE90%R)m7(>phx!^TOgOuPYdasFvvb9o7DfG}c_;e68e2!i+i`#~zQK3J} zfOB(Ar^td|tcj85chAu8f5yx8_Tz{2RsY9d)IY}eK6(6zN>|l!F5LW6)U)nEQk40g z5=S1ThR?}ksm2EGM2S zd|4c|u_tw$9|C5r5j%C^K+AMqiC8(me?bP#z{~)Z)zE8?Le8Ka-BnAG%w#+_8H1bJ z8F4ac0li9YFc}H+RQEjom_G6F{W>EaqA`uJm^yMy*2#L!H>r&_>oaxOL%T+Y!eqg9mk1MPA-1&{gH`*PDS@MaFo0GMNtcmoh7wa7HXF zL&=duI8kc!-2nl$!eI{77K)6qQ`%PuhpLsau&3$?3Q{%rWUg6MRaA&cF)&uNyO;_c zXKql=GxLjJi~CGpl`El-qDoa@24^-9G{^$qw3wOU+uReH=D2R2Y ztm$m4-hovGSAY&dA@S@@T6&H9Bu|xl5jkI5b{=OZqt?tFNl-lw)^$N$AC<7pb~BN5 zjiU|McAfFVqDs(WLdm3n4J$7Ob_SqDw@CpVG^#O}#PfqE8-3u(Cv@A59IyGub!Ghq z9dCbD$7k;(aor2ZMqWCFdQQRi6IEBB`V050s-ocHJ&WH;p;w;{6_gahF^T9CT2JpGS-%NC{TY(=uaU$*Eg7p7$;X%r zF^Sz@-yO<5ff2kr+KoPVtqKC_cwGCWfBqY9)ZcjP|Dso2d@(#%op=_ZA&PpyCT&>^ z=c#IyHcR71A& z!)?0#{EKzV;q!IFaH~3SI&wfrMs8^Sw^}UFNk$i2nOh^-h81H_rQ(Pn?MbpJR5^6a zOjpj1cmGGwB}JJ_EjI}ND?%TYcNvSqphTsvQgnXRtFx>^{wmOIyOB)(tBi@u%vA8_ zEwK_JdBI(W2fg-|MOL>8jG3~TLt(qVg(>>{U&FQM->bpm?*;Pt@#`gYvDL*C5(+K0 zE|bUsI@ubIDw$Qa)uTB_h%mX>lUq=VL6T7sn&xpvfmH#eWSpfr7`aXt_j-a@QYhNP z8ynn=b7hOV=gBAa=#{HN7itNuB|{*EzT_dQ3DJ^?;{X7qKw7`ig4}})gC&%;2q=)` z+?;FVm?7u2w)s?7&o0698mF3NVouysN*5HvzGN%K{!p1f6nKPzsz=^>WuE5{nK8gC zi3bOk^*bB^C#cX5z!+70DM?sX!cc`RdXRuiss>Nibb#~61999Z1OMfdQ~is#yiwlW zsB4=uNW1VYZP~F*>k;0hu8TOIsiK5_nbdSAD{>F$x3Weiow40&&eSj9h_UTf+H&4M z@lp2p@`w$pI<|#{t#3cLQiuG(pa&k{y*U-{Ax8L}WysUyGIX&d!S^BQ?$c8}$!_Co zUvPu|_iy^cdgJ$ey}s&o=QK}`$Z+;zrmDzd)j~s}QxcAjUU*P+^x5#66yV)0(AUF5 z(uj|Q(B>7jql&_uwi_$inCiP09axZ>CS<#+TDHuWoP-v=(-8NS;sAuy6Kst}7n4E= zf;DhdHM_QE7J%&(V^kY!^xECjNmNEcl6fAHrSYHf8Tev47F6(sk~7b*!pTQD(q5t&9{)Ya=JAPRwcEX z2QA=t1AI03<~}!lpkwhIz40|GqxT5xmZIR=*?On9E>8uqx!S}Uwrq^?2x+IMP$~{1;yy< zVo)$C@tP9g|1PHv69FJxVk z)3Pn)0ZHk%+AI12x;w|5Uc?1PmfTGbJ@$m&aPP0^S>ZruZM#lp?ZA(i1nI2W=A$u_ zsW5TNf>UuJ6T;HBMc>USQwaF4Yq1;@Q`F4XzLE$=65ke2I&1%#6Sx9!oA%+us;U*` zlDV5Q0Wh*VI-8`qcE~lzcxJDr4?O;)K6G|Xf9^%M=|}$TSLply+b`1}|FTtR#} zFFT!wsCETpY7@<~ysb2<$WYa6)O?^&b(l$JE)gtUS}CQay0nm4RPZRL1z%~|{--Qa zduCA>r&Ep78W`k850zG^Y6`o!JOfm#CVXe6xx2jCh5qImh8~379K4No1s10GscQOw z8<~aOdj)TsUx6_*8S@gp;UP&1GH^Hj?w)nMuVVl0`~Zc^J88eJQ;v3KzZcl;ZhqzW zb$C!HOXwhWo$h}49(~u(|24g^d!de4{I(p~WERkt&=ZL$4ZX}kK0*8RhooG*oFKIko?H1gF|v!Qr!Rxs?w~Sp_8ohF=Ff`5_2va3_|c&P^m#7;cytY=`c7T@FN^E z*EQjEqI&fc%1?ffd@?G8hNIye6mTMir97+66%5WmN>$6WjCS5IBE|(gHL1>CZLqIrEo0M7YH=9ub8I7dsZi)f=7$nhDD?b?r z4!~wAOK^jwr-F13=u7bIGI!0KAFug9P+7dJIUIdO2E9U}HlDycPwI_665l6IkMxQ0 zlD_Y&UaGf!=U3|czU4Lg@)w=gd4jd_y&!XBG_zAFr9e@NICF64-qE58ell||?DQ4v zQwZkL`k>KlGlPfNL7q+?0`R4oP_3`;7W5^V`K}L96!3wfqDo(nodYrk64_$0r%Hb= zYZWRwhkuj9!}a+YS+4VljLbDNNhUK>!47oEfNw2$kkGTh=sX*P=H6b>bCub3KA8#} z-4qglX$On9RC}23C%>hm^;>j0y-nNt2`OA-d;%EUG8yvdP;D-%o zhYs^@kuX*C8r+d1s?iYB+y7XRxH5~6vG5qrE`w#@XI?Nx>v!3n%g?_=fB7x{hyLDA{G=YZd=1;INK^}%EhJzKKlXS| z&MvT60uR((3c-OfU`)AIlH5vxg+A1!4j0da^?*DgstRjJyNhNfxW z`Mne0EpANpKk(V~n_3J>>iSNLMW@E(ut?yTb$)N5!y%EY>MoZB>=xa`pfV>3H5V0l z=#z0Am36Ri?Xf%U@Ka#SxW^O^zUFJxt6fZOx8JMz?**_MM-DYQjxmv zdvz%&!tk<-Fdx+gQ|da6FpZRqNnHfSiz1GZ0)f_?F^Sra0 zh|L&eY{toGZo2!)`xN3<-}^^jptpa|*XTRG;fwUDXRmZ1SBAHVq&tK`37H!|LVrT0 z482Cf8eHRnV;Xl?K=&S84MxTt8h{|Q(N{DRkekhk3JJo!suIR?CW(;n#8nG$oM~71 zp26#(Q;Z9_qYDTYTE_N}Qh-ju-nCT7HQFu@+V#5yxqVI#wfRUU&}Qh*J6MycFmFeX z=ZTL+6N#;oOw9^oBoN+rRW3*aZ6^;LJAOV=;a6xwP9Yo<3!l=__$#_}_7<(@PfC75 z+;(!3&j;`QnEu(_KdzS^zL4|gSs``I3F^9}h|ka1cA~k%Z!OG~I+8_XT#YxrC*;|1 ztwE~$KZ=@}a?#-=H6R8|UooTx#<{mr#v7beeNewO)n~R50c`qQ@=c^XFRQcb0r3SJe;>w7+-; zkS4=sqCnW|Z&EV+5}U_hYBJZFg^6hnJ|k9;g;|k>SooOH2J{pStW8FeZ^a*igkIsq zs@z>3m{06LOs!BGNZbc|`)aMl#)LeX`4alUUM(mZZ8K@c*4c_!UDS21y=A8%+O93y zJG=cpR3+@~1|KM;ONJd>EAER3&)_sOaAnP9>etUv(id>~7XYiT`3}WWb3gM@aOB*J zZxtF*jC;TsC={h`)*KsvwI#w+s=bIpn!qjKnq#-~oFEj~lpPGmm|4||QVi-6g7ux& zIcozhCqT=%)l+l%V32W# zYLeTaiwDnC483M5xP+mJYmLCUNiMER^G+UDs^L&>z3R~u>dTQjQGuLXmU~i&g9^tV z%}K>}&uXs#5=&JD<2Y?$Z-t>TR@ikpJJ<`z4@WJ)Du_Cgv~akNWZb1BcSo*CkW$5g#siV<&-~>-tpDMUzf`ZdV_%gj`IxggPJWg7_!YF@ z3NX3BTRWNviDXo9cSEiZG)}5AbTh-ygb|DU0uIPmo{(IHhx@BUVA|Q_-FqQSs8>YKzqxP+9a#O{u2vs+2L|?^p+KflYyee zf~g&08TM`bG!hL#Oz548&KY;-nWK#|n;aj_#H`ULICX<7Ps%g0H(zXtwnziJw;ufa zdn>hg9X0BOH{PZH@!dbFpa0EY)k$oL7Cx9S)utBEHC(-%BwKenk1}w(BNN4oVni-z z;S16Bwb$P+$3t9Fv;%zZ1{D(fq%-tVe}y~%01yC4L_t(fa_4(ONK2)3Uc+#sEG*O% zQn6hw1-@JrpY1}Y{b3JW25hflLPzKhz6!7wfHM;_ppO@nSxqmQIIqYJh`N{*Jt%P( zmpAyF;S2rZYYP^H=q?ncRfk;YwoV(h4~H#Q&T&-K5hn%?SW_i$%@i(<0lVn1UDV=B z;3&q*d8P=Nr~Ttk3(~Gbz3(xlDO*2f_{9*CqtM!TS2O-T+krl%){TlVUkI9TkU+AG zI9Ag^F#1u{qK|4q7{RmXg9gvq?;J(ZQ)eNm67z37Jk$z@nbBq!BgQF;^jobcH);<@ zatlJI3cG$6Kr;UI7SvN+br`!4#9g|ezCW+#l*X1T&rX$aN&=%o^GP9?DnZ+5%Ul&D zTL{Xbmjy|u_hal)({x$o%Dr^JrkuX2>VauNfkf^;@SL>RyG<8veT9bon>4Zzz%slq znU~}?$V!j?vVxnQxO`bZ`SIV>9n0r{&vr(QA%dr>oh_TdEojXdy`)_e4!_~!VJ%Wi z9J_O4DyU7Xs8GlZ>_if2x|gb2ZozcWEi&E>c>{b0e5Qkk4(IwLA1Xg~=>dJ`SH4Cr4%DmQQzM>;OfMzpD7~_RTl56y_bA^te*7z&v z*3he2gRPQGZb7v;_bB00(UXGr5`~6aqAx18-3NND2vDi0D@lU^xkaUj9_7G`Jdotb zQwZK#kg$LorB}R%OrdmrH?cWgMPlGIPnQDn8g2LU9?o38C>4c)L!s zOA8IdzGOk}jV`t-!neQ&ZE=MF9!<*V;*_uH0h@Oun?N-&)dzHK{uMoT`lIR(KdmR% z`+DN&3|v)-d2Z;N#n7wd4HwD6tnpyrn<;8${BV*nCRKF4)v)NaGwA&!96Z@H_Nu{rP|O1N!I#k1B=b z^*9MG&?z^Mg^^}E!)_5>*^X-mZB=0r4e?NcSgh=RhHw$60~K}|i*!x{x&%tLOAt}! z#+;yeP71zKmk!l+2a;n?s6`TANP&J@>%+#a_~9nE#*=YxI0|@LtLzLWMPL&&W)|_j zOIi*~#ndTa4})fmK?`!tK#{oYI*QfjAPZzMkEpAYnYweA_N3o*S5EKkWWK+Z_97Ag& zaG$*998(lk1+~AAZi!d~S1h8()TgR}myWqZXKKf-357d&&Iq0+*pf=H;-Gs7EZfN^ zv%ooC#^s9=T4~#j4kZ+!2y=-Rs>#|v6uzY8@q}&TMt#4Jk#B_Oo1wR_Vo|Hb%{n}| zm3dJG$8`DxE)N4YqeedZnC9&hDwY&IDMP#cwZ>GgabC_x z1`-LVR8~vLCz+XimuOb#WkelY5XPMY8=iHtS*x5qfr6!(;AzJa^XHPfp|!lN8c#=_ z;%+gVS@<&B(bhn%K(NFBKA8%w;xHDPT{^Y#Qt3rSi9T77-JXxRM=hYwZIm5HrzJ|xl6bRT52Pt&mYh1t+K9fnvPFR~60SX;+bwdJPk~cVPK|E+a z)y0EJ-}|S(T>t9N{(imuwuL^8O(%{QVvNv_>bj_aj*r=m&Lo3Y@E`_goSnDtU?Q;Y zNfSPDc9QWAM= zCtgE~x9&#$gP;0QJ#_q}Lbp)yULxVP=L-J3i=l+hw#wM4TW_8hYDWQ@=2?v9bZ1=8`0kKkBni`etLNjG1t(BHE2 zJUKb0`tLPh*C9p4E)nHbF(x<&y0-50;KS0G2N9l{Ip23nGItig2|3r%&m$zzPxoiX z+S3KTF)PHV&?i@PgRuZ{aU`+PL?SyKqdxrUYx=eKU)As4eWs(!u9<|E^OhiV^(aa< zDv{VT>YQ+(5pA8Wi%xLBn3g)&JC7iev6bkcGn2edQCP+Vl|GXcgKOxMVhR|{lrU6> z!i>R8a26b*3ygO?2H4`5L57lzMCl%NxOcl!8W;=ehI2C8R|pqWZ7B;KZJ=)foqgu+ ztA;3FKcI4azcSTDuuf_7D1tw#+8id2B-V_Ls}faGAD1FVWf;a|l2DpFfolnkQO&sw zxy-jbi>vCC^FF>-s`a1oT=3+S0%Jwv?yogSILn~8p!F^_x-4$1gQX&RYGjvQS3d^VG4PaZvOxzvn;E_x_nbps#xAZCcc0 zlF}?<4xh~=prRSQA{3S|WN~$=Q}C$euu@CfAy$U75CXXh`jFg%Qc5xlJiFS}`XYLE z=9<|Kch`XnJy%uejPN|tzoqgy-969v*ECH`kKlFqDhkZc8ThGSt6pXa*cl8`0DlW~ zvtGdm<^(z8L%&kMH!8|$Ix=(dv*R}-pSy!=Q1B!Nzr(-p`l3PzGBar8U2FV|B+ZQd z8-KFG9I23q(3mp!0yi0Fa7meD(-x05?!o9GaH;ZdOww1s;s*VLZ~Zd;Phb54?GHsC zzI>HL;M$TvHfI|xsLCt&+d>=6(NWPnRrq!_U&$D1L$&82K(Ro(U?*%r3dzJxDOskV zG8=ZqHsHv}Tj8;?!p7)oav~l+)N98hOz$n1a&)x?$1}Tg`lY+ytsj2#8+BS{^*Ko1 z@r`DQZq7+|AP@VJuItXYr*y#B0PQHp7DnupoV35ZMbEwY%XEHqr!+5R*rpWhUe1_5 z3L#|7Zx5M%t8~{uA9&or6_lWTZf~E3O6V4wz^8(%nyCV5*lk9}7)6{188S}WQ7!3r zG3k)Xa8Q(4K#x*r_O7Bw8qi~;Xj$SfjfcLIR(()!uJikc#AM-Pt5f`Sx^&g_*u#@9 zKVJ2bd$JyUGNR|Cp>w&-GAkBhPzW9&AN2)X{sq8lI)I*vyGb1cn^efda|h#sp+Qex z%{rbIx;7r@{)bKX-9PKnl}^`ArRtEYu4yw14`4brmn#4+{w=DpX;n7Vy{m&EV(igG!u@c}$uE4!*r4J3djdQAMY!b*(~0 zX&#lS8ik8UNgfYX1B(tc+;RNIJ4Y)HelgR`gi18xNW_WUT`_h-G#S$E@9(Mc*)Mu| zha-`g`$bYq224ecnWKjpcvB`|M}o9xVTv)rQ&p?D+=C)Xsnm{eT`+Z2kFG6CpL*(k z{TE+ytNy1y|5|;`E0?;ks5&__@VTs-Vw>A=Niv?v^9mm2w8mr3RguK%o}gzE!o<)4 z(<#PPAQK(E8h)iHZtJ(BU_%G`TR90?2dD zQiHXic185z*r`&1XBLjq0I_AePwe_id&Za*AIQiPS!kQos8bVmZu2Vr6MQt>M|{Ae z*wW3ztpEHgZqq;fvtO$3dDR{I@adyEWf6@e!{DQ87~n5p7l|%ptvmaoMhU%}5gXu@ zNijvWvueRhU?``?OfS0m zi}V9;{HJ=y``;^zQgQ8mRs1>jhYB6V$>;RaSP?N$8e(uZU4lk3musoPFOpn)XeYVR z7_21Yitb9&IV@!sp7uU+98$2I!+XQ)65PAav4HGFM7_My1o5>6vA)Mdq!6oUK2Z(YLl#sdi=^pmrrM%PC=)0(iL_ok6qo8 zl&ck|3c#iG#OX0MD&!qkOVH!A6{4X}GZMYQi1d10zw1A`{-f6> z=YQ1o|NE7AvC6;Tu3C61Rc4?d$?bJvm%zv)YQYoIC?Bexx>j}X14SRbd!vtkdacWs zgRZjToSp>Kv6RJ?jKo;yP8~`ucxoh#+<16_)peQLvUd z%E3#U0c8xK@BwFb4KnXgxhcj$L$@chPSF#_JH^3A`gao~D~!3+%KgmJ^{Fg5Z-vsX;yZdelS{(`u?n)`C!ED%B^dz|mBc(ZOm1gwO5`;k`0v8EJfu|*QhE#Cm z3~X+9aJvV!@s<<@7MB&WK0u=Q?0i{2{Hd?aZ)Xnbe%FtP({N!j7G zlE_4+3_IA!{30{aD*7&J*DhJnSE(*r0&RT_mzm2gsNwm1>j~zVI+;21rGRWE*JiU( zqlq!_gQAwG-I)vB##6)F+Ke3Nblqhb{;lot|FXqMx#B(^M2ZN$6u_3*Qh8 zoXc+Uxt-3S)t)z$ecSCu&SFYAt((t3Q$O^^pVEEDPYIIBEipg?7PMhUVdwy&?MIbZ zZm~5pSZX2WS!n3B=3nN(WQj__cxJNzU$ZK3ppyZYVp0s9#LVUL5PFBIHt=TjNzqI_ z{?YB=0&{`Hj4gHyxmKJVCy*^ES}OLmQdN-SjF0krG3ut(O8e->SXdpp7QhiqIjr>9 zCDT0*yY9X(>$g92qz`>^(g*ID^r6rHzfuhV01yC4L_t(+^zh}XvHdUYAyc zeDVwfQ~szDxLufZ@xoHwVxjdW>D~t?-SgS3PdzZ|GY@2anhN_fpRIc6N$`xl7HQDU z2YY(f-hpnW4Ja{_Xe!Pt6kO1JCCTA?7)c6@3zQG%=DuJ);oWxYi zg0TgGGcney!|*d-T$^-e2+5BtLZ;vdnZP<#sVADAc&~=DPsmdsKsIdYdO2s>>Fn2) zFMUSU9K{s%wO0%*IKGsq9Lyb)1UdK3>0`Qb`J+-^%Sn)>ZWxQSpn{Q)j;;xh)YKNF zX;w=xndH*$El5sJwi-#G?N6qHMWwC--jsS*H%KuWEhxpv;xnpUh54#HRj10H90WT4(BM++mBLbeTYOs;7kWAK-J=3L3NHy!!a&e1;4>u>YDVV;zbfEq zFs)hhD`YEm1TOF9h^xsRk&Q;P2HWb6`J;b6!*k_;F4$ba?+o~!@)TVAVgfAtIXq02`qRO`5) z+=6CSVl<*w45dyA!5VwuLr2(b%>psv+uLoXirmUVfEOnLqPr%V0=|}aeo;9(7{|g` z-nF>1NffG6hdzUMihZxeYN-sr7y6~%^3ePArr-Q6U`z#BD)<7rh}a|e0(m;Q^~&%f za|IUfTBA&Uy)hl71@Gs$$LTlQA^l4n0xFSzA)9dx}4i+P@l zCW_97A3xt2aL+M#Y0_t&+y?&)CSe3(Wy*>tfV`|RVG_zuwE#M=lC6&rqAW+Q6 zk_aS=ihfb!>1UN6eYav76yU=nDyv%+s>o0dl^_09>Fm=g-9C60iX>^=L?vq1cKj8o zTb$GWx!Wb=ZgeU-Y}H61(1&iJyY76hv}M?Wnye9pnMLKf5|~m2zDXnG*=%O5&q$)P zRB)ykf-Dwg_+rnflz7Kj)dR;dMlg?B#iT7Ve)j5-UjLtev3}^Uf3047*B(xKMI*sZ z(G2g>$dOYvlM$pbqG@-Ss@QQD_~y)lBd%JC9JmmI5YkSf7j%EbFLH)q9}-QUx8N9 zX^Y8mI?;AyVXA9*04h(Y&6G>8)SaUSOfm~PzgWpwyehszI7ryBgWrSw0}bfEp^q#= z`^4FPhrGG&U^W}#7$xn+q-PzT(-+-ztIqdJSt)pv)Khd$VOB=A4TY0JhW@Mxxu!Df z>~yUSi)kSCZn-JxdC!Tukq>ptMNyQHR&tVevq1_b&{)ZXG4!;5{Aodc4kZhj$)G9i z9M*Yh21vXo9CGFy?{F{c&S#y|?az$5>$#n7x&c-RzO&Ga%V31{8%8EccTR zE}&qP7f^J?sqqudINp}k?CA7VwIxP#c?illStj|kl@^(yTxC`jlVe=iGiitetK34F z#50prL|-l#L$hWqXapp{{G3vNfaT;Ij@Riuh8Pd*9wb^TJ1kujssd4ptlkX$jl_U z`k^I=>w1+5S)B!NRQqd==CEA{P8`t8ca=}Y38%Jen6;Csr^3m#h+)tsW$#tXTAr)+ zalrdej&y#Q^zZ)mAJd=tlh4;p2N^t39xf>Ppw#y)C? z3ERzTVDivDE*n_@_1dA3WpVa=Mi5#GP%lgm$(2Ml*-|;8) zvKxDS_~=v#T)h_=_8&95G1E?;jYdJI(F6JuJi(twHD7UL>+tKw>&Uri22O~!S#LCR z#8L5;wr~{|knLtIA#01DirjgcDGIidjQ*;N-l_dB!oB6+{}!=2%Rw}QFT`G{Tgma2 z3g5w94ll+u;YU&ch0`(~GomAGQAZN-2^d^X>}j{~M#bn*o0Q2Pc|Mc-R<&;Fi=f(5 ziWlW}POI*Mx>k`(D)5})zxiKjv5tPuEf00W;SE}*e%H?J9je2|TV$#*hIrtuGI+gk z@q!Nb_Q5CJc+e^36AkcJ&W?%YSrva?EnK&qi+a@^=k&F&yj8FHq6@nDFsdsXZ8wvy zon~Sy;9Il$4w=m18Rp#lL>uQxuh-MNcEG&>xlxM5Eg z3_C3}l@$9|n^z6;p>0qv|8SrSYEsr^40u$-(Dckt3rwr)hM$Ad5JJ z4&+zw*7TwOP4(Vikj_3%=N0&<67a;HtghsoAQqBDZHdN`+GJByYt>xj4z(+!)+cQV znrJ~Obi3ezL55AO`f{)tGb^pIZL?~^GZoQI3{fqK)VUJ)LFc-tdmrEEOKv{UKmDs; zuHXN{PHFw5HfL)oxSCFyEros^3IeCoKT`y5 zwRsIjT&5sL?`Fnmk+HDVI%_7GRCOWkK!umwk5-iA8*lX2(Ex1%_I`D zr~pr`xh0vo7Q;%09gB(r{#dYYnve9FyE^@&|Nd+BSHJvu`tbTGjk#ABf`(Yt^$`}z z^=6Wp$sIc2Azb0dNE%sz_w6>LvjE5NiT}*-*3>(kE1A38Q6I}6gI89xj zv>_Sz;ep=z{&(xivm?e$l?6G2tHQ5gD<^3N^dG<@8UKO;^v#-n3;vlk6W_H~8Qf&- zSZb%Czfm#mF{T~gj$Y-b;H3;+M|MB1gZUZ6AtW(o!%lY zbTI5|k0UT)x9Cv=I$dz&Dtxv|%BAYDvlHF@-~)Q}$tM(p>-=7)mp$(W{h=4%pw~Wc zUtj);JM{ZsbhEyWg#0zHxJ9qM>!R*_#-N)yzCGmRAaFcWkvsdKBR(pwXH(ZLlv0#I z$Du2D(ieF77XrT~rrX(8#bHBJ6wXm{RiA<)4(IfZY^k;!X;hdg2)-h*J@1yK?z$=J z)z3SxFTU$QuY5l3d3$>9&8FwwI_UW~ME&tsJyXxWrRPIqR!hn=%5;p|1dQ%l^~25? z19Tz^OeAMBsF>8oRSSxqRq@6p2{n3$Xjlv5nhmPp8{sSM0{u`%C0l_YNAfIrK33s4 zWAn&cRX_1h<@fxY{NxEub&!$RMKw?e7&!(-{w7-m!PrB$OX{|KEWGbuYWm1O()iII z)7kxR*1UN{3Z1GoK3ssSRTT|a!(-@3JQ(sVaIVHqA(%_4R1Gu6?c|KL{y-AzT9unC z2_OIDF6Os!g3LaliibAG%fl>vz3QFS}zapn-5ez1VQ@@^KiyLgtIM_0z4nd*4r>&r}qS)hwpYDq;3@V!7!*a$;LbZv&7 zp)c+Q9WweM8)X7Y{wxfmqODX88D!{*)$(oL9 z1;*4a9`UcL((FcScfr3|*!4GF4Zws>x(8+Aj%Q?r`BD*Vi3*g^$R z(Q8YBnZy6fgnx1BdV^L)}_*yte6I`pg?mYtU9;`nq# z{$rgGk2&?q@UUr%9zHYAMKg&zfN$dI3y9hP01yC4L_t*esr}W?fDHNqE`M6EuSX{U zUM2^1v9A%~PR`EwVmspq5v5kO|96WG!MYhU3N~3Rs$tTone*kVx_a$I1IclZM0C&x zUD$U$<6O}*Z!UVt^HzH0bM|zB>Uw`zs!ttGkw~}%FQnT!Qb3WxTksV{`Z^Th^tn~F zq!9?XR$)V#6?Gqjb_9pPUr-2x@*L%sFm|BNct2)dP_Bn^FrGgCyIOzZr(_TRn#$%g z7+CZMSvNr~L!!!fQu~d=)ix`*2HU6)jN=Sp{|*fpWpU{p+I;GJb$ai+Sa4?e2ux!> z0i@dAi`*oc$-n_a`l37U)E&3JKu>X`GqFu_xk||zNlF#Dr&@Hvem4_r6GuMLl|2zMnzUQ0n)SWjuyOg3W2@dM&aISz3MHSZbF|{(^ zB;|HDQyX&&YL}TIL*=n@FEZx2N9^w4>2`ND$2VtOGJ_u~9{2WMHN&%5AY;>!_s9hd zI2ymTRvF4LGlkIL#TAr*H8~YrqltcjkxXVm!INU>uHR!)qy^-54>D^oG)W;uXjO$> zzf_RDLa(CBiDcLK;t`&U3c9XM26yBk#7u?Yr@rPAMZ}OIBjkcl-$$ zDZtC;+l)Vmt0gA`X0^n>yQG2S+_4zOkkENX&J5jI8C|u7Qs&GuRgUJ?EKVhL6bq)y zmV#oRfRZNsDZy(+Z!?wg%=<(#oAEh;G=Q^xu6gpQBgs1%Hse7B-W}Nr zI@~Q9+#7A9U#6X|uP^oyUVE)Ms>?*1g{2ArSzNY|oYrqdfoa)MER3w?-dczGm zw@g~}PNGjrHYz3Tz|QY*f2ET$=g)eu7@$fV^l+#T?u)=EPZ)kx*!8xS*_BlCClS6v}vt z?aQOl`2hVSLEBzsA7nc z2NjMyY8@0k3h`uSGP%U>*c_4fnYkq|)EsC=@pk$I`naOa@Ino@euoyfy_6L-%TZW! z8kkZkF5`oRT5E{Cs*ba6z2O=9hS&a4J$dOfI#}(gCxEv&O+`NIqBiKzp}$9^u+tOt z1654G_y$bV&xoURLAr+1nPvU6@A^{Co1dfhzqP&Dm?H8Hu~Q2_D`$#vl1J&fPP9u7 zCdtg`D}uA#v$)!`yYG@nOG+)ddYdd}pi_S2=IA@9!Bsl=5hkNAYDra-;=ov^K$13W zQqYT-A1uhsu=TXNx4t&l9bCcbbH&i_?C0uXxKoG;{))tJsc59aESwe60nO{Y*V+Qu zhw#;4MgOG*_}9qFI4OtNBH6mzl{V=1Oe=9{E_iMJC5jhIl}6(=)zS zgtxIoY!&s&=bh8{|F^HzpLo_eedv)#6?56qoJq2Er+wE2(JJ0rivsS<_xCq{<*}u)BjMt-&Jt4`F(bRMm zJSbovGY88oGfA{U%r^ zFxj~&!c(*57M&SCdW^ACf3uv{OJNVxS^A6DAkYJ_6Wz>mI30U5&~uHdO8_jcu}Mo%t&Sxy;giN zXp09FgkndgBR&>QOxRK_Y}f+qcfF$ZvVss)4Cfa6>UP_OjF3)$)`)^<0&amKx)e#I zMeV~ybD9~$&jd$8feVxnQN{>=1`g(m@tl@-{vqkkZ&5w} z;LS`8CXzU%q&7dpr^9rhx+4JH zBdADJMQYE^0@J}A(%PgquoZS>!LTB7)?m;mQvi8ra+BJ@6h;at| z#A}qXIF)=RYdRvK)v^^{2ux;7ayNJ>G8IKngkPuRgM@P?cRAw$e+-snJD8{#I_zFa z0Q2tN^j9#PD}oDrI0mEGWlgcNR?eGUY%nMTpWwBCt8ttQvyink|FNi^%F&yjaQ@n% z&q)6QdW$9}_a3{ms|I&Vcv8XUBK|6|_|jVj{hdGe8h!Kcdx1Xr$gu_&u~C)zNG*KJ zAec$e$;7;@X_JK_V26Ps1q-T3d35zy<4IPd*>{eNlU7uJJV=Z;z0Y&iOhPYnQ33ai zJnE#oAA3M&*dWy^h^uu++fxeXWZ)0hv#|6sUB4PGQM?83&`rnUeLFV20M4=_ciryr zDH0*yICl8LN~#ORbfAzDa2b4A1Sl=3Lsy9&LoXW&ln|UZsROph7gt>et5J(#kekWK z8C6vc?v9&oky8MTe3v;sJ=5`Ks}pvy8Cxhs9j+2{CT;7i&#KQ?i(FR<( zRAqE>J+_KIucLzhqc+BvTc+wWMD_G9fBK-3kE$^Rgo4So>S7P{pyjHQxoac=j5tG6 zKqk0nT7jR|clG#0mybABP*&Fu7&L1}phZeL$CljP)T6`*!V2Dm!jDhRgzSnTsb`K6 z5QcNNgk)wo(1Ps>W6`z0TB)tV7tYP|%QoQSLD!=So z)!+6N>Xy&PAm=5LdSG5aph87HmyJ%>k7zqTrQMYqU%MwM&-&sQzCf>f`D=9l@l*05 zDlB;15a?{DHfNJgxo^>Xz-eG#BOjc$S0|MwldpA!Wc*|{{qNuUQho7DZjf-E;GKjs zwd!T0A}a7>?h4>YF-hi<962+0lbd9;7+sDn+Ov3{;cvo;E3!o9nVDK2kxsA_c*rs{ zo-2G!$`~$$AV}?=+dKz3AiyDW7Apzb@S1aFepZ{?=r=s#a(6X0n(wuTHcq!1Oh#X3 zrVxS>Rd-=^Hhitnzq50z1Z5I-Ghm9%Y6Z_o|KYy$7yi`C^le}HJbmc?OR7hv?X@d9 zS)Xa!60#zA8{cua@NYRXsmR&*Y7Mq@u-{@PX(suuPq!-7khPbYDN+XZ@H7k^G8J$k zhtM$g6xIFR_kLQZ@By&Q!>$$V7GjScfHjkV4T&+UfVmtwL^1^pv@)$IG+$LvWX!-b zQNS7Vm;hZVAa`&nvp~5OHNG4F4TpQsa#mz`=Xmf?*S3>d0on9E5n~R47~mN>GIlvQIM57!;akxv z&l9;X3$sh`Ver}JPrxMj>7#)`40#DfW%u$le>K`q6I3FfnZsmLn90yoQ5RhyR4R+C zk>pTe&YCa;aRzfnNts}of!`od+qzj0^_(f2YenKcSoCtx3En#rcE>q&*RQnPr?Opr zP8m3km8vd8Fu77pTB78ppaZrtQP5|#O>qgX7JOL-(g3_$Xu1@vs1iW6gzF1^Ia6g- zEI4DPg2^1O6nNEeL4N+VTHN)`()0hawsw=${v4|mIE48k=Jg8jyh(>DFZg!(Ew5G7 zz6wcWD%Vu)M8($Jt18uk$>Ap`5a13^jTeWnS{PSlv6}U)3m5f`ulp*kKl||RPkojL zEA=TNYp=64TEyum&epEsR2}DK3mj`=ZknSWInKJ}hM<4*7r$7qylc?<__8ud&zRGt zg$7{v3Bq>Y(XCQT3fvUCPCa&znR0V(b~Q6|GfAqonMHS^1rO@rzws`vfURpSsw(ne zZMrIh1(_P|z__{72Mqm80mgW*?Gc-xNbz1=Zk^0**VpEj9qmki5?WLAC}&oIw^o&C zN7Hw6U;v!ZVLY?Vfh8HxkQOM_o+~yEp_5w@WrnQXY2*NOPnR(te`!Hx*rhE3%{Rnl zG-s^gFyPF?PInO>G<-diMBA9;O6(qneyM=$4s1o#gwEWZ+^vuLzc5*gU{<)fQb`Tc7C4(N&#GC;D%`{+asL*FHy|I?5VY zv}Tg?I1&ePow6MtNf0AI8~=c~n`67YM=B|Im9ktz}}o zOHn;Ad+23I$clT+OAHsYeyy(d!|q-}`Fxes8GPLk*}U zt*BbYi<{B{P8%$1+@2xOpgA{zqzJ zX9Vd;79$2~2l42TS*C;4O8cC@8npYEm^1BK3`uAarUA~V}H zM`P|$lB2duXlo6tg`>Jd)GijO<008oM{gFA8lzFO_mV`{;7el_TU^<@~(Y;|9$mp!-F zmI;n73%WFPP->NVP^-!svdZj?VyBGb@LYj!eU8lv0By&U34%FKI;8?>jvhi#vtx74 zf~+lBRPbhKxP!YnW|?#yHp zZbs0FSyf<8z)sFx%&s7u(Pz_XM*^jmz#71zkG3!l_1-GSL|fOb*?Tm=FlqRMM37RnetpUu#-*TE$|CjH9P_Q)lD;c z9Vi65E@>E=e>9#(t=Ubq|NqBA@)q;<_ zk^*(vcM>!VUBRxb!J3FeSJQp_pC%=pk#FeK7k2p<1}+7UG911cw&K8xAtG=>$&S;w z(31*AEI3;Xu3%M>*7Kaql?j{)DUeT58HWjqDKZD+VB~_eHU!`qA1B(@WMoxODM*;F znK0pSCR0H189FV=6!0+&t)%_c4dlf^sXL&Z-|IxiZxTaF|;yb@c|Kz{;D|-BQ?$-r7pfAYi-&-UN z%MRUF7R0UEU&TZm>cJ--*9&f1=-+<#YxU|c+CyG+nI*C4%yJ0_YP<@%D#%nBlgZs> zW-9z!Z`V}lCj{-Nf+IJla^ycbInjW_8n6j{NBRQyirsRa1Q7*v(%NobKtBQ}HtbR& zXfouZS~8PdP4*ZP_sZNN;TLU@cJ#mtGXqeaV(5Tj!Nk8BJ@X82YsRIvNCZy`5n159 zpqmhrA_465n;CFXj&03MlG9>iRc>vLkg*VplM48G<34F-#~|c#cyfHSaaj4~-Z9@* zkSTbS@h>1}g)Uw4*vp~Y^woTRW>IK(+cOgtg3|&prj!EDE;F#u9Jw1kbI}iOU^Dth zGjLMao}a1t|3nP9OdYz50B2~V*mPu&aM*Tb63P~yF0PjP_CNI^eb?9Dp-*2u(K5Lr zbX#&t9ePPvkA-vO{JOQwxqE0b;-?LF+o>2dn(P%`+^^%_Pz^SDTQZ;6#R=V%IqPAH zgIC=2LhXtM=m%s{b8zO07%7<+cPt{`>q-N zqIp8k=-LcCyqiafxM_B%FxL1U_>L1jfsY~h1I-RcMVOSD-xamKnPlvtYH!smcBbb4 zPMqKc`s@=At9<~BA>tDmcvFYoLJZAa$p={}CRH+SVsYJHE`*8|8^0##nS#3l_(o!| z6`H|a&ioiVLoKFuyyxVjmsC2# zRDA?@n_uO~UrSUq`gaRKmf!&Ze%fuovpW=KSCcRHklxxuIrg>I|p-f zg%IgBnVTXGyz4VpQ(WatcCqMo&ok$6E#b^o(Bq6AMpgdF?D~AULc`@%6aR`A74UdK zcifxL1Ym(yVV--0k0x!ALtDmc(+77ChAn_1VeDuyqo9*OP-GHnX&!DatG1g~;In3M zv2Z#zt);+zmYFG#w1po!@WBJT(yuIm#{~{(SIzWKa5bxN+*KK`+y(x`{9r-BjX4I+ zAVE_Mnb(4YiRLcbtUlL=6HV2s$P*u5o;0#9ZRe2|%m zU)U?j9bZGfmbhmY#U{%T2mHQM7Z|r7kqlY^van9YgKjt+^yj|r<@&C#zEhum>=8v0 zd<;chFr_p|3Yw{!^OVV<1)rXYfg)pWOX7W>G*D$WK4&UzSk!`rIw3>X#oZVvG;w?) z7KR89ZYIgmp>fC3$TPE}R7=o2yLU3;QqA^e=n8ujDO)EwJXuCJnYmCk$wN?xoy>zG zzGc>vR}626ns_`L@V9Y1)0&U!r+n** z(X}aCJ$`&i6R{uqBnPa`FDx{M3BAg)~VLDifsj}%umc+No66K(@zp~ z)NEM*(x6k4q@qgIR@h>hXSI($sS1#yXPmpB*Z+6lAqZ;Am+XpO&o~1wR6Wk((1CB< z0Bk4v=Ktd5y6cXmz($Y-ouTtH90iuC5cbs(6ahXAWchOrR-0S|2Rk4rOmONqnXwW{kFTaFoc>!ZdOF!5&9i?7bF7@JVAfw)P$f& zm_mZ83q3j@6fG^RHGBu5-+|V;e zEd?Ho@AhW#I_e_8W{DkCFjMlx>?p@~LrVs=MT3pq`u!UB?kneoqBt30^qRv?)K{IA)~k-^<%wAuzu3hru& zsr5IwiK_3E7*UlL905l8j>q84lhBi~+bTRw#DZy$Me@)7iC5^)eA&x&Py6c(zBA;u zXrGPB#A@@Y0NtTa@QDARwvlAp3aE+1+TyAGHAKViV6FIlFe!x~2Y0m!eV4F%M&FHX z!IKy_3#uLzQj{U{X1oG_RUsbI2Fb7kf<(TJG<-qrasi}<*N~~um6==6ZNaB1c8L^b z**b-^FC$(P{t5`w%1(v|?G@jwwwtXc&YrvI>bs%uhjxrkPky;9X*eHN{JXE=(wwD&t?Ci;U-t zKDXF@i1b3MT5&{1P=tG$KVJ|qyBx^4(3fTir&!g|OckR~D5H~ypx~~Ea*SueXkhv1 zFv^nmwwie|iG9BIW@+2vx%D!0}tiTs4N=8Nn#K9UI< za%pY`w8~@Dk}Zx~RKWQq<6l%$o>^7Gk=&R-5GhU`wbln;XtqM#%&2a*diC>Ps9*fsKcUA@?o)4r_A9(WNw4*sX70$j6(d8IYF8M|D6ZulZNqyjGiJ`H?L)#7R}O%DxcLZ%Q* z1s^KJlo`(~B5sasL4kXf)NpU1cMkh}^VdIDue>Aao=X{Xm@B3OF(eygJltk+_+6^6pT@ z4^$2Lf=kW+3(s!!jaXsUeED~@ef&MDtyU=WXkBmWLl6=X9~lK?+dN3pk$M4%dYr@#jmI;h9Ged zDQae3J9`f#cEN2XUZy4|f!h-EDLL>O^R^`Eh6@Q*P^^Pf@Z#j5aSR&S=~rl#0+;#! zivO`BSpNJEvT4g!p$WXdh+6eQg%x8OF&f5+2-c;5{(>q>EX5U^_edkS4aTLakX)-F zA<(RBQWM6^bn}h3s0VJtvBrpkuAW{~`wDyI^i)SE;~LI&#Pb=6+AuVFSH*B<&WdgV z1=wfql?J6|OcF%n(Y;qO90Z`GEQdEEVN@26SsPi)Ql%hjk-IaQrXT~>aJH~|HnL2p z|3vZPD`or7k}Sy#y0y(S-lb~CDt;NhTmkwgClBb+$37s3jxe&gH#J0(Vb}G{F`2o{ z3`Z>lQY8Rpef^hyh5p@N{|VjysgLghV_*B!(?OrTdaQr&m%do9c~PhHoolf`CIvO; zmPx^c(``sRS(|ojguW8AdXjd;J`HBVQO9Y7$AV6fp6XLnmr+Fnd@Rw^Ug>l&?8&>R zOnG0uupmkk3&yr4UE{;jwPq*aCw2pw%DPZ32%bipG*V%UL}vtUA)!=NxiaL7&=R!k z?p_GQ!gxXVEhyR@lgd5pWC)?@2GGy_nAz^B(%Wft000mGNklD=20z1Zn`O;%krD zDtFksfkgf+nXS;{Ea3}P>|V7;!e48A_^sML_+#LFL>?Ck5x8cX)*2p@1g`~sh^rFI z1|JS?g@&imF(9E@s@n1A?mV0xH{1EogkE=zhwhB3Rdvj4O_OPOw}3`F;GQu1!KBg$)w0OAX-rjrgk9jRCZUd zt+gSMPXy!4HtGa1-Tk5G6888+C26lqJiuHiRv(*F~ zIYZb&6=e_I62-WRq>NI`J9s;gJF7U3G0_(=Y+c{0K4$d$uO`9sP3t<;>j|9sv0uw*X8~OTKCfDoaJ3V|vk*OCQzcOCMq< za7htfLOGNx2tv%|1`P|)vC4Z@;W^MJDwF@@wSPdr^20x^x)=4?`#+(pmu7wcfA?bj z;a6W!WZMh33g-9+4HN-6_eiR38@IsG`q=H9&KnG*W&)_dUvN_k_?Ut+wz32_Y*lBH zh9{0~aMTl)gX1DSa%}pY2iE#`AAVFn^y?qf5B%~6^aF2tKkWnhhwr#s|LC2c&_DR~ z-_?))*2nawkKU(yp1P*?cc&I6%^_EiwE%s$DBtnuYC*eeWV5QQ8ULNBurjUDjVbNs zwdzgSDaHVYRqftbEbwm44%`DB(Ose8=GnzRe3#!OixJ61Ko!3cR%WRch7nOyZh})t$^)Y6Z^8 zwG(B0vZH{?b5*73>_{(r-V1e(Z%LU&%iWPn$ObgJGOLUquY~C9#h9r z!fL#rdUULc&VzMw;#M+Mj8a+ymBVZ6E9h6yusEpe&nb}5+dP@eDo-92JnZI*cX360 zC^p%6uJpCVF2)4B6FVK^C!Jxh5!}HVP(g1bW$+9XP)(+QF9j0xNX&Iz60RV;TOm5a z7W6M{A(cF9Mh}h8jBjt)b<9-qwzI9NA9@Hp{U3k&pg@wNN-fs7Yf4y90e8W5e6nW6 zHn}AgR$=7O!C*S*W}ROqEeO&@Z%Qeux+^4<1M}zk;=Cdt2M;=6RX@MlS73D-IYTOv zB935?>zcv18xGM~RCCHMc5+~X8OhbO91d8i&q*9vs3l>JFQk=Pv&uadIrsujFdf}3 zJ^4$TuRWwnQY!B9(97XN7_gKqq0mBQ*Md!!7(@7Tw`lIN;{L4)H@;L>U4dW}^Qys3 z74%(hQ~_nJv+=`v_~Ezd(i5MAYSw)Z{Hl(QKd9~epyV51(HHb8t>7H39Jg8rP^bx)5@h9{BlelXwuvk=ZZdsGqv||qa@*sRp1^om9 zt-+!=-ucaVdp9ep%?{YCVzVZ4K@U4~@T($s<-K6ns-_3$cT~H~pv6PGHPS7q1`mo< zXTg>V_))}1uj4Qv8{LKlJScLPK){PLsNo2~kZnYsNg)7dRl5N}69D7A&1s8FM&1wt za1C!}&MeGCvt7eNpf#E_Qw0(-O2o+?=cW-`x$Pk2{A-D`B`8RsizJ$s&IoA#R%nBhX_@KgA^)pRJ_bRWisZ48?tDjM^jUf)TmI z$G9G~7Zz&%d2Y3*3(I{C;0q=p3OI@e3LR(8&?k7Gh3eG&q^PK}09(^D&*lYsj&6Jh z13Qu>nVURFW^Qc!^@UuB`t-s3AfLCzXxPQ&e8Q3^4a4E*kjy&)wq(}{=UsBJ-ltLf z2fex!bvQ)r#j24Y+QV2ERs$b4wkU_O)j~HeR_ZbIajTpO+?J>UR9fIx6i^64N7#^D zG0Y0qDWl9ve~00nARaj@HCF5t4^=RpYOk3&-i(Q3&D_dz46UA0yTIRbNox4wy>k<6_9(#)~W%bxRm z{m9??Mt%JszFmEDc;tyL;7E3?&;igWet~C}6md$hDxmkK(-|i!s#`Q5#d*DGxzg=a02fFOH>8E0 zL8X2%E_GvC>LQ8bj&7w_$BXE8q2IcEtiSZGcj)i`@;me!_dlewCL_y~DeQQQY-jt( z4g@6z&vG{zI+R*M$->-fziORC6u@WY-PPtL7Fr>zHQlpw^R^%}Jm)iId_Y`vmyoxr z_?pa-Jjy~RdEgma?eLDO4W2+X77vtpoeug*BqIlRw4~(B<5}=tfNd4h#F0*g*I?j< zkK7U=JBH{{9sCq3vWzbl8}ZI9yRNOwTn_!2MR+do1}B+s$%-Vk`>!un<#k_sB(uUc;^!fFo#D1v#y%i`WKw2^|LPus@Z>{THSuhX>UAZ>vh>vy#V-;pRxJoG+w@on% z)uuQqx``yyr&$L}6lzuHyFp6?Iv5tZxOX5XAx)8FJKQUdKU$x|p#{S^B8hL_H4%KR zUtEn=!C=W2C@)C_nqdyJByY)Q6T!>*f znX49)z$kDbsb)J?4GSII{PkMg@>&%?gH*wNaG}B-hZej`lRTK7dE4*PbMN@Cbm82q z=-wbxQuPDfc=K!Y>^uJxEmt?~<~sP@f?5MmW-h5$@I-4tf^$RnY;&JB>-TA~bWJ#C zhK^OKV`17`E;NvM+eeU|d#g$(B5lKQ#Yp8f<6v9PiUY=L!QF5-C$A(K?|XgfiH-in zJ3gwv`b%%tH@)W_diT*9r$47Vx`hs@8Y#{Vy#6HJw*xlJMR+CAY%;UO3bO?GB=sIB1setlYFBr ztl{r~BI#Im?QR86@>WPqmz3AUCcN0Zfu&J~k2m{IMd+9}7M>A5%$+*CK>Q+kW z9I_ljx>Ge*kKab;8KWC~q?o{^oji2OEGm;Q1NsZ8Q$H-lrvO#_>U2}|_@!&Q@rIkV zx7<_HaR*E=Q-}V$)aFjgWmcO%bD#0M;I4MHoeZYIH!7^ps@T2Y?~6Xl`H;S8Kwm(+ zj`e9yAcZNwVCYPJDw;W`I^m1%ddhN)8fghe6NW(>I^(D!*Q87pYZzI{bxNWxIO0v` z4s<|bSu&?XqSDUC`1)R}bnWPxn&T}<9>O6bNw-2H%Cz7^R}U_?Ag9V`P9EKLuy>>S z{=7nrR3U54?a$Yk=iYH_5;fH`DOT09OA4otDs|_j^~Y(v6HLWsk*!slgEW0$kP1D> zw>LR>OJ%{yoM+d8l!e#qV1Y81?OKpp)xdbayhVo>U!i9`=PNbzx2fV>&$#(jdd~Cz zh%VgtCGdI^cnfOsU6TyHi`o%{G{{H>Rg$SwE?4yMrC-z4@#Dn7R#ibpa5;_>W3S{v zsVRa9GYPpzKP}NQnYmdT5CJ)hFI|H6Cp58nS#ZMRE( z<>D4SgUUjD6r4b?#>Q=zQo!Asq4$E3s!UrJx3&;e37%-^+u$Xmv<-Z02&)9qXD^?tF000mGNklNs# zq%O5i6a86%K=x3v&z5mPAP?+UX>y7T|qUj2WMsbm?rXhfYR)`V#Hx zTK68G>e2PYjv}bVjbXRY1?7~rwJkDM4Rg|}cEY9LrNBv5;or=0T(0n`8NQ|+A41Mx zLHiUuE0Sw+SL5E{y^=&5j+P{5_$%oC=EIG?^-sM_7gs@38#jaJNlo{F;2&A-V!A5& ztF_8O)?$6b(U;((?@SBwfGAjh|BGI!^CZa$IKbhx&1@~h+AOmGKT8$#;Q{?vQn5jP z?NP<~Ns@MEJmBTW%8!4Dg>IwDoA851%Xkj)Kq2jGSX@wQ|COIh*<1#mJXiP}f{L3 z671pLz9tlQ+>*Lg&>0O=#e{+kC3IS{y>5zW!QtK{tzwx{7x%Qkys$GuTiGg3Tq@p! zBNyO`Tr*bkd|A`wdoXNJ#c(Q5YI^D;in#;NMP-ypk%%Ir#5}{mFeqm{mX(4TaMxr4 zH;K5Apv7h2H-WQxXlquXJZdUc{o)2K_nxCj(!A;7OH`(Vot%{bH=J7LGFrO_cuE9W zwW4Yhii&DZ`1tW}>B{zZalAv-k_gP8l8c(*4H|eQeKS?SD2MMv@O0oYb2Yp@3tfd) z`#ajXoU%w>(L3(FtiSn&kLvs0{a#X3)(Z|VYU^1iGs|!VHx3%X6(C|V@MnoTXsA9~ zPzae4{FvO}r$~*JaXYD^gTi}LPiYC$HhST~t@^%uKchE&@E%RT*WhYNuK<|hpnxB^ zM^$!Q6BU2P5U`&*4h}Y zR#%(r<~us=-Ql4%F|pt?X2zJrtJ-^QOiAcqroSk2v{P{U_UER3KWJO`^t%rn>&M^s z8U5f}->=ud@!k4cZ~krlV1#u*FSSf z_g$`XKTqNtH2I0@Fsmo7yc7+{*%>mENQ6>hNfq959?^k`5g4jX%-wTBmtE?V+^9+5 zlPYhMUB{DjbaqY8=S1SW|I6pDK_#8zzK1+}ggEp{#EMHrlC?@in}gdHgq(ufAVqlgw9&7*{w|rO5({kKk#}fun2M(Zf5r&r&0|`_&$dCzIkR zVXV-qV7a8Ey~AhGUV(Gns9GikAAx14{nVnSJGCA|7p3uC1rlgWC(}K8bZa8NH&({G249Q{-`Wpvkq?DCb%8cB{B2ec5hAbBj8gPxdOKlbbQ=$n7x{rcFk z>jeilYJ!LM6??X#(wRmOZyO70T-c6};4T=Y6>gQjfW8ISrcC0n_5_T1*o_&_QLdpn zTAOx>|H~K8&=3FayY>D@pHd--OjM$&=^1~TEo;VqnxD8clP0;$BKk_uWV}_YETdIL zEs5g_JHIgnMeG~HLgu|>Nn(CRuK&>R(SmN8uH8&-E+Sq$NlMV@k*5h=WsEi4!JRRv z;nZ$X0e_4v2JT7bw&RIBiUfv@&h(B?KcfHjUGLTRzWbf}Cm;Qo-u~2)KGU|$>y365 z7}ej*Zt|hCNgud!O>cej3H|RM`+&ahH$R{sc-#B+h6a=s=xFHzeRUG^I2+o z4d|$nfQ1h={)COHRS7=@fkfi0*d^90s*k-(zI{N>; z3$ZFhS8!y4npwLe0s1NUA1O{>$kk?lA@GuAgs!a2#^WsbqK_XFmnpa8T&3D7%q>n7 zd-W;lV1I=(xiXd6h2=RNE|=;sSdVk`sbegWpa%}*aWF9Dj6+dnq8<58SUUM>%8DpE z=d2K-f(I?SL#4EoheRS?DTPDTvVe)CXf7{D%18HT%ZIyVzAR@;<~}RPw3v@|cHd8E zzH+w$G8h3^yd?x)P->%>?Tj^EDu%JVwJ3Rj8~b*pAmT)5Ld1o3tn3>FiDtgoI*%|0bY)5+Z_^lf2DY$zA(bdik7mFl^N4F>= zn8umFL?0D?8&5!Ov+^H za!?v2(TsT+yfdxg4d#l$W!#-sakfUQK(k4nspw{$wjjia<5uojMHw4e8Q`d;9gA29LGCW!&4X4gYP8Lw9DN4-s=`6Rh6VY|4PQqJ;2KNT@Pk7n1r?JE z=s4>`Pd=u<`_^C8U-;Eu)GvMN!%Eq7`(n_AMWO}w-=y;cg%P|v^t|~1RXw0GlLUQpuF* z=Am~<5B`!uyh)-cN|w22T+S#C_7snPOQ)ZDJ4ca^D0RNW(W>zhwlbe&{F4ItWkSXg zS-4Y#RCenX{RHAuaxKX(F(!F%MRYR5TOpqWccC_!L67&0Oo*z&QOUTT)f1P`zUfvC z%sIMtM3J)n}mk7BU=C&6-u<9%9B9yF0NMu9U6bt>bmLN_MKEU3WqOcE>w4tb6^ ze&xQU0?v~7Jiu>9mVho?urDBY^m9Qo*gDiIp|=~;f!_GoeR}B9i598ZEUJX8-dE~; zPj0-kH7;e+4F0CuOwb$sf%ygx2ET=%oC`doyFg;v1mCQ6ZoY<<+;8Yde6|1s_&!x)0C?`p6l`X52)GE?nzvn^yjbHp_{q|F9 zJ%4qJZXNbzDYBDc4t5F1k7`xX9~&C6-l_4BQQAzbZ20!VjqQKYZTQ3O-9>%+wCb<_ z#(VVxZ+f5J`H4q#G)rx}k4sa-s_FMjP1yY}ryxv+G=3>Fk5| z>%0HU|3=Tbm2WHCN#?GS5}x17uD{Y|3yGAhcVW6jq-tnm~7T=totmd^nwXf_+7 z*947_GMsJIhXZv_ybT)vOy!aHqPK^ID3UFKM5oO+Dt3YY%KSj$$MdAJrWNdn1xr!H z*6xw-FC&ZDD7nK%_?IdktXd8D^0}L)V`TWnz1L~ z^3Z9*Hv@fO({`Nk3!IU`7^+%wHp|?zf*Zc#3Q@az&z$!2`u6K-G=CZ}{{f^P%BFEP zHAI{1GOTsnc9`XCujt4btvB$6W%#2_Biq$WM!$DIaw%X^Q2Di`8S$A z^LMnk{2?V4jV-*f`%yp6Li18u%HTVo@KCSFAN%`SfAYswuYDH20$AV|MemI2lZ@n; z1TWJig_gvTyEc!eHC~#-RPbrn??8s@4QH_?I{|2l;0CX*k2hGUVA>)xa5FBMian*P zr+3ThQt~Zgn}poj!APybs}M`*VMrYn5O{Hh);IDYR?u3L(3LxM8YvDMQ4!=G;h3Nb5N>X z#=9fq+_DeP)0lO7I+CD@E`j@Z@4a6qB$$asxu8F`ImkHDupYcLKFk8Bg4{YH8ou{-e*>qQvP4G{b#^sNm=g=|E{W9Ywf-7)VX_d&WSWLFarY;C5WJa zC}spb28;*A<55)3V^+i?V)huobOa-Kceyf`6GG?TnHa?9$$jeH{?fr zimb*M-USr1iOK>cP({hs<(5V=n^ibf!e4vfAph+XAE5m0oZZ!u991&5SpgwP4wDnd zc}p5wS})sA;>~#MFqesvr4vR`cxD`(CLKwr(?`o(bMz5@o5nBDwV`PWAX^50${=a6vXLu#7yWM+8TSac3A-re|m+B^RzlbimE zc-Q->j@*NZ=Onz0DbY)gvh5*h(9strEa22oOz_q)Qg!dWWESR0MMnsM(!6u(=jQV^ zDh6?$4aK4ojC<)@3S~jhcPy>2b#a-6reJ9}$?Ag_Zh>CDP&Ey8Rn1$H&6XA=p8&)3 zCuc{HZ=$2Ff(p;T6FuA$8L-fji?WvnuuXeXE|t-lrG>z-%4}Jv*s(HTX^>eMn3`wj zmJ!R1M?V6rrj|ywFE<1!1c4jVlwOV+l^II_D8^L`*$ar2GERc>e;=8qsx((kRh&fQ zn`(*0g`HRdPs)ISg4zJ*x3jSGg@oO&XEr(!{Di9Cr1G9##^jTT23VIuv7A+5iR3<1+gK?nUgjgyo*eyk8tqO&$3bev5O4E$!Sez(>cW{NkLJAL_7+t zUM)%j!Df0Seo?m0GO~Fq_mWT@u=0FI!xPAkZey?~C0+K6b1O3FDnJOvH)Pi$V zGPY6mPPEpOiauJH`>F6Z@fjtb$Wi9X;)8OJk#zRXQELtv9A+L< z>ovGlkXf{4&TE!=??aFBqaXMzAHMw&4)=km1Q)w?HkoRev7p+FGJ!BCvP|`%ePg^q zt9|9B3Iu0(H7k9@yE7rE;8D52)`8MgXmI@qHAs6MK{6eT{jc2xBemB?SDX6y$RngiVyjz9IsMx zGc&xfE(Kf?rrvu@Sd@%XPEFWBVF5G*_x`V87e6vI$79^>XA?lH0rx$ zVF{G{XTq}zo>3i2FJ`K)lWe8(MlIUjd4igW181_8l$j}U_VO9$lHydAcW=?lC-CE* zKwqXxs?I_#!FWMIxTzYln5f(g#cNiEj#0?yLnkW>fn8e$RC&UVrG|kjxwW-}jJ&Z` z_oKRDI2@*5zn>KM$>bNz{^Brz*_oWeB|jkQF@CYOOGasx%4)8`FvC zIN4C7RL`A^A}T;Vba2M+e&lxctNK~83GK9>W2*^CDbHKi_L|;=vzL)p^J^ah1#2jM zqWVQuzCA=ExJE2HqMRxT6G=;QdWT*suoPoVIg`z?sX)<+ex_ilF*0(!V!_qixps^V zRbb7a(llNMUeD()?@L2ROi?&x*+D-)&!6j;a}Gqm*hG>IO zjEGYy&^SFcOG*?oT}8FRdDb|?$*I6V&3mu8l4MSJS7GL<X&3<}gqobP6V2+2+vdu+z#!QwV0m zrA${=9inl(9BMJw{QM`b=1)F%H;>3R2O%ryb!<*%lD5MMli^SwyM&Jqnj<5fb)ab| zhn_c8rTHD9@TnIwtzNQdEr-N+f9Ls}xNC{k&8RmW_n|cdG4Hk=-*Jv}6 z1kd6z2!Chjm@=C_i20U!bOFtlONC!8yPL>YIs~z3miwM;8X)=0Ii_;0;TBZsu8?|1 zq3LB9z3eRuS2M?SG9j49YiyPc)v_HkV|`P0qbj=BN{HU52-LEJt&)8-7?L&d@c%7JC^P@3$*go8vvr0~v>7Fz#|(rvR7ZxcRD8l)Ctg}_ld}0VJlV8__9arM(WF4A(f1(C?n+&G`on~&Zj>6 z74(Z|EBGD}sF^H9xRomzo%8MFg|ne`dY7127+&u+4+^fN9;blbr;d8C%;L&`!j-e`Q%sQcSTZ^Srf1&C;W0fo%mVraGB?yG%USy}7gP4H-^_Y{5ZRVg zT7fcpl3}$e(F)Tx_DW8PQW=(yYqFy`PD$@WsAQNG_aBA7`1nn%>Pz@aRUF48a}rCA zY)-XTF4qom~}7mryaCLKkM zFZnLALADLAFvD&bA%yiqf(spAd*A^7c>k@OINHKgHqi;AD49a6#0RuxIxF>XHNhj2N?~6}lwr`V# zI*T?0g{NJ%g?m@;V~GnSQ#=Y_dk@hxij-LOSb*;gX+a;p}gx z@jLK1^b2RQ3j;GJ{PavXNJR7qBv8R&ARe|X?ZP|t5ziq6)D!O=OG`_b^OQowOGn;0 z-5W?hV^zxLD@-=&q<@hkSRgnx>3Y-zD#`DKZ<6dLBt#0QD%qsG(wuZ>nsZPG@goZm zbA6842Ti6YkN3wt-j1bTNv_WIwlu87lF_N6@@7(Kt&9>F4r)3DW=YBRl`Rs;>G^=# z*8~KT6tYzTde%jbZf?-aX+kxiEH8SED^5L?k+~8G)ZYsbC;C~2l_j8=>8TM!&rxQ9 zQN4l{jR}~nK$g`bGsLOlXf(#VN>3tL)q)L9qu%oZ1wKy@ff)nBDX$>A9nxu!t7JIN z(W^R%s`z{BN#Yq%5cm2mXHu|Ea&UtByP)?1Uzn$Cnp5}UCK)&81=m)z zO4Zp(t`C0s0p9b#ej(8-@y&>;qI>o6&P3Oct<0OOTkmE%1*`-Z(I^QpYv$IqdM2(n zJ?*}pV|UE7t=Mz;x+zDmo3L?9&+LA1M~76Zo}3p-CJo3?%Sr;tX`+LknF3DGd+(gE zcRVa7n96=8rK;&mRb*gZd}wC%A?wx>&Kp6_f|d7zR9HmGkx~W>r!gi>3Vn~Gn{6^4 zuc-*AsJs{dNi=)eS}T$sZ9<%6{=7Q!8o}z0_BGQs48$}P~$Hw>Ci`I)d zt*Ag%3W%hu0DCW~hhr)x^u zUNCr4Z^EE}<>jN9E{|zniIVA zVR%fuI5jC<^aD!^+vrv4Or}Cbx~l62hv7`~r7R_7e6bTJG?YA|vSvwYNlsjuqf!5v z^m%A~jZVcyl+Q-%3Uc&Oek1%!Uo$XWhY)dE$)oY+6SXh18QwgT*4eQcPv~$@r1w~z zhXcn@%b&IJjLoq^gn+8VSW^icvE3`%>r#sBC`$-)p4j!2xBYCw_Jx`(BUzh`=_IU% zGb>p>_7Ygag+Qw%W24?qyim6J_#;_BNE@$T#I=E=)j=u6*> z$m^HsT>2|PX7Qni=n06AAX-V)XjC8PiI-et6gSRGe(|3bJiTxTvPYZWi?(?vgVY}{Zsv?+vhK@# zz63qZjrfS!V;Rr~#;#4QX=`Cc0dg`;{PKH0tZIIhN?S04;6Pg`NqU`X%d3?fB_Jo+ zFp@HfMzNup=uGvlRO+3QWT(=+^oyObb;W8pTGT^Kj9C4?iV>{ z_en%0Q*%YP4lkM}9J6>YIHmH}_{tWYo|i;rI#-(8$tHXGGq#m*{G~MKe+O}JD)cS6 zY=0>!<-(kTZr81nw!fIA^S=wXMXOXdgl-e$7p6p&P+*iVI>%!=Lx&kzffdoP!jJqV z%YaRg>ft2nJP9p0bSSC{2NcN6=l&^l0FZQ*EupB zQ!48sO0bHh!4je9Xin2%iWwFibqHF))@0qMw7_`1Vq-0EU%HuoS zpHKIs*Xy5zcN#WX%uoU zCyGIvRe0wqMj*cw-2vmI50INT(-&h^Kr7BvoXRWOFeFdY6&#p;)h z{(zIdgwyf@c^I&T9@`SJmCT?S5Y~nq{94D}Pp{G4SCO^HpCoJ$Zzf%M?{JmJoQZ|P ze?qZ84vH9@aUnQBkRU^7EF27cmjueJbmB#pPi18w~p#O zs>(1YK^@*4Rb3Z2s6${7Dppi!c!@GMEJc;d2bi@iina0=^otr7)TabrXI{ZWs!%{0 z=vkq+ppSC=`^VtP@Y;`GXFvCm@qh#l- zEKj*szgQf1%J6iB_bG8qAlWLNy%MSmQ@-ux7x3#p{RYlI@hp;p zRZ1E2j-;`IWnO4x8P;UHNlvMdpVO0K$hym=Q#tFdT<0vz(k=tD0#Q|Sdse?NNy{7)LiM7&3}t{5To46uG>xfEUr)j8RilInSVOSY8;hZE=KCKd)zHiC{8b=YkBxF=<;m zRYT6yzZv31Hz|lzLC)0n1n*_|nG#r>Xv7pncN^+W7O3dRa`31O=*)z*L%-)O%ZbV} zUzCia*>N6=XTAZya3aWHvL6%OCT0pYQmZLwl>XHqkgQ{$mU>^k??(RWQ~%0a{`L2G z`9J(Hm;dAU^8KHBD{uJp+xWw~|DN|B`2xRo$6xW@`#;EgZhtrLd-!Ag+_itew|(Si zc+Q6yq~6L8;N#J(leT*K7mxCu98}mPngv>%~$4jPIxIHmTkXc z^Cd`RP1S0(krYI>3A?)#$lm$kuk+A)q|@A8b`&hdh2$&rjdB#Z<}#XTPB(9bR?^E( zzUGR}`61X|Hdb#`VLB#huS<*1!U`HCJtku6DYPtT2GPoTQvzhB9OZXb{~oiXrfXMG zE0K8lcgU;wdM9zmQ$*t!#U_*DNFRqZ^CXMAwllBg!P*t7XIa zf|EyPf^#+A4=^{-`&vAfxf3OLFORm!jvXmR-OEme=S=Y?=B)lkIh;9$D*VhD3yX_v zbYl*z9g$y7$=-n(QT%6tF_~4|a`zg0RVm-UH}mlR#Om15OK&s1@3k#e)eBV30#1Bo z=|cp_vax~Y;0YX>`EmAEx6yW37}gRJOoo%qQG$e%V`Vb|;HAcu8DDjPPs?j=|q z9NV|9(5O200=BY>rQw1MDNriKi7IRmNQ0q(%xD-3+Dr6u2Im|>fvi$1&r?cNRiLRC z0Z#!>BXrJq0i5+drSe3IltQH)gsw7w1ii~{3u*ZrqNzV7VAaoROrPH?SHV)ngVRU& z_-&u#?H~Coe(-PpD=+!m*YU=W{W{klx`Ca8om^I(%`;cdz=Uop0yW|MKnp^80>;4_^NP4y`|k8;m(R zzMW}&R0u4H)hITT3{5f!*HSqr@mXXIKH!8&Crp!M*_d{$O=e7q-1@+j_a8XQmf8yo zHy@v*k6sB*qT;=t>E3&L^g*a+@{r^3OTzz1%lMB<}5;2N? zaG2901r^9Ruis)e3oqwA#ep}*Q})Igm3V^|lRc^z&KW_=;VfHA3ed@V$`+5us9K?O zu%^9t!1QKgdJD|%^voV`a3m0>UcJPI#wU%>n$xRNLp<~fC}t+wUOYxIhjg__rC^#= z>85H(<3^lZ5BSJ|L;UmC?k4s{XH!zC*s^?3{AV?hBPtnTd9L@^iN};M=P1E{tYBFF z9(R=>N@owXUzCJLaH@U{={gxs%_OVIjx>;3!6us*X~Sffn1Sw)9Hk{;ku|rY6VVa9 z#zq%0C%LzrNLCCG3u39oYtK0>pMroRmgkskX`((8ytd4|{8NmJzW5eKPV=_1>q)zT zQ8xOi(NVI5DKD|F&qOGkWE#Ac zEFq)?aw5x^v&JHtCWT>9@1zLL=cB`<6r`k}0m*ty{k#SG5XpU@#&B}nhc_SP-Cz6w zKmGS_<3)ecFPg5IPWi%ZdvhN;y8r+X07*naRM~BgnLg&^ z-UxGf9l=TdLQ+^*2Ge<74d|Se$YwJ5{;223O)@5pSJWWsol`ZCyitN~-)5qWt==tXO-}>!adFa70yS6U#l=GIk`0^cG zbomx8czVMH&sgA$t&T??9rN)o?&p8L^B#WSwV&e$e(=M*^Utp1wmVmG!o~_)868GW zUaWca8$&n9}gK+56Kzi+DO~NN;Nh@Nqb(8LDJq^bk(_1sg zKASo4A3cwLe$1-&=~q8L)3cVNpX}5!>z@nsx7WByLl}C3wrH|++RGnONd(f56Gsk5 z(iBM7$?MK)%5|@8jG6jhpoI6X8?WK&dk&CPAx0(9<yRQNAG(3RYRoaOUvd>yc3s zy^~*M`CJLqC7=%cR(fqYtWO@n5!5eYCE&@XF~e!RnHOdQOnm4TG*=WaIZm*gu_P37 zuY`U?e2VuB8i<#t^=XV-*hAiOA)SI}aBKKomm>hkZj&s1n{zk@IIYagB_ZlSD3d3m zZ8!@YS$%**^Z)Th!bLqphcL_OFL8;ygV4OWnqy889@1GliPLe%`m;6|CSPh4@{;rdI1|{fo2YAgRWH6hK@`*dGk6u z3W%lRsjDT;Js=8f(*P%DZ|Y@Y+T?+~H*)Ck1M|Mf@XGj93|q>`G9IL8FCNHVmx-!2 z4$2_^u#ngouWNsJ0>NZ_qAYrq5<)xK zq>(J94BV-r)d`dF4unVJP4bOt+jZL6G5WZ{*1>MhS-P07uinLve*72s%TIrn2lp8v zIE`D!3Xgf9pjSywujihuppi9S5>ANza{+9AaDDfH`^v!%jNrd-anM zMOCLs@)wMI8B`f>W@MmFBi0{j6(@BkS&|Z(&kdytG>tVG`oe{YUC-^=eq~_vw8+Y3 ziQ}IcIQbcJ(ycQYCgQmks&WqPvj#VyJqnxqrg9#Ke&MQ&%!I>wf6|U!vWvh>0WW(a zxCUn)uMV|jNHHTZ5%)OR)mOf7lsA0WC#VK1Jmp#2x%f$o)HQQVTFJIKj*Q2NiQ06t zr&onPtUbqXsX6bw0p~w`3y&Ou|N7%sbMg7_;iI3vmm~))b5>O)LWyh{ll)Q6n1ywc z4$KU_q@1E`6k+S6BSENVM#R*s6CV-Mgl4m0a`TA2pNKsA#mw}<2Hy{;n+mUQ57kOV zSd^TNd_)0vwV~)l3A(-Zs4YjWKD)X9 zw8HCTx6FIKa07>DHA5fp&H-6}%5P+Yi70=FqT37H64;9^OlLESZFpZ}!Q&;L!?7`Y zgk)0@|nOD93<}pW(?&HX@N0{l`lamg;3ORKI z5^@urP0dkN1L|fGFAS?n^PADs)x3Z-$>e=Urw`6%9%sT$_(xqQOEg-E^iyp|I+!No zntS*2$m%95T4nE8GJ>13H0)((aN>@d6Sf;?oigIIlLlFxw6DaLJyi6FX2!tf!7oi0*(q#>5B}+%wakg zBSg=~3TUk|k15!j^YftVeMJc-QDB>ZowY4sbKpBwucN^>f^YQ1slODWs%L$2ggfrO zmHm%C$O8}E!b5v+(sZrHNZDjj0?OQKx}D#L3Jx6G$M5|6pYy`M|53hl@LDcC;T)Ei zSLkF2Q&q+@dEpO4gjc1#R0bQ46U^jGSu5Qdfxu2Yx(>w57AX4P%_AQ6DN8qMC3q4 zjwt@K=2kO9KREp=_#9asb4-=}Kv63T^Abz4Fzyv#%bDx}HT5q}*$eMp0wo2ayy0l| zkzE6QA|bz=1DG zuV0wbXyvfj$m*LY=zFsCn3VX6O()AjIZ@6|!FN%<#6CFmS>bx$2;ZZ3GY}(Lzmn#} zl^L01RFapO!(31wM-YBVxGvN~K6Pk=PhN8$GwIfw#)vO;BwhC!Gm$+^wCXGG#Ti-S zvS=sGGt-tq!LseAm>grRyNAw>fvd%T#(UFzrfb9O|J{)MMl*juneFB9u{)3f;Zw2o z6EsG*mY82)5+&fm2)FH2t-!9K-S%W`@ho9%m`P|wizr+(9VKaIBc7Blt7WCxKX`JH0PXt;tV*0XNTXYD*8JDmCjml!q! zI{D9dGG=qKrUG=6+-9~lL$(aI$UYXh{+^@kn^u?)>|V%Rb;(JbbIKB@>`?)5+6rf# zxWJC3#K2Ev*E51oWL53X8D4f);-5^h2*~3-+~e(7`YkV$Lltr_$Dc}=N?9u@fK60A zCYfBHBH26b{J^>do&OW$;Axd5XWdLjmz41n1;jaU&dVu`uFF()!*tp*lku4MR5IFL zj@HU(I!T3$%R7e|cp2uP*@FCoENVp|-WwYVgsZEM5~_rgLUWD?A?8Xp^vscqAl_qg z46lAyKX5ZY@t)t|J$L{p$@6@UbuMW77>t^+fV^RK6xiKIbw>rlcSgg=wp#6;-MyNmc%d?om0P z6Aw;0aHYRF$>20ba*NO6=cYiNlkVru>wQJL-qIh_bq<;jwV*LsMUS!a2C-jN_kF;dXQi!ZlfQAS*CuOjnWIM#k%T#YBO1k9av_=N8OI zkp2q)=De^@y35GC=B>z4o?{hW^1zfXjy1;5+q|Ey%1lD7`Ys?#hk#W!}f`P0zCCEObq0R+qGe z;pp!U43;W1C8^k8X~M{)GjCoPm>P4MWxQT#ZlfP`AbyWr-7pms< zOHhx;?%S?q)=A$kusXY!18a9+vb%ZU6n{vM=1e?m@`7HG9VcEzBQG+n_o%u&QL;E> zEaHDGc}riX>nK{Cg5(;VwzX{g1#r&W7@qz*8n+G8Hj)Zx$~0Mm>j(4{=R{+flF;@Y zxRJZ>`!t7--YZ&;P#H(pAK;d|KE<8)eVtZYJ0|cAG|b@?OpS)oa4V%CSzSHGOe=<< zfSHQyejp!Q62F1LU`QshK9fJnHm8$yn({Y3RDC~q@EB_n> zqs;9rkjirfZjZ6Fut-xicqwlr!Izeoa3Re9t$W)=!GHpHWU7F?wmBy0StsY;&^})> z$5>U-=448z3P^%UsyuI#sWMxxXzC^1N4(FtBDDb6z3l|fJ^PaRyAw}5k5f;+1e0^8 z)FYdcQ6*BgdH*Bb27mU+f8$%;{gYgK^e#?YzK~W<6r887RLz-zmS|qS7R&RxBGfe{ zF|}Iix)j(4==oYH!rz-CqMP-i};flUdFNY zgB&`tp#;$5giI7($0YAeMy_cXC}?i+EBwsN4LyQ!wwuJE}sQ=5k6hE_oT3e&o%raOKor z0e-BYwS1AM*`1lFKYhheRlQj%b&YT}yduO9sA5mMIAhC&k*PaIvPyFrQTnuDhPu$;UXxl6hV<1_oN4z?IgviR3`~wHUa_nn z^%f4r(KKLwZ{{)mnaiHR6~)V`$V1G;hx+Hf1||wnk$K`#5UuNg7k`L%uRxr{OD|r{ z46o-tRCKZfF)Z(RJa= z32)T56RC{h{U+Yv(d#7pu>sC`jd8-Dye;D$Q4XJEdxb{dN9`5&@;e|J-Zd;*;>Ia_ z@%96%F5}!z6M8MVd+AB00cMg#Pfhf5MwAJ%v#R#gO|5J3u6MoLWNmgIQCLY31eQ)l z))QSwzK9+|A>aZ9v&rT`Dz~EN?*Hy*Ryr7lS0Bkz?v-Q+$1^zL`BaOi6Q!H7rRGX< zH$d~71(DZ$DoV1Fv#HWOe$V+_eBQTn#;MODsmkuPnjX~KIPa|IaPb9KF{}o{sMFk< zQ+hPfNwV`stJ10(Qh4nZ4>o1rh5Mk`h2~7!esBh(#TDwocA5JY9yk`c{n5x*?>xZ4 zjTx03uTC3WeA)@@T<}yHQ_C)rK#h`TQ{RDNpK-p@3a=%h!yH{Ih^6_xa)TZ|{vS#4 zfA0L&Uy-~(sa@KxR;B7N8H0JppsGR2ZzNsFnFR%s?JHXe0yp?Tkm4o^z?S0Nn0!@< z(Igt1%GCYbP|P`;=+`0OCD3dpW7h|;B*9CSm=%DW`v0xC{Qv+E07*naRCEgF&Wwe@ zR)N)ko|ku7{WMI5+ADh>$C;-*on70{Lh*r|BOU|sVFIog;NjUifB3=o@MG`)El#d> zv#nWRwFF9451m@_My`$WB^)i~qcVFE9bCqHCz&hFZ*;jQ$-#Q@ar%~??|I3EJnh__ zj7on=)GII)Ot$!z7hZNE|KnB9CUTfi5AXqM%`e!<*v`j$2sZou9dp zfBx6o*qoI4kPLyL#syW`gTaVO=cR=q3&Ta6_b9(n3mfMgN&5KDCXj?nq~PYzyAq&< zSF5=?C6`_awW^SlNJ?$vM6D1e(+NAzt*OtLk=#s)qa)HYRd8`Ogj5OFgvilr&M1jn z$+~cYvE%%tel6X^aErRXV2P^HkT^w!2lvEHE<5>nw$_ebW+hhiGSTW|DoTj41QFqx zGxS=Glry@&ObPGYr`Gzmu%RH5$?EISc}_rcNp5rKFg*pcgo$e_Oe&?Ak0kSiCbBu5 za>77&j zl9%jytV+uK1h2Ex2k1EQw35q>B9-8y1ld`ZUNYVp&ayDpx!*!8%8(ZyxqF@5d1guy z-c^`MTJZ#i_YT3XaYmG`ONAX3<+Zg>RSPBBqr3<}Uo^7Ez&G}3D;8)y|ELhu9`W-GbZ<|ZP| zpiZc6)wRRRPqQ%UlYA)kCgz^dPeJVeIN+_n%AD}6`>`+GcGz8WN5?Jqta10gV?4Zn z%%RnuF4Z*kNJb+Amq9!8QkZ9|tqr71ry$kJ;hMT)p2=}6EpBD1Akno`oD@;X@CH?o z@|^BHRj6287|D=3kw{eXbjwB^O=5fvZ<5YR(lx=(QGm(Kztfm;i(=XV@>z{owtqfwzlJL@;Su2F3 z4Bt=XywT{T!=yxX(e~Z^-EDXA*Z=etj%@^ng)gu7IU>QSCHg>t*-55KEyavWJP1+Cm%q73sc&`bub-xqNzOi;r1!X|ftO4~)VNk>0uS-BW?J=@s%?8x@#z?Nrw z7N4v+mLny22k9?cCi$qjB8{r~UO|7hx5qn&&yFa3&1FnIdIgq(3mOwbkc?Ss)H`MA z85LXV%uPqvxpV)fFz@7>nN6*pvh)ippD>Fl4tY1B5hB-CyV{>X584_EzTqrCCnzKVL14@!$`f(H)&Ags#J|%1;P*un2 z`4T}s=HiTKN%WIwm;P?vR}U~8W)8^oY@%D)&~Yl3cClsY1XK`HI!CU>K=?U1m#-b6X(W%si_NO43agYPL5f9HWC0=J08G}jCOXi#inK`fomE5K;xYg6D2rdEG$R~ zf*S@_gmaayhqN-fV8({_Xv)9(`~AEAi7R%V%X(Z_e@Ckd)>mR! zK_?6OqzWdgq>Y_|i^lonWkAqq?(#vQm&#`fq>5Jxyb~@xaVuw?x`#@EumqNwf_E%R z5p^+wl`2R3dgbqWUw+ZaoYeFjT9+}#j!sx4&0D%=ji1e={A@HK@|11c`G@-s@HZd0 zkt5nCM$XkxD-f^a6ac(mpzp@ws%OwRj_Q3ct1-=8_^67Jh<|YNtsk>kg^rw z<1`j#0lF=b(bQN4l=nxg+Mp($WGaO4g|Yp;Lii@`HG!qd(}x ze@Q?+In2=1bW=xtO3$vVdPYwg6Hl12zL?nD(lgt=N`2OtJMR# zh!v(_y7};gsvF^ajXH?;o~TMMr%VZ=ah}|zr-bHrAUrb~E;|~l-#riPV^h3Fee^53 z!X@=n5BQyMB`4N#!6c16=?HV7t`W8NN;{$x)x_fxvHFxoW z8}H%0*W9k_JNU|dkMghrb|$bgTwvScf_!Q~n*$q@mh*OR<&xu-oE2d+8SkKSu(HrF zYJ&P!G);v$hgUqCYA<}IL|1TZf-$NC&YPt`5ue9+ASp z>{J5Z80vW~A%`IlJW@o$a5!g}TBD3MY_L<2mgTHIQS=;Ua+IubDQSESHlT?? zRcqv*zVdPYeeWl@Z2S34ZNg}nsH&DA{{76+%o16~vZ?ttx=OS%QNcPnv>40ua?-N2 z7E$$+k*MF%ex0*UT9#T{@syBl(`)WnoLzw}R25nG-Wk34JZ{l=>d8BJWCp=BXEPuO z4|H^0OI_EvMoC3uCJOWyZrjCQ-gq1T_#d~jCdW-Okj6pwk}gIPtdJ!{u5Vv3$QiL!#(qe^(MyyQH#jw%J*zL1#n6pQnk zxE4NH7$?o)3r*4YuFxA`N=*Hv=i&>8{MnzqjNf|4Q@Qo_ySVwQ5Ax`tAUzHkEI9_F zA1XrNxGGmbU8KKqe^wwo2@sY^D>H7}U=>>9S!{dJE@dwmo@~c3Wnw{qHmx z;p7ALw4xa=Dw))DTPF;k6xn)?F_X+CICu+aLI!g7w9lBT=qW2VaF}de@CY*_$-Z;} zGB{^mXNV@f1HF7>OBl%SjnS_no840DE1>B<_svE=D7|dQYT3P+l3)+xP)&OajwQR(3~TL z2JZ?p>Z_oi^pVzdM%Wh`%n#EX>SJDay}0c}x7W3NT}8;Ubg^gGX=EiNJ=7UWG0|&9 zn&kIcb{JKe7hN?~#lSDKrVsoR=^#4=Tj|B*OI2M_4;#GVfVqqlA7lCA=5&)ZJH`px zx;k;kf>zezAt&*HB!%IiVTY1-qr?}b^TD8+2k98quP|hWcTRo|wKwYE^`0kmxX07` zt>5T$@~OkTd)Kz?s*YZO7N~lRN|t#KT)Y1$H|$^IlQ-SVJ$sL0 zejrMY;Jp-7rp~NuuQ?0g>13vAumGPVl*f6`Y&xaaazD!8rmDQ<=Tri=4w?rYmi4Q& zGOi_51H9&N7H|si**p=$*Pbujc`a}J+;4F5@MLn|Gwz3|+{4X`pe5-Y5w6}?#Z{3r zhCL@XJqP7=akb$y~G zW-sHb_4-s*+}+%$MK4CY93ljdNzqQN7k@{m{e1s1u*uVQ@8O*{Kg36`zMp=uAj2Oq z6D~=SwXTNLaC9pC=I4R7Oq1nA<}$u=8XL)SL}wn;A0Hf5lW}##B#*Bf ziX(S$wqpgEvR0rgE5$U0WUh>t5AyL5`<{i_vrIFH4UtZ#n_^uFo83n6F+%PJUPjGD@9dj)2 zh8^cXtb58|X^W}~b51n1$+0(xjc^G`OKyn5b#$_$IliOqGNz#6G*9vhMk*50X2v^5 z&d@0#^wLQd&XhyXiro|(qv#}qtQkz#rO3JS$XNT(9hLOu@Q66Qwnuuc+RU;fK-fB5 zxhi86#Ju;S0BFt)FcArZz zA%E(XC{tOXg`VQ`p{}W9!`_R}IbHn{cwRJDRu(CLsfX#wSpWbK07*naRG?}CRb63{ zw3cpNH0e(|8PDc^wWgRfovm}mo+X}h(eZrKQ%++j-v?M8O z)=uXjdS@Ubs>m!z;Z0rRWc*%BQREO+F#rWz6JWF16z3hT4l>>v?*hf?X9{NP+Wu%& zahZAMTOaj+EF)T3+DYXbiov6K(R5jwo#^A5X?#XAD|46UDLTsh*X+HMpZL3<;nIb3 zN!}5)jWLjs_NorFqrf$);=g0-fPeX>C-ZkNyNJJj`5FB6OHSwSUvw6~|D;nTVq#qh zWaul6mvWmLN1|2I@P((UB4{q>YRn|hV>W^5Pc|bf0GF@**)r?ea~~`W*e>9wa+cYw zrIWlSdbNy3v6jk`l0pML$_fk4n#5C4>v=WnS})3keE5sdJhuV5a`Y zV@HOf$6m|vGB!!4u<5=l^;t?|$#I_~e&f#)tmpW&HYEpUexs={UA5^qOOxoXez|Up88tMk*BD!gH20 zwZyDIAgl^w&UupTDO44s;5bFwGe=eNJMotUblN^)_hmJM(`MK%aro4Zm8bVi7pGA5 zT5*PXGLL0trlgx}07N-)E3A6xJ*#1pEvHIf!%iGYC+g7&8$qX_UvTNL^2gVp=OK8U zGn_Y~l7-W=ESa)Kd)Zhi7AoghmrYKkD?uxnJ-!Nr;)~AfK7sn@lw>DH(6b_I78u1I z^@>pJR`jOn0F&7v%*`}T!xWNolHK;}!fR7xqA-#yxtCl?{XEGUKJxJ|@XFWy7*{;w zCA{#PU&C+z-n*D+6=der&mcjWPXqMR;HpK?xFU<#DlF(;eE@L?TIJ>J#cwsgmkM-^ z4)LNb#4J5|zo_uj(oc^-9_xLl_r0zoPNP;g*Ep)Lr~3{ZQM$Nfb&&VK_=g&k9-zBkDy=nO1XQj$%;wn&o=HNPO1{=!;TrRsf1ps(g$} zfuNEjX~U6%$EJc&GEuh3({`S~pM3mFeEqINNJN~LRY`+PZReH+E?0GW+vX~j=C38Y zkqMQNWO#~|c)?XG89EK_<=As4aC#_4eRg=6POn7dqQl7%lKM=QgmN;<5h_QeB|y-S z@53w^$4@Z*qy0ta_%QK$Jc_(V$H)YWV9|9$?N8z8vzloT- z5~>wJl*MaC_Dohqh7S3HY-gr{5p|tAc==mBCLy-1G+c0=;{`7q@&n(02CseXHoo=c zySenK3!HFV79L~f-xc&h(_GP9M^Z4EW3IEyGn||-jXHdR^kmKBJ$MBJuPO$WqT zpc9RNoOcBE&PbBJL$1lOW+r|*tyWtl@Fe3g*-0g!1gki3WZ|Y?I6ZS1me9DQN131= z4^GFd9_xhlKzpE`pa2tObHP`#pGtZ#4KPeL(2Kr?(2JjvPz&iocUX-`w#i1ffu_)y zmMpn?*>qDaGZ-wX^4F@MU!&JPa*`bd`SIG?CcpLj|BIXNdxXoM_bkr6=&5}9hMPFn z9l*I>bBfv82*XCWR?_=`5Jq_UM{t26rvsAgrPqwn9N7#BhaTef&Z}Q^m7WK@-eviN zl|Zfeb0fZs4GXgzCm=mP`$-q^{a3t*7hiZL-~Oa?`GG5+&JR833Vz~wSMY;ZJ%!i5 z=;^%t85eNTaa&n-5ho40oSDjQ2m=Pbnd6hC0R1SDXX?6QWiX^G^IAevv!rb{Cw^-A zUgI-TDzyhMgLZwR2V(wl{gpcME?DYpX6_E_>cnc zS!`(IUIiRPXnV(AC5&Hr#>u?$X(zBFtkccbm`r7UT54t~G|O0ho^avr#D9I!*}Qyv zOWC@q%6swX%(1Cu=lXaeRra*8laZElKR*}nR*${0sOs4iEF1xeMG3H3^iv-2-~R2Z+3N*aHDDpSyl+1P&u3o$jtCw*J_29_kmV8EPMT{Frc`c>SFmxifl{%z+UlB2Hbl4Q zw4FP6<+c+!q7~RsHtMw+=w)xM6AxiS$-Bd59w)nS3e1?1Xi2h#KK4Y((TSUx7>l~E zi$0WuX4Qnji4}Qk%ev;5(Q{8P9Y>8w(q)uR+H#)}znOt_(@6g%$z;hFHO|cD_EGRJ zd`JYG^8{aG<|V7(cE+K@hq>pGHO@VGH=ELh#AmZ^dB?~8g5Uh$yZE1<`3L???QcH& zPyFv|Kg@@(|2Ut!`}5p#`{^*SdSMD zCR>=v$D=ti#!QJ-c@-a|hnz@iNcfV>TiHQy2vvEvN1{nT@nbEx;@9 zSsqs0<`Zu|??k@k4CG9QRPlK#Yw!ZmN%5w6LaksxP0}{Rxr>|p+;fiOVyy&bu9lJZ z^itf06uCM{a`K{-4(WETs4jNOsy`Yn49}5VO*F7zIMFdLZAfuhX4rR$a+nN0mf#{moOm+zcfzfgb9R!qmwuv}>{#86 z;+*9@sxqT@Vjh-1%1w#3U5(f@u)>a!#Sb4pv!hlfMIAIog&vwy% z;W=kXHZ7rL&w>k+3`MTsszM&j6d-lNB%gwAD)*U@wyQ!@;k?5v)6|dkPchKDOxoJB za!JjOOTD&DDz;zP5W|$Dz*$6@tH>N7c(UZl3I6=6%$GxQEj^E+e)b1VxY zeQa^g>v_OeH8?N&(Df1Lob0lqEZI#wn}m}nAj*^35en`oN!GH{;b1$?4>0pM;oFIB z)|{?HY>fNwf0(^DUyUy5qAMQ2>fkVc_wc89%U%DIH(m2T_|dC>gCG3DTY1%g{2VX+ z@K5sW_x=#C{Kp^WyZ`aO@|J)9eSYs_f6d=~{(bz%onMwO-_L=`AvV*d^Z^6!Xhhrx za1zkX5?;og#cQt?@nThW(5U%ZLEO|pil!3JreM0UvC6EQQ2v^2RxmC9*lbg)>mXbP zbwf9u(aQIu>}7K@Rwdn0%O_29XYUznwX->!Qp-NPNM|L_fda4)k^g!s=_$$YlkB#t z1I?hyT~tM;zK{3EJ>HI`-{C`&FG+9#P@La+u_Y#YDOo;3ag>1^)TJPQjVx5KW8@gB zLhy2)q}h5o`Rxxp%0sGl4)--*zvp4Tq-D^j?>@u_uDOfP-0~n_yZa$-d~iRHtWVL- zga91Ui{|2R3yp6`a;_|)vkce7SX3}+7WNS8mANsR4@_OlN51}fZl3IAv255^1AM6I z&Da!>PYTAjUa*G+dO8`kbF<OHMsP{zVD4UUE6{lvSOT=S62xL`*n)3-o;A+&$B57mA%t-t~hClUwrv9IIgOgbY8TP?qhlWUpN%(hw_>-{}J1w%A zcxcLMRD7D#T(Vi2IZk|)>b>mUmw=OH1D$v;6=_p7;wu!cL{Hp_(cnGhotGaWUNtl8 zvqy#36qEi-lc8eNNEo%O@f}%(lL_0bAbr~%_kz45rbMztY?`8Uu`nD_f^Rlsb0)i$lu@|pL_&w2XPts~)WWRbT3#$F5if|B zCg{0*(|IqS>0}R4h%_4U_H5~U7M8#T=>RoxH*2GU3Szuts^w;M5qT)(U)N1# z)D{{k(BTZ$HYT*$aFxfLqo4J3{S2i*w(Qu>P|j3^Osl>(?A^Ug)7L%3>eMsvV8UT4 z8TV{){Lssu#x^;`ObH?5>0_qImZTTw9g=2lEXo<$7LeieMP7(~M*dV@D**IlWmHF) z-#hW^%h(J;2=rUPYmAK9dAxKmRNq16_`=3*eDj&7^XspCA!qBuWiRY{=QL-BGl!Y+ z7(aP_j>2T5eY;C9JBf79QKrK7v?nEg=I5{H<~uvW(37Q$Bwgh=lsp7EsP!^N45Jlh?{6y!OC?)mxRDM}DFv5v!`HxuLa{GO7`YDg`S|A)q z?kzA)y<&!%>~$$;}gSHiwD z&7^yi?{oyw3F_l@_RdLH(z9ZVrg%a>h34ZPpsge(?G*DS#w%U-bjc~;1{?!S8A`q; zAfk+HCIK4(QqhaC$wWdcFjoTVNoSwM>D!i>OpkDEbq%7&3j}pzVR6KQO7AUODvqgi z*w-a)di02b^n-lr>aTKaV-2tQk$GfQJ|)OGRmZ^(WN!&m&RidF@}5tAi~}r+hoOul z3-Bl&9VX*N0cqEAEq*7+?Wp@i)H2${e6KA56Kz+tm7|n3Bk8$wQ!Sw~OO|;4`LZsr2p*rFHBqyX)Fpcs6S&3Q+bveq`n?4M3S*L~TO?Dw%Ucq4nZ zy@h7*Qu*Z5>8nd{qsv%c{!Vu9_<8F3&4@K>SJBIfPpc~a`SypndKdFuIiG%kxyrWP(o6iM!xtnypWdI>-KEmv~Vl4q=yPp5h4S8>ka-ALOlhUvPN z4BmU7+#Bbdy&affN%ZQ9sbu>3H-C;VfAI*;?;s2yG@G)igb#_~V8lR`?Df}e@SQLF zG-q74#M555#7F-1ApiKDYsh|xZ+b#7&Fd)vQ4{Oj;lytvyP5PO{^@;t`JPw*2j@Sf zV!1-ch?9<6pt3BP9kaNp@kTK5SsRV`d;W!I^0e)zGf_|r!K0q)3q?i|$4REXk8|CZ zXJ(GF9g{UyzD}``Vk@2QlkC~7q7lDYz-yt0_dT^bDlUniIOkca)U{#Kp>uwIUFeu* z%VwaTR(6MundUxWw7~A|E0msd9@V)DflB-q2At^B!db)gEND)Tnax|R3b1^{-wn10KmHbe@ZWxoU-`hFslxs^U)*~;`{O#cG+>}@jyn3e&E}X@WrLINj)cR~aDX!> z`76q{M)@b;S^O<6E-=3nF66ybAJ;H(V13@FI~k)Ty24hXa|Fz<{Yp@*Si=xb^T zRKT2Qd6?SB$vbMUI)6J)KYKUFZ*>fs#CY7Yst;sq>r-9NB%XjY&sJr-fgHJ$LAq+F zYOxX%h3ihdlsOkL23QHE^F9p^Jo+H-y6=NLY2jqO1E=vpL24jncwH9)lX0ZCp)RLH zIie30&RV>8L>XBg)<|nOT}v2z&>V8ctm^k%#gT7b@f=q2s6Bk~z?8cmK8jgSQA~FF zT{MD`q?{<6It7$kh8$Jp?mgO3C(ls(rRu?uSFv^JHEd3_yqkzdpJhY_GKle}ydz%B zj)fn=x~I~$cM{v0CWybrxbcpMng2)oWgI2wn&vKs^prEU@++@<9%t;Xd2lvnq>r=< z3qd+_6oWMJ<%;phDGHC3JnExZx4qz zrQ7J(wG`O*i1Ct_eUNwl`OSRk>+2j|ThMZJ3rCI`dmn)hyni3R^p+2E(j`mC)HrZt zmGiII!rT7*>-_CM-oxg2nX<*TG+bafY}5h^WyPY(^txM)^5$Q>mY;pY*SP4)U6{e4 zBOO=1Xe$?AcpM(^5;74n$zTG=NpGF-+`ePTn_uuuZW})!T^d0cIA=K7QX(K&ae9`7 zajyj5D=;NZ5ai6xdrD>2)HQ>;Qg8LAC&~VrO7Cze_UK8*f|?gy7pSD0sy3P`;6y9R z{>o}Jw&GB>;>}UZUn<#I`5=DIO2b0ah_@jnx%8=3paTqfr8@aCps~Lr0g7h_5llCLrTn()A3JotTxLH3*~o_a5ZNtFKn|t{^UhDYCd7 zz5J?!sbYj|$F*K0vbvk-qg6+biKY*R8FQY9iPDl*7v+B4%F-?%VM75}T^tvqg}v z4P@I(!y!XqRfoXhV8Ebi80dQ7jbR-SYjQ{7Ed6dw)`i`eC?!`_h1biGVx1LsR))^`4E6q}4z+ zr-0YV0VfK6MplLGlOyPlJVeh;l_!W0Sq7727%NTYse48|sK+HhZ6k5hokvw^)l@zp zyV1~Ad)s4Xk~hlu^SB}^C{Fs0yY4&8P>OH0j6~Jtw&iaV*Cm-~Rt5Rsr0Ham#!&)e z6uwp1&i0iTD^zT!Fyx!aS#riZ_D|TD+PvCw3WBnDjgdo2QD^T7{M0L+%6FZ68V{)g zcu@11m%L6kBb{V%7}z8zd335MQn4^O59RDLrV7V7k9jW%vt$zP+Cz7fnGDY{@dNG} zuX1GkC_jJMY5dmry^zb#-idEF>6FNlw9RK=}y?p6wN4ewHDK~umC?9|@hT=XSbZvH^06owuDxWdOTaU)A*RPp@3MG> z!e@*Lxs>Qc`j{0TBCQ^DUC)W5-8_5gBF<^f<=R7c@e`l_W4`Twzm1>!*t_}I-8XQM zF%pHvf}1`J2kE>CfyKd)M!b*wfHX@i=zgi}B@Y?~RbV9DKvwan6~$~uuO!x~O||VY zQ)`;)G5(}LC)zG}*@Re4bl4VsfS+&?*sj+Ix4Lvb4Z!UvN7A<6EE1%TL}Z{x>+fp$b5id~^d`y{NG@wRtKF z^A_;BHzg6M`TKvrle_OQET?jnR1qY}NKHlkFnbl0wj-lb6GK=S9FtU=y{fSLftE+M03%a&oG`B2o6&D@Ye#Vve)nr1RwRTyC$qW(w%LtDaL&SYR?f zCVDNwDh8TUv4%3vdp|#yrTRqMZ80T!`J=jc-}JS#dq?yL^_wg!`kOsLM*5X z-MTo?63Q4g9zidmx;c}Du`C5RIhfQg1!lw?O;ZzUW1^219hnV*ngmd<;GShUkxt5+ zh~|`CTN7uWRB_?yHJ6;Wz$>0{xhm}ARGsxmJsNGIS(8*XnyYp1p!VFl?_u6`@5ec@ z*$&S1V+shJ39;Tw|I>{?l7SzZZE)*7hgnsC?~zD#jd;H7oD=xrZ+|jxdCir)_R{0I@VG!VoO1o-0M~5n=jQepkM>jc zs**jRz*F{<@0+f3&uo)hCL4V1$YJhZn{dLykng(q1m5-XXYnhqypr$IcE*!V*vXO~ z;MICneyys#B-x8ScA_bOFlQv;n{zKGko+>pq8ovt*Yh)(HU7sRAB6L!`RPw&`o)G!#HpEh|8Wg;?nc? zaPbM-x#W^vJn5NxxZvCxeG(B(!%6oRChV8fzU;N9>f2T=_&ZD$pi!5HWpgZFXk?QPlf2Fxx}h9VV^d0$*6Z3#y4CxvXXB0OW5G=N z^v>hu=*702^O*W11t1A4EHnl~#vn+Y_|~=PWN&(u>Ak~(v2S{WmtJxjC+=Qhpvo_) zTbWmHDG6Y6JOPc<55-rjP*JO2FPklVR&_1RBS|_6q1HJ|R>%6}2(9#Kn4U$-QLdeH zL;C)umG) zkC-oXv+j*@^r9DqMb72U#5+V`2BvHEaVGwH=JHz7vnV>9c+KpnxAD}ai}>(?`}wY~ z{3}2G`H%6J*WJn&_U>oR1#B>)iqJ^rf#RDZ5yWRGI*>nBzNQW}l?@40)UIZEaar-M z77wjfTVv4JUcr9+4SW$lodUD33SS-Ndxi4OoO>SUQ4n~XT|pJUgf%M@D{?RzR%lyK zMhSK3b@r5MD``t&C?zisN0hVpNk|dVEbqR`5?Uc=ROAVO}(g1qkSi zwGa8L_il3M;fhL5mL-;jfO%22j5CJ_%MnbfGRm#VxMa3KV5JF^hn=cthi6g7bkE_) ze|+{~4y+DEbAZ_UIu7?i8M?U^A4{MKeB+$yXLe3ZI{YjE(Ku{31zM|J`8R^g< z3lr3$%7tEvcNSz2BL>xssxV6C?4^dU-twq|f1+1kr(}{q!ET*SORqK=(dk}}P>J?A z$1n2AtIp%6zU^uJ+IL*eJ74w`-tx=~c;l1L<$D(S=95q2RcD>d z&#Fp(=Zl}jU%dJve(g0E^TRK>n3p`|6wcbdjF-j9Z$vrZU9Fk30&z>HEvZuZ&YfOH z+Dit#A;~t9^e6X0YNiC~q<2aCrmgh7b!*^x&pn=pZ`z=e$4C`OOtLy>>8^ZKo9P9o`EMHE*737?|(XdPFS|H9nCgSENtHf{~XwM%9p!Dp#+&sA&yH z5`QI7BVL_In(i_6SCwn>`QV)}b7akxE6+^t#z5y;JXdAI+K&g-`?}_5W%;BtJG+4wbSyzfVgD}GDPbip+zgjrgRnSUfNTnDO z)Cx;w6*AEC1^Ig&D!elgeV)MKoG; zOa=j%(~k#1X9DTb)uX)swlBy@7ueJn*s{z(NSP&^PYj0wN%T7@Hnrlb%Y5LvgKV@x z;K>;!i|j;+A(OS_fka6hU7X=8O5teUUTvmiF$3}4OP~@oqWX+v#Gkuy#{YWvOJ;0^(v{IrwwWKG7Vm#XSqq3@#PHQ58wg(o=2o$We%+?d~8X!z*%-MnwlN&L;O zE&TP$F8*lo1m3ZH9Pin-n~yK=;rFZUJWWAaF_?!sW27J+#7`_yCP^RBSU9)gX_qaM zZA#ma6Js4a+( ztYF^bdpI`jnN=M>{PHU~Ra*+VOPC8FyYW>cyK?t<;8#ODr#~kfLYYuW7C|q7qh?uNIwV5os07llU(D-*?|bl%oXWEWIaXZ#U>= ziW;}l zoPfw`y?FL!6x~#H5KU8GkC!o-L$+x154qY@)U(8fd}u1WI-^?RG{2S4uN~t1zxqDj z@`aD{fxGYFptg~$X@oDh48HOtDWD{U+UvO+P?AP3xf|JWqb-lhIhx8b)pmxjEBSUH z1jlT1N)XcnCmOAY%y>eFdqTa{*?e!pU00q~N=pKEFsvnrqX~w0PJ37_`X!}yf(~j4 z)YyQcz}3xFdS&66_7!_6Pk!;3@>CJ^$&vJR-BE+?y>qHyCRAFsdIc-pNwK+*!8sYH zz%zqfoJ{9K>#N+db{CcUnyY2tp7r$^RXq}519~Z?Epw_`iMi*3!GO2jwZXsL*olW_ zLZ0EXC#MNX+XYDm;mlDisS%F@LN&y^&3Q6mG}T9+!gIkk znIoI0RzPssFf)SYE_`4TOZQF<$>wzq!o#_YbA~e~nH}e9dHjkWIEB0SCVXSWEM3c= zqqI_sM%37GyU8EzIFa}6K98SJKz@;Q`DE`oM^*7@8TQa~oJB53@B%;JM;5p8&TYr> zs6e-dqFzx=(V)QBfBvL z*PeCROq4y$+8MoMs+%F5&a2}M#;T|{#+yiGI2Q<2&BADfnSzu%<&lbh6T=A3P(^7d!`SN{BpU*MvJ(-`Mtcwga#eZhOa8K?jN5CBO;K~&C2 zB+K+_rmc>o<~7m0nHM%5)1QL5iNW3+(N&lWNC5eYC`d26lprr$Jf1tX7yst3ew1JQ z%%}PHd+%qTD(X}@N`aJBBzTuZ4Ju{qk$Cn7Gkxo*29x3R!mUq6F*FUs%rMu_TNF>SA`Ix2Fp{pK6ih_f_$Fe$MmR4kXe7!%kDg2u zCsq~vkF2w)kDE)r15M<7Jj{WuIrkC0Z+DrYJ%Q1x<-1;InMbi{c<+E#w5)dHFs&wOmVrV z>moDp8a0oZ>At7*Gd~~-?B#_S-}cHAnBKRFuaX=oA!@?E5?`5}swn?%_sM+!(jupk zgfL7*OFwc}qsT*55d~r^zKX0tD~N4cbv(Ux{Os=2Im-{3NC&m_RhGg~@%-nWK&U1v z3N)X2C}ZVpQXUAOl4J^>=qU1(>sjoH>emXd*?5(pEsp1&zmvEBz>9g?6;I=^0@_XM z$C&0UJ~NecTB|vYD?zwd@sX7*v*wRt*fej}oF@Aki>l%xGplrNIkqw8@Ude^t+*0VKsxhy1&pH#%q6%v?`f)v zT2-T&r`P<>>6}DIzBp_uOjsuA6xV>Tt5oeeoFFp1^z4`SN%C3-5Z>@9<;K z_(6W?nQ!3NUjE-XVQ>O{+N9Qe_!=1-!4jpou;fIok$qMaVM}p>=J&bQvml-w#cqT5 z)J=s63p1gjmdPZwk?i=K?WgfbyT)RkyfUPz zNyietVl-G_rmB0bUuQ!f#d?jCT4c0=pnlGI1*FLRM;@gdtpdP?J{}GHNbg4|Hsht# z(yxG=)qmPH6a}NefA(O&Yd?7}pSmlPZ7ax85F=i5I<+LEy=eM0fV>V`;@$M%6ahi- z4pZW2HAyc0WD7F|_KCJnX1&)c!-%5ge4xBn7n0=b5t0I6jsZan?>znOjw z^lV74fMw{mTNCD=lcE<1m7`MTFLPJ*fJGU=BS$wlx-On%6zWYb{Im&=qx_xDUV2I? zLvXY*AQJ|?0QTZ*qM*G|lqjQnOeZED5{TrBgzklC-^a=~H*{>~C3S-y;>Z^OO#C zf+I?jzjEBkJX^te=v%tbgKWuH9+R^MVPWFO%ruA7Gs8TfXEUp1M-}Vimc7zX@J_Z> zbJx|8pM2|?oPX{DNwRuhYk(1>P8eP?c=dIXF~*(}z|di4Vyh!5cp_}P>`0Hu8>^hK z(DJ>{KZC#j-e>WvPd|&1cU-r+$|F-_oJOME=%Z9-vG{`~Dp<-a@a728QId|bcq#a_ z!oD-FFCaDM482w%VBDp7+I*Csy!=$&{K}_s@^J%>O^#w!Rx%S%k0>)h?!GN=x{qaW>tqNvD8*ERYhL-U+<6d)#+_qvUobbcmB`t>6iX3 z?|SW@@|JJ;DPDQWi@Ef~C$npC9C=#9%yZ_h)41fsOE{VyL~I~PaMDMRe1e9l>uH7M z;r<{~X2QUcn}iF(%aC^TUaN#&zL{0<{HOg5nt>{97!mNYiFVqH?ug?DE9_Mf@h7+4 z%ddaot9P^jfx4>jY6!=T%?-xd0$yET!v~kD z;Yfh-xDWTZd;VufSyJD3GO$RNMGcj8WA=}4Q>O3+Ezv8s+oHXr5D@evMg9-+0C>}O;* zAq3d_=t16h?PoYiOQ9%UdhKm@GCsrM1%9V{Z(hKH6YWM7mDLq9mZ*j`J0-`@efj|J zzdh6WMJg$=RrbxUrdBnRnl3ziY{1B;sL}QZrG|8=U5g3dEHRv63-WK2VltS$vNblsj!tBr_t+ z`|7u*%~ZlX$_PykY2MLEk3~1md(1ppxH{4DY8r}}&6^i5=xtl7b~!|`5vLAb!AZ1Qz3-*#-Va#oWSeeeZfuY6_3=>-^;1=T9gDSNR5^lW8X3q)_e+B?FSsi4C@v&N$F1#A zZeHKZil6agPd$sbzWPdD`@9p`Ihrt;B@zoX;$x;nMqPe3fz<200?bqixvVT3 z`RjtPtd)q1t|cs4awJSiyjNgR<)!>?5qBTcf9tu`H2_)5YIVN`{u(F7`lOy^o%4v zO+M!(e6J*Y)5EuN!$a4zyE>bwxML}k&C^SPCY$Q4M_$WwcZ*JS5>o|6C*JKFg-l(0 ze9@&?D7-mmVl~l<)}~@kC!UjndD8nvE6d(Tlvgz0R_@s7Inp`$rjZ;bdyq_3J-?2^ zRD>vg3egklTdAaN=h= zooT5)BgsH}C5Yj0NY&It8C%)!RZ&G&MKzS8&9r~~s!u$~UtKrjXdKZ#v`T$hR=&Gv53G*yj6l=ssFU&jf*GqpL z6OHW)VoGW=1>IIbJt_#y`PF=yT6)X0vsPm}8E~)rPSld#8bJn|MYsgGqK8iOW?BhN zr{lRZVutsg%7!#bmcEgU!s4o{PUgFQWH&e5yM`OVN(x*fvRAJiC2HkR>?D}_N|>l% zhItBhh2DHF%~|;DNF)UdX@CzNUE^=Yp0gzH;vjM7*AMf@zj+xKowdZIY=z0GyjM_j ze$IE4-5~nRk2L>nW;$nK)YHi$42M(omK7A8Qy=kO5x@q_rkO?o{p2kTFMi7D{MT1p z!JmEa^ZC1%KZBpS_)N~;w#@bI247p<&)1Lb=bF_=xK`UD*KIt)*N@!CP3uRvb-KYg zjoGv8`0|;$PUhB0b4h$ACaNB% z;zfcn9E=!M1GPqW*>Ao_ET#d3f#iIW{xVp3V}zbSW`#8t>&am4az6oAq**!dH35TrL@zzI&AW)&3CY z%kN)y!Nu%SRUJaDSxvHM);2bZWs&ASn+RsOoaZ*(_dQWb%`{gkfC65!`?9B`>&N?} z;g7c~=o^v6-eJl(SQ2x+i~vmF7sZyUcOs&TA66uaGMNkI|i)I#yD~8b!X=1 zfcb)$oW00Uo@Go5j_U331d{U{nXNHaVD4g6#XDo7;PvR*13Yr*ZoGn!caHh?NQNwc zsUnU1qd0GAgSx?Ki4|0BP4!jPsnT2tOR8L)!g+^Rh2UfuLrYw|b1Oe~?W6p+&+O%s z_Zzn#G#=8j^q$qse>~9g>z_KzAKgFY)Pd$I)v>lc#x~>VOdn4sHRpCu<`v^vK!J=SqAud zj;#MN-W|cqp^_@WZYt;K!Ab5=LF$a5BJ_O^2q&!$oB~Qy)wq(NVzHVcJNtUdjfj9|DaOJZ6gAI{JAW9Z5N+u^Yy2kO|@hWdzTjN|;!O*z= zzD>UMyHDqZFWe~}##CV&rQ(t#njf6=YECwl3!?;KRfj-u=+Jejdm;b;5CBO;K~ytQ zm+9-?OH{-2SPQ~3xPY8RdUGVJXeC$fjU_!lW5*)Tz4$nO>{~A3w_kS!ANZc<^UvS) zBL4Cf&*6_=d=+nh;g$T>bDz!cJn#9u&$;+`PTjdh z^c_>#!=&%Q*L3X!?|VXBi4!P76fUCzNGXmc(*IbMeXD}WO9)pv4rzaSQy<5o{5AKH zsjgk{j3-l~{LLh9at!^Hc`{i8bJAU)6-|?!?mM)P4}a=9PJGgFw9>EdhWM2k zeLZ6`6MwSrVI7F_l~V>Ma@>yNiQ+q^4NTY&Y7VxC`1%9a@-NqZfM5B*@ADmh`9@y! z&)?5~yYBZm7>}tK+)fF4&O0P9m2(Ui7MNBuE?s#Bm!5hh%eI|UR!-(;U+}}ceAi33 zck=+X57JM>c_XTT)gqrHZRrWUWOl-*(tT|~eH+oO^}MBTGdC&my!MidIZ=gZuei~6 zGh&kMS!88lg<)uL!p>`KrKH!W01VD#%WA2?JU9jFPPFJ|8IFqg$Ny6#ecZiMEt`m) zX?}{ZmGfkYK2?U2oj8)04OE}ZmT4$u($V^{KivfA3 z{vvGRs5Ekqmvr7W^a7w!kZs&RU_g?=w8lKKBo1mf-bCw{K;LO=YRSbmwtF3uY2DbuX_KO z7k=mg-gU6$NjqRH6;E9y!#TjN$xdETzm_*`eJihDely>`{CZyGU&{;Jcks>ryZH8n zH}ZYUKhJkB{Wizzx3IqXL5^&^mCAn| z-w%iaIcrJhm1uf7W~Z<6sqdJ{0Z!hs!maln5YC=Pj!=~-B?oJQXY2A3i-Q4;^O!R_ zZT)QOi~U5#*U4e0TJn~C==I4a>)QWaRa+D7UO_gSQB@UYMsZSKd77riiH?~ucNX>Q zl^7aT<3la!dhrxBw{uxKmR`h5N*!Lhb!u5rysn0#Gb98@Qg-$_n;gjDapov=ySC@z z3%B#P|M)y^|JrRdi;+d4b2KmT<_GWLBWtV7TqS%W=37dj$g;?kJIqA|)J$W>s@l7b zPWkTDP0sZr^pI8`RM_A0b8mh!+qOBnSeKDFZEJZKkVx}}jv`C3KQ94Fa}pvbaK*kS z%9eZy0HIb-k3^HqvN>HiVeh=@IhcZ5(z3tT^G-UPjb{w>YWv7@*~trBb@o{?+UiQM1c*!@N!c)%M$|*aB>{%F5OP1+)N;~c7ggH9QjKb4MdMUow zXkIj5c_#B$py`BB#jqX}2PTOBvGhNa?pyIRF56K$2l;Ifjd{?OK6=@B7J(>V@eUnX z%~4gNL!&uI(%~{NVBh{@T>s^-vukCMWSJxYt8E!;z|dgbC+RuJcJJN1ha2v>9%oa` zDtenyDM&teU>}$M^{aW!zx^b?d&^((NbI<5=^~!Ibb;{m3g{6pMmqUH39LzWW?g2E zrxHM|hIHRaC&w+H#Ls-o&+w#eXX)G6Q7Y-DmY*a1iw~78P-2!0j;ZD;_CFP-GwnOC zua6m|z%6l|A3gDWo^jGCBw;kAAObn}<<~rzdjdhWfcQGPwobt`OHOo{d3xDMQ&pP3 zLO2@J|33z37svyZrXd(6#T7<+eSDH<;6hF1Di*_tom;mRH-l^xUOO4kp$##wO0LJz zsoLrFu2BecQ#&&Vlc!^`1k%wE8*$tR! z{wHsNr!K&8lIww@9D)7hg)LkxO0AYewO{5~Jje?Mui#B9zs}3u>p3xB%tG9SACKs# zkYtSXHGaCt_SLg_W_mH-zx6FVv-&>PHt%C5LyB%E(eB~cu^;8&zMtUGf&Yu6N8Zl9 zgRfH{|9;F4Lcc^e6Tt|{QuIf z@s`#5__?(soWqD*O^H77zzyrX?~~8w+_MHm>D><#<%70Y)gy5za+(=la+L(t3WxDz zgSOa$5}TPZ7%pHwNRPcL&yH3BGb#W?VOMxh+Ab-<9g@T{at5;DU0`8x2`tb@Q-G)$ zi-+||)JMC>rjkdL?fPj?uRxwv02NCg*Qh)O?1r+)JGlZ)xVVu zlzA)hY2t0z3~=V9|AF3Te9%bI3i79^0^i#DhPcg&U6Fnsh$4fg5=FzZqplVRVF>td zv>=%(?!8BLswY-jh6(FbTV)%SqtV>Y7Xzc>`Ocf^HEyn!$+>>Q#XEs*tAOF zdN+YHwx7dWU-Tx1!hEJNp~zWzhQk4MT~q9@t{SR(fC)p@fzpwco~rtFwoP7r(HU$P zrb#gi@lbT@yqB(9_02dZ`6b_2wzj&lMoMY^Jj;iAZ7CJ3vt$?!E7?n(#cmNpb3DEu z_xQR!)@x8>r7AKak`={COn|Rzxw)aeoK^~6l(0&^0=y@SS<}!~ihgwMFi4Q35#_^H znYgOL94J?aiKYfnoFtfmfuLm_tqh*|yMXi?ggJwYnWo&A%xsWgm zIH{;t_2tZ~(m?xnp2e~8SL0K7`|NanHl4^1#@)O!@8XBrVj7QKRBWa`DpnxA|4dZdotjlzw z(<&i)$(U84R&uU^46NYPD%fwyBW}6(5Yx_tNx}=O=#7agr9NlrB#M_Ao8t{SJ&UQw zoY7Pjb*OOW2x?U^I`!`qOwD`If+QpFRB@CbBo)rbp*}e$nx-a5moru3NjgVIrDu3k zzdWDQ$g=NP9^sv*RL5mGokgRMog89{xQew8r;;=W+brJ1^(@ zFW=4)1<2DzfeU3HZ&};qxd-?0hKC>Jmk&(%LsiM|IyU83j~wHB4nE3j4vqPX*)kVt z>Di2W#%ojV`pQ23;r*9$<>f>2I3dR9C0hxu=6aHFaSpurpJ-Jv(O!9xxmIvX;%hdW zaBOu|ymrFO(|;of24Ps&4Q3{2GL`PV7k-)oO!VAiE$EqqooSUK@hNsK*!3<8gDIW1 z^D61YIoY-C;C1F@Qz;koocRieGuM| ze#Y|C66~Yo?R!y8S-c{dGiFY9*DzBOD!LzQ|5SyEhKYJ3w& zO3yA^wG_A@h6&AXgcYu#yL+Tx;J*m_|1#1 z;_Rg%GYM4H<(;O^;zM|rq9ux_EZ#~{b9jAC^>v=h=tZLv{=thbo=(ZOU{y&aJi$Za zJ;4!{%`77ups747%ZmgBiK4fnj4C+#+%nTXahGOfivqA^eY$}U4NivUE#dXNsf^%s zt%jcGoGAaiTPHDPEY({Gsz6=zI8CrLTwUhrcLLwRc8(MHBL&fcjAlUtYdPBvU0;<){ER!5-x}}afU23g4-6IP zYA2quqw6G>3_1qmXkO*%{+oH`(#u$!?vhd<+7yLCoC2~p$3i`#R-(w_$H_3Q?ucZ4 zv^z#MUF3@9#k_v;XXzD8V!uvS1sDYC$WAA>XVEKwrl>L?o9G&qur_nd4+H0gqzom> zcqZE7kRPzK8gSG7`&rd$psI%yL-FPjaZGeZ+9PvTXc(;$p!1#*aAykqor2_40b{0l zrC_|kEaT_85RF!tMNBf-oQ1CIBn^53so+wYf^*hW=Yr^_az$O$l*6fKqvTzQ$HGD? zl*}G`FWwgPF?djo7%JfguQ7U80#HeCiI&D%Uh~~2@h=~H1!wQ6x$cV(DG~KtzB1sn z+H<>t^}9CV%?j9WI&y$NP$1hUT|H&d@zfDKqQrLNmmgy3v?bp6AJ5`NSM5Nm2PeHr z$Cejyf~wC-nzBy;MyZ%P(TNHu&U?I8Q=zW#b)XmSQ&rU^F`60PIdsB`)VeMhBn1&K zS!(&mNELsQzLNADW2+!sg1g2^kHLAI27B*|suZM?D(ZEnNz37CMlWA7(JYRRuqufp z%chd>_QKl9K7+te7fjb?O_{ zoaS0tT4qpHqLGjZt7Xdgs_3&2m;SPvlaG~T_0?-`=lF|`lU=n~a^xm43p0|USR#ND zuPM3p=IBJwi-)cLB>u+-f0G9f-Zc-3^MALcg!Ia%y@cO+`cL!q^;^W7X%>yQ3WrA` z&s!G%#ZD^e9^Zj^&RaU0|Lfzw%k775gF%lkHmlY13l(VJb;Vn`=IEU)`{g;EsMWh5 zWT>q_CnN7<_f8MI=su*air4Pm&Wp}G6&G!uNMxa(c2`GW+GDZe9FPdf!(;tb%@yjQ2`t~z9YxjAa zzVjT0EYPmEq}H)(+sO>-B}`viV@j-T$U8+w_-Y-f^qSwkLYYDu;~P-w)>t3iSX05CBO;K~x0V zevPxb3wZYMTiB-HY@wyd612t8TMZU8rboi*8balvb`sB#6-9#^QXH~g4P4Ybiyu1f zSJ^$;rY#yNQ`LHF6B<>Yro`0h z%cij&udzXNob%$;5LpE*$4qxFP5GwFck+%uy^`Phi>I-5VZzm4dQ?^Qgs{eW}s_`3t@=V_S_NVZir|o7U6e%kV=|0CA=cdJ-m@3&# zr<+RjQzhjo!(k(RRJ2N7`5Us-bG&7tbB91St{|2=@eeb7L^THmizFlK+DEytUD0{4f=BtL(egt_an+bR&`hf!dK*7O#PZGA65h}fN@X5A&r@UCWlGMa_@+Q;(%g_KB>*CMw9u4auTYY%Z(t=~a<$ zbt`;n^>*I&;Xh#iXa`GD6y=ll5Oi++S_XSYzz0!eyt#oh~F!oYB(;uE6j zLe!j*oq5DVvje>SBY(oN^+$14t>7A|de5t$@p4{&)~mUH^%e$hK;>!`a1B{lIT@K( z>x!<#!+ynySD$wlyUH{9)j)o4veicRE_Df~M3muAy9tNaj!;%7NqjlyaKhGmN2R$b zIH@^d6)Yo-qkO;@g91TZrsqtx!mR5+_Zk-}L3SU{)0t>-NxDR0|oJKKlb*j4Z0 zq{TBhdF$EiSlq>lJI-L+;z>9L8XGa!G8_ywUXvsQr1m%3}gB%ZRclZjbPt?Bx5V3*0Y0ecAt$apW*nf8#*ApBF5?jN{{}#AyfK zw7t<2x?1xZW{JuI!5Ok6LgVluiANccl&GbEwk{IcAWkYS%9rx0?Qde6ZO76YvKPI} zIG2G;${t@FvCebabuD@xT}S;YJ$i<<&W-R69Y+%EKXgnZOMeqR%`2t0YrR?HkDz@HiQ_^duIX*S*FfB3a`! zu~tD5hYksognKM=1xKkyr?Llg9_K2e_{z!Y+Ut78Oj{bgs?}bJY|ev7n4C~OC^ULi z!5h;xcJGRO&v%@{yZ-t){Ox-$=Va#S5 zOtmGI5(Hm~JN>AWsYz3$93_4duaPJj%6(GjvBqqu!iutg(>z_wbiyT@N$yN?j-Esb zt{Bw9vvT53IMse2tcOI6afK(nFMSH#C>p)=s^Oprn1y%nov_PC(A;+C!f?fx>R2A_V4!V+K4p@?>s5IyHH)0E;}kvLPRr)1wKMT@X7G4Y(`%t~C6zmmfTWhQgbt9mSan|a~p zv_CxKH2)O7XU9)Kw~kGm0(aE-o-BG<4uq0NFJmHRqGUsukWuheA6*NAxqN0Y^Z|5H zj4sMB(GewwsbA_7Q8<+Q;>=D&N>$KkOf7yYwOZF%{S-ko(jYL6x!BxK>G%zGKfG9w+)HJ!IK{cTUe8k0lLsZSuQ3=V>}?DSm6Rg<>n-ffM1zFN#i0 z(wypuiQYsXpfAO#KnkRp=0wVNE8sc5qc?z#M^;O)vewTFTC>@*#mj531z&8{-JagcA z@%d-7V1Xp5(drZP64x-ZqFyQ`(85d6MUiEBG@v#|EQT*zSnPCzn%OP}$>0RG;ARDcGg)cw~6lb2@D<_EF0F-^CEde7FERHz~cpC|ftYM1A z8TB)^EQmW@>*4%e0Zx;$+Xf5@$iXNDX>3TU-Q4S z+sa*ud|<9|w5nDFN4uZ!ZTZN-M3XfxV~Y~)|}Noi5HK4f%SM7L2_ih4BnAtK*dSQ@Lozz3Mz(C`1HTw1!8XS5iXco9!3IpU0b{^`NG2mtKxq__Mk7tq`0FJ$1Twb=3;L6>3%WojY5)@NqeO2NsWDcv=pc?`N&+dAmj(nl5$i zRj#K~Nk8l9+q&-RpreNd5pS{~C==vXT#oLj9Y(`b=#3!RqgizSv8tHMl(cIOv4fIq=!NaBCGITYriFp;%s&fK&;8URA zFkmLRRXKVvS+V#nIV2y;Kva#;N(xw?78m%`8Pc3e&~Va%oeUe`ddOo6=34Pm zHDkL3&bXCAjB@16@UQkR;=`|&#KT3!44;(>HW2sK4+ZWW)v483Vi6HMD!)@__jS_; zZqfBu9ai;Ej_g$qkBVCny@FyFWL4I#-F!y`t=S0`aHw*kp3aYRz4(&n=s&#oNA+v( z_&uHB&xz#|`il>}OLz4TNz7j@R~p&L4s|X>qPg~#r}WAzo}>T%%Gc{HU-~Qhjj#I| z{lu4kzrNY$%!ZubIG!hI!Q=GFpUO@OY zl|cRLbz?CScZ1P!a=BtNQFZQ)6@F`LqHU5v0PvCV6-*I)ldvU<2IdT#wh0j2Gnf=A zN*X0Y3;?evUV8kfRO6(G#Fr458$cXOQNb%mjyvi(=;%@pol+f)`A$Prd2odUc2ua# z&eaC#bNWLJ&IEALqJL7)x0mRtx)IvFhOuZQ@$iKtPp~6QO{DHHkYo@J1VSA%kl+%A zto$=FA+>VFkC#OWGp@KVoN>Qs_Gx{CyL)j)a=$?qzypaQxWfqXNL8c@_kVrs9#*GBVKoT$y8;t7WK7jF>1dMo_ z@tZ`~L?PB@Oo5Lhpw(tlz3 z(VXC~bEnNVJc`KHO3;b6BUh)6ihjzQn-Q5NR6Y%7tOfsA_6sdm`;6^n^(+DujU6>N(IDXRVruV8~F zF-zU3)9Fe5&insNfBewfb;vGd%t`~`m_@ar(+Mi^FSzt6`n;z-O%3$ynXXm<01yC4 zL_t*N*wW>smI6L$ldv6ZA~9IdQRc|*8@UDwydM1;-f09Awvrxxd{4JOw6EJA*w?*} zt##MqqwaomQJy$ofr9|!`2BbO_}@9=|1n?~*Jcne%Ia`Bu$8kq#Spc#wapf>DjZaB zmf}SRY|GXdH(|W0*a}cK_ZY3xVW*=Ay#OJ@fh|b&ug-0Ehh)$&PZuQMnS4+#OOh_EKR=dnSpU&x1+867x<|-Uc$~MXk143oQ3)kzls-xEcaC8y+30qY9 zkp$-`hTR2qx{E?^wQWFdkR;g{rTvldO1)(Jb9G6$RtwI;*27xetWj)Dp_L4vV7Z}@ ziz%Vc08zxh;~`e9I~uVGxBTWPCei{5Pv_yP+G&l575nG(647m~G&@b6hf zjBzn`GK;7Y6p*9(!(usC{o4{F_U!w$s;xp}ay4q4WRVI9yO&4PdK^?rtKgCOQi}|c zqY9U+fN_=E6!}I{Oc{F@#ya@)stL;QoB3j)dB0-UmxRk|LIuCQO=4=N8f!w#*bABK zZlcO}usFvt)jl>EG_c^-1*j)jYhuof%RFHQiE^!)AO{wL40Z|rS=v0Um<-Ssl1YZ# zE!cJHLaWI43HF?}ojPwcK@KzGOVS99$g}!=(zM!Qaqjr;%$t*(&mF9Ifz_)AqY=3S zK39D|V~2!IMy|yKHrnW)@c(L~q(VZ%zj3S#2vu(<2DT?h)P#x5Oa}jqO&)&a1b32E zA8?noN!{_FwXe}8YBL`>Gc=v&f@TO&k!JW-#i+g?%J_Jn*7}5STp!}d;;!}My5i6^ zio{_;{(_)xfyJY!pvwcx$8_VRPuEpPE>~VS?}JR4q#7uASS#dhuT$$2`s)X8)^EP= zkMwQ7^Zok9w|<8XPqvlWiF)tY*R(fZ%E0RW^=WX342toA|cou1Ea?1JTQVDCiB@G;PoLemXLb=ItyBUABoHknDb?V_k=z`84clh5ZKDu_00%m~3JX}< zwmj>l(NrwxYP(J^?p~o2j3eyWKPN3YIeI7$PZwrw_nT!RxLR4ju!6zo?? zb&)Nun_(5Qv^DKB!;OZf$n6O*kV3V+EeaoM)X9mZCT}Gudg4>)$eSfh> zVqR(>XbZNA$PAoHU{Glm_!(mzQ5{}g(`tKbCZ}qGG*!{IoVWU2oFFHD6$m)|#JF~C zRMC>LsS3n>(LhoiNk;XGb_(7o##U`R0TpZ}mxo5?0UyJ5*f`@)YZlT4FD^%tLzOLq zOU6F^kW{Y$z1;(R3_7m1E<)U8WDo8dIpV7cy<$^!ul$#s0r&LR-Oq}+s(NSi208U4 z10iw72_rrL7Y*Q7h?C%s+!0+eyd|>+tW71G0yr#~FXXvBSr1yGy$2sS>ec$QTZ1m06MvD;0dGfKxrTQZPBb zVP;yR6DF0oFU%vIsDN3uV*`!jzN1O~+gU7HgQWWZ7aY^by!A#_x#^N{i5}g3P-o{S z6s8hp)!dhQVtz{ReekdKGw=LO{mVDLK`;5;uh&2Si|^K7J^W5Rf9q+)?Me-LMRyIz zQ2>6oto1dQKSwWl%C#Dix7KK$;L8zH&+e%DU@lpP4rniN?;glc5qW}SQIThpQizL; zMl|BqgwHWH6O#k>PIbpS%Jld6_}^o!C@$1ARiJ{RgL<5($Eop293iNSK_k^rO=3+= zun4)vxa3T98ZdeU94WYgvCAxq>GBX%0H(+>YSP4JJTsI>VV)U@1ytmK;vq^zU?QNj z2{`1TN;-D@5?yoUjXFspsWW3E)$RTrv#6Wmf!4?q0my=>9w`_Gs&bA5Ls|{0M<5BO zCo8D7ngj*7D+q0eq$}f7bomyQZB16gl1HvYU{reMNQaCy;ew3ncM$|`Cdp*A!-S)c z49w`2#f_}q`KU-Dj_8?ut}e}VZFss^H&mT?vEQp_yRH=AnbmmIgla0Ie<4XZPEqd@ zZ?l6nEG}SXj%-P0rT|^%dlOu36O_R@qgU0v>UfB%YXUZHrAcs%$h3xb2vP2~8DEpT z_~6SL5Q<*KN|(Ms(2dBku89Mk1AGcJ253|sn|qvd#q+{=tIw;t_+$;L_YFxdxT^shGwFg4GFRM#OYlHKZ!x{e z{6d4BOkz%h$qhP^2Xru#z#uk(AyxY#cbAzF6KT_G@8-B^z0yDxwX9@xp%RW# zZ)!X$n91ZUpiYvb)xMErhh0|ac0de|G^2>Krcvw8_^fK1XpCpvA)n}Ddk^Tx|M*vQ z)4dYk;S9)F&Wg)4;Anuduox+xY- zDH;!LPj!#&>4n$api8&dk(rPS-y=?%rsk(!^ZDM!Pga|Jmo$(^o;Y<*_dk9{kDooz z*~Oq0Nv>(@{E31O+R~DDB-mwtp%wNU@%1!c79Lfz>S8~BF#})!dhlT&zG3~CtW>}N z)jep$F*6a?vJCQ7bI!i%mZ)UY!D6lXVue!{8IF+wKd}-(@gktkMj#*Oimz%jSNn|bo9t2dew72MXTLMsKOhqR<2!=+f!E3 ze_ubQzb-4a+E%W7+d%^kP>=;NRgfp(aFc}71Q$nV%CXS_&u`9q+8OtCRl}RzFlA%i zqVu591|=Lm5&0(mAiroAFDGM;;7ComHFn})%ZNa7L4T@Qo2WJT;$7tEZCMr19> zhe%REPx%JpCN%I4o8y5VTaL(Ef+>(VdKRzsYOSuDD1t|kWM+y@tAM>>Ou+VvA!x?) z#)Fp7*e9?@_!peKChq|pvm+zl6a5vlXhDIdEac9>V`^fngq%I{BZl0gaw^~`6_`8v zgy`xy`auk2amJvBcqoCvpb`EBJ*$olG8r;eoy<&mOwcK+i^mEX2gZ&7QWKe=kx1&9 z#9YNn$m;L{NcAMg5glNtw(C^&_YnVvm(+qs1jmHXBlKjF2S@I#HL@o9`np3Pi7oNF z6^qrn(qu7OQKeP8S1b%QGCmO8)isTRy9zX&t4FXX1>Nyo5@85IW-i0Fb%E;PF|V}I zX@#CiK?NBhB3VE^lX1;pGvafFE2Ood%{ViMXUjNk^r}8o<&I61dsei=>NtO{JGc?~ zg}#qHc1kC2yF*PElnwbp(GJHOfomdG#IZ#t$pWkU-Ii;qJQ}NU$6gu&;Qf|>qqn{ zljrGWv*+pX;Ehhr*Lv{mp6)+$j&`8iA33jcYghgAw5w3an?{hc5GJ4Z0!^{@XMVl&~~xvbb4s2bHHPXmhgaul{wP}c?C zDsBP`@JjdR%R3<(!MD>D5i6AfiozAgC(+B`-V^{8Ybl9l=V) zwK!O7$(ho8J+eS#<{@LXNlpS_Y?owaa^#3Oeb;uXvhB}V=}8m`A&XSKp}ju#tx#cd#>) z#CWjP=3ex>JO*w|sbsXSK2)GtXa3)tHu}fVz-x_8tIe46dwbwE$ovA2ie*B!L=x{e zRn~yL5^aEWQG&M-4}oN-;Hq|RIodKa1?hlt)Q^qvzZ$25sM>ez94#g2gw+<#&--H@jcQ!8G^-D0=v_WoWSkQ9gYt|l6Z zO;f{|j(J^(YKgl}4GUY88+b6c#?M%No>&hSlv$WFNqCL@tL+D{U6C9y7tazvfh+!a zO}tLem_1i7JNyZ{aqC8H*_0w-t-XGsYTJr$B);5DBeAk(@$d1qTzwWyLqOr!X$!MA zG&hqWV}XBNr0XKr@E#&G$m&uE(xi(NkyGxYhyaOVC4>7J;_~6s2YT$xO5M&(PbVoq z_xetk>#$l$&lKyXaqj(IXk?y~=2|Sp^qV%4R`Et4hwApc&_G5v3OZ01yC4 zL_t)`qg)5eK?O|~08ta4^K1Z^>gX}D(q`eLMTT*V@dk~a#AQ~r%^`lrxuJW}m<}XM zvihe;Aw=$i0<#LjyV%0!MtRBeVp{gf?(y3<+g;*YmCe?J9RYyRc4~#iLNI zN1ly16ed;;a1TLpQ~ek`4ZNC3qrAbWQ`2o}Brzb28gzBWCKdsNRv=I+tz?-OTx%6< zYKA@#8xbVq_%7SvES_~#ja6>UvkCAzKjM`9DDu34o(ESAwrD|hesn0nQ$*B25~&qq zQb8YSN%CE%eiJ|i6+AG9XDe{3m0$|O@e|v|p772ZYh?=^GX_hD+Fwz}^@q8H*wq(vfScbilnnaxlrrT@!+sJmMpT@8VN!m`MUcKFO4sTfhLaGH}BM zH;+c_;e0p7@OU$Bjf_rA1$3=Rbh%jaf^Y^3n^6i3Y+On#x~;p~U@F+49$O?Z9(V~V z7S@sK9@5;7GSg-OuQxsTR5?ccpvpFk3te{PMjhU|Oa_IXmV+w{%XeTAModx}z+=tJRAeQ0&3K6LI*ePnfyo*efyfQvjRkT55qK|UmA zQ4mVw;zCLINHw{3@an-JfsY3q1A!}68H^j|X67u(0V^gBIL=5xX3jV;kQGRcsj^%J8?>np4&s14qStLvzve}#e>1O zV2cD^#?uVHF}hC9i|#$KtNpnNa24_5ev#D=Rv=0C(?x&$qDL~2cWI?;ka7(uGLUd` zN{R&N3;;?g+TEY)hER{%Y@ zQ~mXFk_WjCQa_2`HZKDJSvY-ZPUS_0Ll`0lAak_ z{ZX9jYtxQ2P^tR!mIB2=TiHiiE(b{Z4mnQcx3SGUR};&WvaD;Oc~8 zKhszP*iD@p8CQrXwsE6=BG>~UnYl7sIeAi3q?4!Sdhn4aH3zR1L6~xaX_1+6l@d<1 z>X%v&z`To*pj+h$A*utn+6u`dli>bVueNRF!8bJ0m{fQctIoJTUsy&mwi>lV^19;K zrO;_rbLWc0uunDBmxMfPs^TS8UlKc7W#k#htPx+5Ofdwo$qF81MH?Cd%POB7G2LYr z7|%nl&bT^nM4shxCF6P3!^{-WrOMApNv6%U(vt9r0UAk}?+M-+92ug_sH#`}TH8p1 zD%rGw51L|-naSbPkvL*(2>4E-A2^s@WeoM}W=BvLfa;4Chq3xE)o;R|RYx)bZB%y@ zh{0l#wPq2iK67DgQvELw=bT+uUg9%hp(HFM8z%ppkMj6UN{tQJ0wo?X)-s=+x^I9)-Em5v{)wNYZ~fALt{?o0|D<33`XADte*G`&qhIw0dh3_| zn*QhKyg{#f_P^Apa~`}L?$d`)-=>@Ack7A8qYCEQYPWg8-6Ef4C17@^5F8bos1`Xo zWHL7u9K%>}nzms~;d|CdqFm$aJzKTbM&`S7Z6I<<*!yY2S>|U?EMbU~Rz2HUGyl=F-pEQnpl1x^+B>nRm#Ms4)d|JP4E4`^K^%hR=extPkc!cOi@*4Q=VIyA%7?W&RSKHOFxR4|mH&&;D zrYS0McDG_tczAqBM`No5uB$%5Cn&jO(uS4{r^x6;qD^2|;9D$7&^ctT2X^=Mb8o#< zfA-;rbb48o;aeBLN+Xd<4SbzBtxR2vB0ja%HJZB6PwEJUQ4Zh4eG7((N_(jV$z@Fv zDDb9JF*cjzl}K`q=SLhfKr@&rFux|Bt!AR5(;e*5s$mSmuuzZSnixqySX67RTJMld z*tYPC#!rGzt*A4!Y7_bi{Vp_yAp|Cs1(ydfDuRZo+Ph+3@R3A95+65*ALF1^|Kk@^ zRH3h!$uiFXCm_R^jnTCFOhq-x_CwDVn^wH>xq-9D6^mPD-i#ezgKb?wE!Tk0PDwVC zI8jG4Vx<9>2Ao5PV%?LON&Ki@UFz8(n3-lI{jTZM#zs}HuAOMwb}B4xJ-Cnf!jy$F zBHNmsS>d{@_LnoK)d=x|9CmszVz4-1yJyrFj5q5Hr zsWXzKQ6s)G^W(aFcBL}tj>u^qbn@g$z31(B>iG5)`~bc@sh7O;Q#5O)I#jlF1=aS` zjyy%5c*V2y${Sy%um7|!*6UvTZ}qeP1lrN$!ATqe3*fNg;qX_st}rV^Uq9om^zWl+iIn#&oWP?ju-) z7{wITeh-pkj|E48t5s3*R+nq4t)^3AI)rIoI8w4%};uJ2bsT zjw+`~`^0cRHt;GG%LQ2iD1jf4P><@Nvq5X(__`~0^z17y*CjJ;K0MxB|E^zX{&x-e zzn-7ucD@q96$pYE6!F$kZw9M(+}85{ge2hzBt~e&mZs9SNc8r zp6Qdit212(=!W=Xbcjf+aY5B^pc*8Co`<05QIZir+Q?d1BnGxSH8E@&-%$`J%UUyk z$YlEL>dH(r+g3A9)RrBotqv5`De7bEcj;s0?Yg9Ux~v=3wyp%t5r+`;N|7p7m2a#L z6_Bs~)GN z<5&C3g$Al_*9fJ8ZbgaePlbmtcXzd2tANwj1hK}>NsOb7$eBt~ZABSmr04v`Q$AlhLp9G$nBgaW5H^f7F-qp|07rykf^}S#8pY&5-_XGN)fAyRC^)LSkea9z# zqpqB7>y|mmdU>1nD4fg!I+@liJ}YoWHPtG5RsERU09knxkXtIC+SAeV4yI=@Tdxwk zkhSIqOP(3Yb5sD6vHEP46Zz`#+o3=lqJPGBQA7#cM|`UZrmHSJu1iVOPBy_h>c^E5 zc5boP&TOjFyXW*IF9|nup`Eg+$A+MU&BVfJdi?ZUXUKg;ZJs$oRwftO{36F)7&gkz z?GILy?c*mGdi2S;9(??~PVOe%`{-Fz2ntjfEtTLD;WLU>CF;wH`C`qul}_!>wIunS z+Ru7&FYAfZy`DTfkhF`s?y%aXRSj?hoSfDg(-MasWiCLL63$p}YH(^}tti;20QMG@ z-%CI7`O;F*>F77+R?&+Vn={}+;HTbQ+nm=tCA)55#k|2=J)JY#8{rCfC}R1Vn}=(3{Da8wk7*;-)WQ(2_FxyLha?pVW`)R|9vhn4*jXtk8;$gob9& zD$X^A6=!j#v03Y*`F;A6^S`CRd-ZHl<)#4rT3H2MuxvrufvUTQHnE0&g&+bSxCCS$ zaE?*EreUdT4;|7+&YahG-}FBH>N{`KpMK~u-Fjls`4PH4O5>c$cnzi_iJ|IO6u{rB z0yt|=U{!iW1@6w`+sWZy6XKE`(?DWLJX?{>G7fJ9Z?#>)6RHyjY*=v_2nZox42>#A z9Xx8nsOHPe-&~<(5>6d9$)d_&d^b?b*xa$1kgLj4lx0k6n@*K}28bF|>%N!OIp&or z*kp8LEEtZ=T_MLR^_p|MlRYzcV02+j+F+QdkR2T=#%p#!^;&5Qst!#aDO!+&bJH|( zm{lKl?h{{nd|*WY_bjGWo*r9NJO=Ptv*;OPCRE80->S3=QhEsdE@+fDas<8$S*u=V zrixRli3tp<9MKz9Kbli19n6;`0CWad(eLP4aB%maE=JWw4iHpZDVf4*Ei*&cOdB;~ zz)`lC^m+@c6H^EPtYVBQ(=*`B8kP|-UlA%Myj%$++XPPU;AQR zdD$f@VC?P^_AxUBcNvq4jlyWk=#n@($ehVeeWqi9gnj(!`q~%%Bfb8M{+<5!Km7^) z{ulj-zUPJiQde&s)yK}?sgLd7sp;U_A;C{0KBR(&AqKMGc7mAmQ#B?x&X#(mXj%gPIUjV6 z{vJ@7E^iuT##q|cUOFvKJT_>A#<7K~Cm|$^C3s{EG7jlNABnntA6Utq`88RM1n(F| z+0;}_GI#D9r7h{v_I~~G!LRDq&%Z(Q@TdYoIwC+K5E9vZy-X_g%-u{8=c&yEoysSJ ziChCmF#{!Vt&X~_D7GWS(Mney+0sj|eu_T6+v|UR^a=gk+iumLf9Mh2eX7?!FF+wE zfh;hqP?2=#R^?ma$Zif_lrx6VB61}HtmM8`_kqASqM>nv#mDX+1c=}Y;6OAsi1PHSrp7G{s|+G&>x|bNZQE;WsX-8i$jCH*B-q_ zU--%YP~Y|Hf1x*i!;g}ze@x&0?0>Ag@?HAS?wxvQaYBWd*cvCAjtz2Q0WcWAKr(e; zXL1$n^tXPHSy&bq+xjKTah3|xyBu(3HoOC3SCR!IU2+1gO@S2-KLKs+PiaO{UKu6|QBUi^i)Ev^T zo!wNmv2}?b{7jBfBvXc7#;}zxk)(t})MTBjROn^stVlLA6dRfA6UOl zZ$9%o`i-+cuAg7OLDodIc3g|~O4CVq!D&B6 z6X$A4L!{5al?vWOHDqfYk6HWd9De!cJN4slx=p|O*1PprH$SF__oH^xl!etau9hln z;XFh&O}oKtz@BRYyKegnyholV95%osx@+2W+S=O2K1q@7w+!B3GS;Mlf7HOtU|Ore zu9>jHqZ7DQCzhEqw2Cf#YF{RqT)39aGXZu2$3(wlx9ap2qguJATtEnVE_F=InGkj}h2uR4*NXQ?c5kjMW$XXNFdk1s)NP#{tE`ws%G-^Dmi>)Fu4lE8HBjZdF zTtr{xEM5#{nn8EX;vwco4@iJa@G%8Vx|w2_phoRC8U9f9EzHSQNY;buC!t@;Nye9i z`~z`S1HS4|pAF!2o|kjk3|(5)fh}?d=rlp7R%DiuF|+6-Y`xN|+6}seIhDr9_h_zd zg1Ih64LTVZO}KV#qlUH`iVlWWXV;Bp?I-H^;o}N%McifhuC%+qt2e#-<9f!W+xpwj z^Pl%D;R>p@yU^!c40%ke#ZoB^k|jx5UK@HFcZFeN=#_j>_BDRImN>lizST8{uF)62 zkVdpI)5RmUU`!tC3xE8Q~{WK-a31gG&JG z$z%$rMvH?%m=Wt5*R1twt^NIdO$@y~$iNA4kO2%U9Wddd+ROs}lGG?FdaKYywV~*e zt*wpD1)VZJDq>^Gvq{$wt)@a87&dLYAT?$u=lXkn{Gvv`UbY!G%FG(e#Z(=3z8o~L z)h<+yj$rHg0YXPze(7;_B!^?X@H^a2b$q(5t10qrI-_o=@s!6-07+T4qPMkia$4)-3{lytc3zEk^*OfAkY&;)Q zuS-|}KKGMfs558pRfqMf5fUq`FiXL~b1pJK&T<(>X-O_FmmHli5I-t7Y$D-iz^zW* zQCU1N-mATSS293%W>@3Mm}bftz_`!MA+8j2lq)C_+$Bm+AOuAe zFc?wIWa!E=qY*nywz>_bjf&Q6b=D;B%}#(s-{Gv+bk(Vf-t*|Le&Br%=okL%X8q|+ zAJ^>dco2 zR&8t~{Kl)$v<${gh@d9Mfzqn;|QCqdQwYTpQdx1hFZH#VPh3^p<@m`f<=(5|u<7TlV2 z5nl;k<&1&NQb1-Z;jl~3I83RP<`L`IIw5(%;( z_iQ@Rh;9RmQvv8m@ze%Wpo%7vbZ%Yrz=?%!yZ4;l{=R$lo?B1ox8MC%{nk7GP`~t- zzpJ15<6qS;|Jm;=Gd?q~?j*YBJyVxozaxbe2|g+Wcw>ZWjxC-w#Rs87r`#HOQQo#cTO^DFTqsi20tj?yQ*M9nE>G+k;)81;Kz}Xcsw;5k> z{xpnok{QFa#xNClmT#O#itrjZCWx3p3~-zfi}5Y$Y&fY~*B{W@2WgdA9S1=UC+YaFYW%Ae(~VH=t29KeB0D@J9=vSG(B5S*R$iZ_}*#NQ1N%I zs=l!?g^iOJIXZc8#Hme)V>lf8S|Djl!T|~n6A&U+d5MF`EXWj)FX{01M3J$HfDG;` z*vG*u@+SJoB^Y@ zG#iV>l|7RXR@iVfFd`Wktp)~4D3wuF-r^>N#1_!@K!OSq_^rV| z6;r_WwNjZ)cDV1KyYV#My`My87#oXf;_gHwjvR)NXYIkF{`$E4@}@v z$iE3{rfqh4+fknRFzJF7EDyHiG{T4+m9m^Idssy6a%*j3sA@01P(8&a59%vXBam5u)Qdw&Jr`8B=w{&(x)g9nu2N->h~@Xef@u>rf=1-UhVE~uh+ z+f9%wqDGYPF=Kov5j>o+JYuBquVKBX5j=J#$Mi|peX_pkRbQ)L_@}Seo4@v_^tu;+ zgNCi3o9DOdmc5TFl_bwnAlcejHWhpbuIa2r2Gn=xdQt?-B^Vy>w$+ejNu%PcvK0+w zrIgkZ25>O*AT|6N^C%~Frg2N9Omz6rwx;NrHxuEjpj4ob#tc)zFNy_Qc@}uR6Y?8?U)kPru^0Ex-$lAJPs-EIZp<8i%67 zQGk2oeytHljaodvdS-Yg3reTHR!upnp2Z2nu2^*H-**kM zA=xJt7*~1LIAgufe9r{&NM$;X1^Mtiq&5>{GddwCT~5-?K59z!TtF(|6ov5#y@z#D zFq|{hiAgo-XA(6-#JYwKgAUDRYN@PmK6yrOefX6A=SS|+FTU#z{lSMH)JJ#``~=4* z-bPKQt$<2}Gyln?$m7U*#TFKxeLZtWu8wgHP9EuVm;|t?@@8cg#WjA?^F;8?$l{7D z&f0v6S}GbdQ`OtdHsd-RI|OG$kvR-xs^8sRW;E44X{0;}?%=1yrBfuos8I1ec4v$L01yC4 zL_t&{Mje17JJO&j@<(Mb&FCf-OjER29?;iIj1xX&ws|7SRk2B6)l(E^3XV&*kL$Ca z`{{b!Yra8m`^P`5KY8sN^nEY=XS(Xh<$C|>F@5mhF5Nop={%O{v2Ta7H!K!yj!LVd zLcUnbbjjq9js$YgC|OO^6dgTBeLT{V_gg3T&S*{1QNPE~B1s_GP7p8YF!6BN&LLuV z&~iO&Y+n*r?l*R?zEl!w~#Kr%V3f??qTX0D8L=e(v*N*>93Q7?babE;+4L{x;? zFl3&)B979qs;L;?iv)#?x2`#=8R5}Z*`QsWCKJpFuw_Y;rj<{V{`~as>!I2G>ZVie zvkdds5M)>!2GINSwmHhJFEh34fn27jzZo^_xIX7|WQS3G9sCBT2CG zH^yf-jqn*6Q=bRq4`w9uEk$r|88eY|2)T|&~&2fnrG9@W?-}T1-tT)~EmpVT@sg|*0%@4?_(8v_P zq=B_b=wRmHI+4}qav7~u9*i@O+I+CLfs3JkVW% zEHo)Z9gt(@8znCOmtXu)Y(2_Gm}R!9DdW^^0lTIs5a89RgKueIsXD*AkhQJ_DJ#OOZuvSAZ|z^RKw5U4Kf9;( z=I`*1O5RZgw^~zCtnjh{=Nm~N-DF2Q?F7!!!>goUy!~Q11r<(O3lQIqiV1g?S@5!(PHwTJQ+& zinPSDH7nnW1-@Qe5`1hMRhwIIISbh4IEHx%xrEFac@Am?OH!@(SyIz=atk!q_H;{| z#oUS_`Zi4?WBx`TcmWLG!2~@RpcS$ic!;r^#Eu>X{no=;m3K|V?yeD=3=|MOyR*Ra zqQaCYnX8yl7#(28m2xv`Xp8oFYxWR}^v#d$>*mJ}^cS~0qThMvUHXl8-KSrB$K(3L zk37jS)w~u9L;=5SrmpUg zG+JVTfp=L8A%zKI3{`jV;@tdxNEU>Ax3dgc?gnBg+F3u z44upxe8^S5t5`KHPegxK!LQs0U|dUSLDJmS;B$O;fev#;c6!%wg!rGr%nr4j7 z(vk#*I2v)-bs02}YG!Fm_xr8-ne#uSpF86M`2}JK>(5D<|_R0C^fCigDJRZSwN^lb=Pte>5m@dw0bmDuVz7+07;AySXRsh zt;F%gNPo8-36e&s>18o9x6%$Ol{e!Y`-G+_Hm;_bY0XhYjNnZmbnR5C`kGV|1cFL9 zXKgDSj-^k*vu4b_w2hh;$G)xJ_1Ibc=uIEdfBuVi>nHy5efr}MKcctZ@}xfgqn61_=VXSk|WDPZosJuaG$@Gyxq%QpX4;d2srR0#`F5S&dL~ z#hTbmiuC84reNcO?zK7N0|8|Q9$?jzv_}#@fD89j{aZs@U90U$$9=#Dk%_r= zK}hI7g88anVfm!OZ}Xzr){g%$#Nx?#ez{nM$!f);i_*I(?{es>>#%h67Dz*ks$-pe- zl?v7U$P31LZ!$UY+pvHIOG=@~_eOlFXjt{?%yf)IdlgmgOK-SNU-IG?>R-O%6?)z0 zzgj=@6uPZHvHszt|tY*nxH2lvoZbiXJr9ghd5C?HcvG zG>h=P>~O0i({0rXtj{~;4@3r%b)W+eZ^&k>nVHD>L7@HccaAOID3iNZz%dhl;sTH;cN< zZqu(We?~t)yg|RU`X9Qxd{meD5gi#XmvA;88^r|;J(f7|h$Ep1&XXjPULa8icp5<; z&~aq3sC-asgK45N9hOTl>)H;y8`a97LgC_gMzIz__&*_&_HH^2Lx`nErQvwrMN@7GVi?H0ZHzO%aR@daDnqE(zK!`slg z=~^}D+e+{&8@cM=gRMtDjsOt`1GcP5cjOo>jcZ0`<(x?%(qwi>9mhA~JTb&h^Hr}k zyQ(&}3Qbg`H3V32rr|L~dGmXPuNZeZtx&JWMp=>Xkzs~BwIvd=nW>G98V_>(%mB%% z{qd8!XdW!)%yMkdY1VbBe$g|pj^zQPAy_0c-*C22qsFL~*XpU#vLy}JYaq~5DRTJX zE=u(eiEqVwi6JlKB&g~eF}P;aMn^bO8A)b)`05#Ug%6%x>%F%>rZ@lf{rbb3?$)pU z#Vz_TzyA*X;G5s0H{X9o`}p56;_*ncr9(bZv&hQ3g98E|6blDT)B${Zup7XdgB z+%i+);fP$v4jtAvfAv?1$1*Bp!yyL{?0E?iyxEM41dqF^>!JeJP+hXPdXf&}DKXyR z$5UCBM)WEek2umKce*Y|LoEwiQV5+K zJ5<>cJf>TnM1YUu5Z)nr|rKZK}jNYS{_Gz#L;)cfvyLMM2Wyh~DD8Fp=3g)VBFt?5>rsR943ZlW1gZRELGq>3pD z_z{AuES5_6Mj8*Kyp$8%yZcY-?EXoOEKCuHX-Tj^ejzE6JTrZ3d{ZGX9(qtKQ!My1 z000mGNkl!-GnTLe}{8DyR9~>AzrAj?SJKQxoM55p6rUW_3G#*k6Gwt`% zqi2`8`^h_2AEAO~Nzwp*O^oBqFfPUytKd5hi`yKj$n;+Ki-SvR(!k)98 zqk8@oSL@nCjhcuYB#HiD(EJSkc#s5J`~?}nBTE{n?mb`wz-sVC&yBjxOT3^ppg6&^ z)wPPq(#$)FqmW5!ny3N6QL|Z4=7n1th{KSPJ1Tq82!<7()=W-<&b<5|$qP@J-l(@e z_UHQcKl~Q`pAY@HQdr6uFOsg5g?q`ZQJq!5mDPm%0r#ywSLBQlI$1~{-z(dwKF`_9 ztVOUWn&c#8_ryGt@Fg$ET_y@v6Rg2%jT;?kvEJ2ueO3cW-`wkT6>><)37fl|=~ed~ zc9|W;K$nBo#J9{eq1fyw4ifP(jzxzj6Fv2kLkh@Tyj2JeKVxXrD8qkynH5jXg#`Wj z?~~~wK7J8n{;wfZY0NUSAd@TdyNgk^tT7(UaIl`lYZyG>!5K`mq=V&LOAh2MNDa$wi*ds>pPPwbx3NOD12^#nmRS_RDj4ra>yNdWoy*~fI6z+4lk z8KaB@N3nuqPUIx3k>vBK&w7sj>oZlw9b+C8YS1&ssv?q_Y0$`-%bIMx#>ffYBja0ioK8B05J-SYX0C_>6dWs< zDZ|qMrXWMXpfHx>4eqYs(xmIuhDLPB_};WZ6W+W7nB1r8;T0!6{2R^ZBWLo3#h^NX zlki(*lw8>If&OP7$IVRgX-tYWUu&BPnh$Ku0qpP}j=f8-gh?-pt`|j17Dbs<_tY zuA<0zoJ|tC(Dvaq67WePP_iaXt7ACq)%e3D%{CQO)E__afd2Cb-lu>4-uLQ1zUKq_ z0ggp}>K!-h7v6P?{`Uv&q}o2Ix8HG}KJw@ZJ+@fu6uW_ajsh|uv{ZRjCXcRA&kF58 z^@X#6QB>4+yQL$Cj;JN68~!5vW8|o6xn3ach+b=gHmhx-Hh5zv!%oZfQkna#tTj7` z>Mw~z!D|#{j~dX);jtp=J4vGrEE?TR)5#W9U4e}$B2%e64Jwvc5avqIj*NBu?ZUND z8)w>SCIoV)Zfh#X##Oh-;(WmTN6zo+f!#fQ?8F(}bl<~z>m3j2_uhYx-uT`-^}pVA ztA6h7AJ$L5=c9Vt!{_wD$9Bn+4Vt^2x7F(T#L$(^lx?~ZpMx^Vu_l^~{k1mrd!y2R z+N4yE5^hGqC<-1FW5d-o62G`7_gO;zvXbJ!u5~YJ{fM-8yX*YNvzm3QVL1?|y=;5X znwXoPGxF$6#vhr>-IeHfomupN3qsEqPW@S1YuRSzF2QdVk4=^DGx{|eagx?2 zy6otcI(p;^9cHI;?cqk(9o+)UQI1^!S0ni((?W>o(!%NQ_v3%R;V8n-fHIZgv~6(K zRyD_T7EnwwloTIBC^J(}!Zn<@_T%rWZ6JY+v}z2)IosA1X*Ch|(6$hi0KMTFV^CGl zROHYb8J{>$eSG(%KK|I{2HiYkn6s6@Z$m9NkzKmSwo=xSH3 zMdfG;EGz+813b(t^`Zj!nN~1Rt-$LRCN@!R(<#xp7X*f?NHTLd^eUxE z*CKzCyKm+u0%bzNnoe0b=*z5JmA`@6zzUqxT~LKD!HDdAU^Awq$&gM^*70+ zAz8Pza^ZUntx7Ukc3%4R+*dsc2?Tkla~Aa{o=j10gXgFu_jTXdQIGO6=;U&xgQ_=2KY@iSkERuRE#TXmt6_0!xlWL& zX^cg#RYYf8N~~(iHsYUa$&w-qexL#jo)xi2!<;GeXC(dYwiWKF3D;>ya%E9-v~Q!U z{#R(*-Ar5b*Q#p5H8?(oO$K%#gSif%cOJhy$-;d1nSFiep;P+n$Ik0598bLE&L{Od zAAC$d_qIFqQ*XOXKX((?58t6*zw;rz`|(}fJU1)^VwLL)O*{dmVkLt9eRNn7M|uR6@rk#~Zb17)Fyyi=Q-L zY+BrXSZIe(*2h*(>6Q#yFYrQ^S|d1U0sR1C92rk$fRb|r)A-@DA{ zAoQ$r3M7|QpDoo?&k8S&B-R8>z?qd@ZVmKCR``|r{s6p^x+c(96v$cy94oyT7?;Jk z5vRq;;1NQQgFi5-aGx2gT;ww5qXJln&p0II+Ha=TgE)aAXj~&;t)Mff^_&4uz1L1> zR342k*_!Ce!-w>utxI*Ob~viYdSqU7)9Ew%KMy>t|MBq$^|QC!qaS(C$MlAG-mL#h zMftPu`G|h`gSY9|K76-+^@Dfm_doWK-gMUqy_bY{^ZmQ}*n@j|_*~Hw`>w}!iyk;V z=)-qBsax+quZPZMJ$zoe{ow=M@z`7so*DJzveBcvNl(D{B-lO55!KyKp3{A&_jQ^D z;2aD7L+6*e@8q6NfaxRk{?vKhdip>&Ke4Y5JaCrlLhpOvoZfl=S^fFlC-m#@zeB(I zp}Y0J-gmox?j0Z2FTMMAlJL#?A0NC&KmY!_^h>uste^krefsTt9@5V;weqjN*^_Mh)j$$1oEK**XDo(a zn7NEr{U9*MJ<9k#U@u{-$#jBG_R1^<&0?nIW2XK4vbxn&^+jr_hCH;V%buTf__*X&A{6bQyqLCgW4U}zro*+-tMe}B{W z>)ju}Nui^!`l`^dIRM>;@+ar}}2C z-d$YtiyL-fRGi6!yM+)5%A(n%mCO{WGJ4LSma8?c12{1Njz~HKztRTK3TkGR%VaEb z*T|L0a_&2nEr4CO5A{rELV|eSwKwY8W0xsn&?ZC$a}6ZQTkp9`AHDBZ?XC~BKP+{= z-`DQmfushRFf7Ln1xGHdo`$gs@)^nX3tsjzz2Z5au8*%yX(nTQRsgiZm@0#rDTJtm zTqAR1=L#(pN^MddQ*zPYelk@tNT^=TOe6X?O{2EG&}zjzvakqV5xOC`Mi!=yg)CCN zkM$W9&U)EvwOq?Qz#|YOqv|5ITrI(^sEG})FJZIF1B*+juxsX8(;C*)~j zEApS#oRiggYmOcw__l2%A@}lNs!m-9_NH-7XB~X;W+n~DUv1-zub8A71l&htt+d1k zHvBf^f-O>rj)WdY?}eXw=c@YF1*cZ$16>2-+)NFvL*4+UGS!**XCq zk*{Oyk!GgjvDMS2ovxcrbop$mTE#AS{^)Z7PlJ|GMz4{M-{7!xEU%p@e>820s zKfdk#`nErLr@rqm-lK1O(_iWP|MI>1>9>ANKlbMz*6aV`1NzQCyGgJA!*}b4{`{}? zJ#YSNy^dp<@1*}b-u!;f*WaV>h4$;-@(%s`cf3pA`lh$)+u!+qecyXNte<$_&HAGU zPU!dVd|2;%=&Wvje5DUPaYpB{c|#x{!N)Jho>#=Ir%qdKTTuh1-Uj@-*B)o=BUP!9 z{32M8LsWQol9{h&s*wd(^;3P4xTr@7!EJ+m+jLTnBwLe)fKM~9k!p-4-ZP9O<&1qx zVOSQpOUVXl*30HW^Lw)LiB4^3Ma`grgf+A)UG<#fnqCo=rb|r&c3ZtFr6?m6>_q9Zr{m}!Q0 zWagQ>VzVXT^C?3ac?>}2%U#`h&n>$Bu3JdHC*>g%D@&4ZF9)-P@QK}M@ThUj>Ul?S zu)n{NGka07H3@sJy4YnUWFJY=Yc1Dv-FNB<-S)&Iy6y3Ybe3Gx^OBM5stz5{T!ws$ z`L2e3L5wmtWsR&bTj+lM@tK}}&1K}rs0rWO=)|fiMEACtnEoE0%Il(sUwZ8NS&?tm zzmX6S=1Ai}7_hJ@k(v*NQ3KnWoJMG7#n>t0h&72-LlqKL#+hm*Y87~MH6)Em6XaYS zdILa#t*+WWqDBGG7V@&t$g=q12kzB9r|;K3)irpC1z01iR2Lk;kbp5(sytcJH`rw9 z000mGNkl@fuC|SejW#lc)M>Sg zoPETI6V>gQHzsQX=XyyE*cr?mq3WCxd#TInhe84#HITs9%awAKm&9*WT874j05Ota z?nj=D+B!T@GYP7}(s-i+Xt9YnVnVj0s_Vd6{iN+w7nx0CCu1(vimbLwK#%pMDZGuu zkZ^Vn5e!;of=}=Z;LMMLAq9b%kgE{9rm)$%MhO|D9>W>7ckEP>$?=sQ{6bZL{I4s={l<3>p1ebcDAkKvD0PnJkmCr#)3RWhW?ID37e^Y zyy~2EelhT#Mw)Z>K0<#*)yX=G?t>Eg7PX>VB}p~;G;VSkA{rREay?R@n28O&_>pkZ zp5#{(k$X_AF;){ry_zwzAVt>50$byt;Jclo(z&4FTqEz+DkLyRJ~qgjT?S6ad+o81 zhoQlDL|hgnUi?{Xl;M}aC&ZS7H`0%w*cpu#>C0nA_N0swJ&9SFIY_aF@6@*S<|jU? z-+9;XsrLi9H4-wqikP8*uSk(^Uc2$jLB^j-r--<4>4_&F*FKf>?H_r!9=!i9#*Q+Q zxRD3y*xB^34((hnprjH$;057<4ps{#05!0TO{1EZmb{B7wGaALr^#%l7@`h%0lUsw zZO1sO^3|{+rdw^bGX-zqm2`}Sw&}Lzq0uuho$7Ftk+sOlBLld3&WEm=U2#Z%j}NL{ z{OCNcjcV4WlU9jfNq{?Gl%4?ZG4ufm!*Pb%#0oPIe1n44IL-hvIc3EP1}qz{4KVId zQMP8PfgM9oL$c!$322R>_vE(FnyUE258h41bgQ;_`4$;dlj=a?Sg!^hK73S?LnF$} zl?cXCdKu>RT%Wiy0rAoozCb_pFaD$Mee6yh4>NcqVLz?|iEosXbln2ZCJiLG1pQ2+ z5y@dhKU9)IzdBu_e;l$dT$7Cc+7jbNoG^r_DyLbH245E;fzZZDxN&#v;4(90bd_8rnWobj_fo{cYLgtgLPzvtNCAKXrqxavXC2YCUd>Em z{@;Gj&?{h87ljcFgQSeF@EiI;<5-}ZHDD8N1_-3e3tx(y5octqbepzS(>CgwMlCJim%yjQbA$SXMDF;}hzu34U_pYH zE2>k_Hu!d}t81gC%~bWTX~y8-71c1Wstj8Ji-eqsFMr#5X!c-J+XxnkR%I<{maylF zo#s&ep!x#@YLaN+19?qI0FgKdZVD!fwo!&$J#*F^Ukt?9iXCGU8niI5Op-BBG{$A@ z;&m=GM88K@2Glk41N^JNM#A%|FV90A=Sb#3X=PfR>ltMmo0kfh_X<4Ix>H#tL5?Jv zp()L#6If)$CMYznYV%C;&FJRH;syD^VnvJ=zKb@n*w$+R!-_qkYLVx9!O>69@7(%J zy6K}Fbu{qIjjCVPwPx(5$_#XO8PYj&qwNNlH49q;>j^P4-8rnCW7lbG=a>ZV%`e7| zJU25nO}mMaH94yZu~O1VoDalf#x4f+uAOMc;#&VoV`1@e{3Yj+m%mFE`Cb`X14kbQ z&KVy(&(X+oTx-BK2a5x((Y>A|RNcGa3ixPnQxm`^kDeS5bnzd*_@UBt)|KE^s!gX| z#6TTE-6_VP5?bYI&@ygFmJL8spPlN2hTsFriT)Ze(P2#jpTtJ3hYi??ZI1zlp*US~ zXsvOD58QvBK5*ZiN_3vvpL71&t2%4K!5cqB5Kg3AR2^g8FC`l^a{M8ZLNG}PGgzjA zaec#UzEEHE($CYqr|;5{tsPm@D0-t1TqE>^kr*x`=n5vpZjuZVMF|~h!og-#14Sce zYKXd#9WfOc-^3Q)1P%A|gT>~a9HLEv0h$6H2EB%=Hgs9-#N~M5ij|w}v7s^61&`|I z;9v!7*JQgR#CjE~q)MmnlM;fJoK+Kgl?MT3ZrGyHg-=n}Ra_iyaO7D{ULpal7b9{N zTGkpJWKnsufEJk zlA&49&#gfp=%cU8Hgtq7N8+joLGZm0TP5Tik#S^hVe!po8p2x5T9k?{(=Oi%s8P5 z>=f{e$<~aV)GL)y4fH#bwuit7QwWVJ&Jnv^!F#JMd?t~6NAieb(7H0FFA!HXiMx62 zCjJZaqFT=K@`TqBj>CL#+4_cZ7^?|R#oW;pSTLqH*Yx0?)*!(y#sXDkQKX71bS9!fq*> z%p7D2&>GJqsK_bHtimdh2**v6O!ZUkQODH4tbWyF!&C!?z=ev;Odi7KTEAD{KpbfF zO6Y@-DhOP_%iYy-2 z$mAEAxt#t4oq|1U|KIYdb`qabosH4ePcoPob0hvTnM_nbW?+#i%&&|4n#HsF&i>w9 zVoqUvPsP`>aN?>Os8)w{#+K;Hv!-oiX2_Uj{BaBDQ{|OJe@#jOtTQnNTOq1y%oSAb zsQ?G~iP(e!k83P)=Z!LoN12Alc69a*DR55h)@niZI`+2lqcvf+UceW<6Ecn@Q$eLR zLk{4>zhFeHwCx0c%x zuq_LC0mn5J@(x&Db?I@5(rDz_oH%~u%&zWy;*{eCu>0?cYL-}qL*&M9piKo|S*iyip4yw%M*`K&XGeH<9$=!Fk+5cb+(ip_StqF+jI8o(aouO$0`b6esXcg6f%dEm7L|8lhrV;1M*|Z4~R?AHgRwHF;MJ4RP z6wud$6bOjz?Jdb9atd>d1eiH%9JH9PHQ!&WC&-ioi8P^u)KleoTeNk!k$8JzOcS3GydwE`siqHqYtIDD3J{tQ&-1J!vKqIR~Yitx!SVC(f# zQkDl-U08a?nbXBjfq&Jx;9F(}{{p4TJ4uE6;xYzT93=Yb{?}H8Y#^KJmdw;dwIa_1}`OST&Z*#lU?AAe+G1im`3cA z(5q=0=m;Go$f?rL3@}IL0v+N3vFVY_YM)iOBnFu|wn-ZBpOP7@LV$PZ)u?GYnKer6 zFh&xl6c}4Hz&A4v)bX=HB9`)4RG?SwRa<~=#ljHCuuJ8Y!N}3Gw!os_uxkq~lX$5H zWW-KZVnP1@<41KVK2{rgBDD%JW+Xs^pt18CiPK8A^5T|-f0U55Agg(lyD_q;YGy8l z;P_$Iii7o@g{9ibiN{QZnxQ=fOS7bBw$Ideeef6bfk$puz$dKY&slPjL8Fm}h~APF zZSd&Fe)HYYl_zprsgyN#8Mv;L(j42;hBhLFc&7titS+G$%gJ_uhM-=IrAv5iq>` zT2nbiEL&Hm0bnD+d-Wxkslm~TB>?K#j$SZCrKhq)u7wJjbhG_-ml4QDviO`AzDR%Z zL%*W)4?Uul4Vp#dOhu)7n*j;QVyxK@PcRw%^x^b17;0ehSmp*l(MGf$&-Q(QU#3b}94uL|7(_sNb%CmH^{>GsIXvJGrK(HYK?ldo8aB_yO#m^jiUDOh2HGtfl#XMG$s&a>5YT7_TjB49f5nQ{jp|6pJpaH*0Bqi04nFcVe z-`xW9J9=FX&8i>L%F(f4+u(r6RYi*QxopD|RW=x9d@SO-4U0?+PLdwjWe{8VU!}iV zuF@Rg`ytp2CB}4P9t+it{a&L>7d3w#_}K;Z8}9CzzNVSRMIG?Kl{MjwC)dNX3A8* zP9;3>KBZJ#1)j!SrP7 zn9i@~WS<01sX`-%Cz)MGXEPn$W>|V)q<{ z*PhnPh4vRqN*-6n7HtE6+;d6-&*0JaRnCiVhda>U2>%XPb>j!j()%5t*VHE8Y(c zpNgsK>%j;FiQKG2LLNEO)6i+YyHw`<*cn%`tBXa#5s57goV{LfI(Xt(4Z2P^vMDu4 zDytIUFW%;l8(CU{D3j1?AOvJ>Nu(8bL#`I*YrIjN&@~PgMbQh1dVx%XGD@x39tV_4 zU{U|--TtvKR9vh>KKo!_J^xRzT#@G5pasntc4!VMlp+*u@R5d-Njo2tAfk=k)# zwv=;LhEHU^qjLi;z^ygOA>(tEkXuj&1EaMbSlELqO~dEXW&xuQxeENUikc>>gF+h|S-nR(@PM@f@`+p`&qVopBYX6z%o=EQ$kWK-C$PyR@5Z8< z(n;B5*kWy2C-%?k@iQlgv4u!d_{fNUD}`~TOJ@^`DsTquHSfq^t0p#(D_W7WO0%uI z&hF~%x80_-7Y)QsE~bGU=$Z2eTJTc0LB_zu>7>g|2ScE}*dMgL*rCWC2ooIA5kh&? zfb%E0B5lOk=B#jQoTzOlB6q7g@Cfi*Zo1*}%d`a`f%|GS^8i4dc5%2!Gttby3h5afm|003|Cljk?*6s-s@EbGa@(#v5A_zhMAbkAbo><0}2O*;1_< z2Y@!Ml=GfanhUBb$dQokGe7YKde4vjp{mX9n?IzT&~3nPJoAO2IhsJC#ORT-UM7CP%;&_6P#_`#&)KLJA5ss)=gNb`ui9e^lv(5ar%TxzWgCX|U#tLIuK#>OH?06Xh0EWJY8O;^-RX_KOMqRsP!uhd}cYOqwE$ljAk zs_YK0hIlC?$$|b68`Q#PU}32VyI_ZDJB6=lVCOX+NaG?YSdfs}@#*?c22Zb-wyvf0 z>*flv19LIP6}+MHPaMq=6Bs1oKay)884pzEabtf2*PsmVgntSdyS8DzVaKcjk4)SI zn=ntR|B(L3S@vDKr*v*_PeOLUXMU2q(R_npE=kqT#?-6e^Jcb@$1KPkU#SIAE{f`9 zT5s^}tNnu$Lp>x%HQ3Ftk(spD@9UBCkL$^UQ#x_(F|9ZfNd(|!hqiQ_ae;i7kY&AI zqw@-Rm`@DWxs#^R6XzDX{n0(;Fj2&Y(^@RWM)m4Qumbb-kjpY{98VYf@rxb!x8p+R zg0E(5iCPDElpbnw$iit@nV@J0R4QyppBUpf+)n4(YNMP!XAHx6n~FiK(-UV8w8x97 zHGrJkU+A`n&MR*Y-zya(hz13MW717j1KhH=f=g09!l@+xO$kx0aX!GY)S6rV&D zU1c<*pK%p_ZdEF-v_c>XA1FL<44$c2xJ9?1h{G3xC-b}}(*orlJ;sVjB?5itYQZ;D z8Za`mjcz8_3fV@+i+R-sl?RXI@Tr*fR4?N=R_Y?psM3!$d6+gIt7;%jsrY*%Guz0; zMaJk%k~O<9I0JJ}vJp>mF4$yL<sT2M`YXDny77}=vL1vtZY=?BhCR&8U0zXQ2ko~X095LW`+%_D;a@1 zl_C3t>b-&Hk~hlE_qJ(O`P5)Pm>)n-%t_D&ORAHGg=HiO*5Jyi;QTa5W`YfIWy&gy z%Q>NQQ9{A0huq{LDA3FdEb4Rm%s2GMO%;WzJPXLTIa4bZP0Gg3MKvSDfmYJbb z7F$tW7@NUk*z`-L`e6uTip=WRB0;AvZWrj75^}jkp#;NC?t$i_1O1Aap~9SsTLD8< zSD`|Vp;1Kcu4_pgt^uDxR8@HgFoT=S_?-nE$jN2FH&v)ewr0MeEBf2E5h^l{*nmiV zRu=-Nv*B5dxr`r)cwMoJG7HQNN(Uxm=TyWW3X_cKraML6tdvVp<55l`?Ak1ul<mAC#?Pw~u%`I@M^rc=|nx@n`ytdTnD zy?5QBgFHv0gr4}N3W$NA8~n3M;l4KFJBFZud^}+{a9XSVCl#=5K^A~g3^UpUv7ue3 zZAAQaTbk8ylii6$vg-m#9KpbHN;!5HJGf& zUiBaMy6ePgWNx)Zeu5OoD}x-lS75aqM(fu&y)ORa7e7>N6r}m8l!wkr@40hVH{E(p z_n&gjF+_+h#>;AP8pF5+YZNM-2Jw!}I_&`dC5Lt-ln)p(lOz)*XT7dV)-FkT_xz}j zJvy%&76pQ<@m6?-%v{w`tHJ9Ey5hX}hHI|XQC?2udVCR*GOJ%HOQ2Y@H9Dd7a8_eJ z(Ek1tl30}q##|kpg<_z;z`L!+$ru`$6N&OU(EOtx`C0wjul)b$uDfp45ua*S8Wq6p zIZNsqv@ye31}O)OI*~IspxX%TrU@9?H--yAT&4O~*VfCTG6crErc@-&L^6&c;EcRr zM)I^E2^?yWy16`j_R?j)-&DfDequvX=mjvCC7foq2 z6@p+%bzhTNj2$u^(g81fSIY~u7V|-7QHGONUyn^x(+0Jj(;ozR1#c8WBU6~ER)kOK4z{aRytvm%o7SZV*@RcJR!}TK?tl#*D0d2L%a!5 zfnXQd7;S=V9nZGA31^8eCm}}0&Dc@~_)-JNuvd$`tz$bD_BKXEWGnED9_7dqcy9EG z;5Gw8M_<}TYB!A(7LqF9u;tgYVH}=TFHzDTc~Z;8?U7U${cQ;2R|uCHx=v zkeo5gUB%@AXUDEuh^ZcHu6B7~XzeO>E_xT%=6VP3 z$v$}RNj-dKC7H2!uF-GY$Ykau=uy>fTg+MTp6;F}cEM~=;rPYTt0O;DnbA26h1FOW z|3RILA8dIQLT*AETaBF6K5)wPc9Qgm?jH5TZmYpswQa*uMytaF%yhb?j6(DIt^!_o zsB1PtZvbT*I1`u$`~TzxYr#& zrY?<=B@*#~zmD1{lamf@PvzEWOe=MhE!-?%lu;Qvws8my%F0~wQl`2x56W^GN%X15 zuhi?m>6`WQ-}xiD@2)%b;Qk{z8fOYP|CkLL!qActSk)P;2-Pu~Ho!*&Osg6Mv2|@9 zCV@9u?W9pQ(_%5IUj=3O5JxJ=-cE}`JE#zJg{1575g+4TnH!u3t|Cvs8+~Ecn3Hg% zE(BG5Dz9!bXwrp^oE}ZFX>mNqnMT$B;%lJ#?s-8oaE9w>Ek46AYTWc?)!-Q!lg6yD z35Ki~8kq$N*(-JhDT7O1q-sOsdp(mM37~|q^?Bx+o1!``)Ib2I%>*}PWEc2DQIjY| zmx2|wF{r`5+mo3(k7^kk(LvDNXS;dc=i0~aXK42mc<#^ z-g~gCn|Aqra8e&wo!5sBp412T9utsjmq2^8KdrI8Ltw$nU@e50g3*l1t&1cwNfDXa z#^En*Pg^y}Q1B=7pb%Y%#VA9aqUw_aO1cai6!35~H?I?%L>+N!+Haf!&xnJfg$^RbL;WQUBBS zf!)Fy8g(q@(Xt;kwTa%le~%tG@rcY?DNGeYRD-=_9rU9z@gw*&23BqwOt{QKcKTuU zXYR+RcV(Oht6!=g_mv2`?a481x0iBkH*n6JwOl0TFdteDX4AN>20nZ2DnpR!U;lHC znFjct+&xgO_6>Pb3Mv*9!CoflOA8jPHF@py!CViWTgfrbn85<9bB!&v7JZA+y66w; zUG!LO?MyO>GK;KKx8_~b;9RX9+a2`Ld(P?ByY}_S$*lA9pf$+GDqV7T ziqV(6eeO|U#McLvGBCgB(AHsHchz%s{S`0Jp+m=%IchK;MF!s7OPR4f_ZQ~iZSo=s zi`2k748MQ+WnZD2e&bK{$}2xbci(%bq6IlG_Y8RuU1Lr5W?EAfi~}KMuAaWdVx{`r zD>?KF5O%z-22Q_9+CMjFv4@CO2P8kDFwMv_rZ+Rz%n5>~2sb#04| zLSL)C$p9zen+6=faGGINv^*HJV!=ye(qey&e-7F`yNh$JRKUVrYTJmj#|_OUG&p6e zHZ}_G+(!~bP~YQZ=#a2?LBNbm3FX!tXP`kvQ31gsjy+QO6)pXK0V>=GC4aUhV7A<*p#0Pdffoz2ex@^}Ozo zUOqXjZ@BJh`X8V6Ir^2){&M~Nr+tC`$7gW7~5Df+d~`)d8lXMVnZ^f@ok z_dNTh`tDD8rH)vyXKo+Wt8aLLE+fchRS(YPAKHIR@8qTM`{yV0AUyX0pN)so7+^b9 zQdAo|P1;7}W<_&(jOv3aHBzmZI zR(TrzorGUd%&D;Jv)m|pi_1I3!l>1vzi_5pSF-BcmLxE88P_adwA#OaDLWk$=PkC4 zY9}MUSHK{s>+qRqsug}gmifU_BMVX*N4bTKe|T`H71e9tK7(~4R$A^`b9LS*uaj1L z_$M*8x3|!Wot@^=-S^l34#H$4Y%~Iwg=GCk!U7$`2BWnS!PJF?`qTGHEY9uXF^!LZ zK)!xVg+Q7#6SX0${wMp_UUI#*d|Odci|idXuGmSYj>2+hdq=C@z-CXE>|{+`V6@cs zPSAA66gi??vs*Yo*HTPt67vdsax1aJ2-xz$Gy9s4oep<_w45|;nQMJ%ClZ3KuB*#r8g%*5 zZ5R^XaLxcMybC+3{!@gDrD-79_3Jq+T-F(4^vSbp?d^|R6BP$+%kv;T?}jTCFw$Tv z_2XPtJGIiN%4-k{rH<`frYnzKufi&wOD~plnIQiLuZ|)R0;*7h(ufUOC-YpCLu&cT0PsV zPFNi{!(&+W$Qh8MVWBaq=>xT`EHd!b5`|k-2#w6b2J>M^ zS`u6jTIP`~tbU!|Y=@-NW$e8E4^&wSad_48l;8vTzif31G=i@r?1 z|H{wTFa3kh)IWdb4f>3$cl5MlQOA#DDy=6l>uEi(dQ1>G>LapEVl#7% z@H9aUn>dNQ@Zz~H!~x8!JvNPTlBqz@xJKj~s6ba#qe8mg#n4HgK)J zW!mQ52wad?@J3ki4gE4b0ek9XtJQ;9^ZS_pWK?4;wOmAm|npn7`Y0`oDOv?CWVEn4CuQ_ii28fctou7bNfAa>HKmb=j^KBV5O`S zLXnwd3L3q|UP;=0Nd1E!)Z+15mD(r^E<1Cd`bR&meC8poPTr+{K^09w)oP|Ik6x?8 z?2>jka>S)k*oeiEWZP@8?6tqh5=PsZ6kT<6szdC8LzN+~Br;9M_lEqortmD)+ac0Y ze9oqwLer|A0^IZXTs_aXI@9JK%ZgQP5*!pR_Qx-FU>z6ZewodN(lw6h@&?3GTmvig zNW!xTI&t27ZXNWIdk%EplSOlc=de)024FEAW5sn#q{%EZ(O+pv`}3sp^{@KmqSiaG zi6muL^|SrO$kxhq`s``lc+FM1bbF%xz0+C^bBrY%d9Fm#O*kR@6=Yj$AaRdrx#6Me zY&fu>EtynA9$Mp!nfqQw&JgjuX*0*>wyP9 zu18s=cbW-Fy4_T&!~pz@Oj(I7ZQ*KWT2mcnlGipB{Su?sd$#~zX=}Sv(@2TxG*h`` zLk`O8r*p+WV z2BB0S84LAu$%-{uaMmj@8K?$=!`odM{kx{qY__eotvrmeu93y8X(L!AMHam_Fcvy; z$)nT`4bXQwNs@X0d{-abeOT|`eNZ2oKcQnd=oepqrCxXARr+5qd#?V^t6!p@`=S@? zXTRc=`p;kVnfjNX@k0H}fADgB(F;FWFTCPPz2J(g^(j=vPd$8G$G~DoYh5}G+76mtWVWo9m45EkU#y>a^=tIsUiJCBs%hfE%NEocuTunwy6!6sF_(QdIDFsuw zB$EKIYo}^rs{n`vJDCMJcIqZmWyUU9%r49q$$U-gHImRqm}H#B-0`7IAITFu5=R-8 zr9rEZNO;#RHY@6;E(fDpkgSIT6}QLWBAtO>eAlDI6( zy^-c;)bBn{i7-))jSyP_zT4YJ)W)fntGUdip)QbP67Hr2zIA~Ata}$JLDOcgD~>yU zpA=G5OQIXuu~u7-DUoYNa7${M2(2O!O9RE*ISR@R4$NwH< zb!>ZGF1=(@y2b%?(0aWF9x1rXxyyiQD$2n$vI^dO?-||p=!y!XQ~ej9n(BCrwT)$i zWKjhtOQ1Gi1SKzWlnbiPnH8YJ000}biy@EhZ0m*3yo{7zpGB3DC91vx{D~GInMu@{ zmDWR&5x5S`h?Wh%BJ|v($S)&ks$+BxS-Y``2M0rG8Ulo~PLr%|PLJvqe@xfliQjnTQ}v^t{CWD5fAmlE z?$>^)e)fxArSGJo{pQbmxxVbh&(+I0ruc+Qo~mniF4rM4NL_e(vP#baG}JS3l1$H{ zwZzF2dPMvq^?;Nm#FxcJ9%twh$-{%jDlg}+1HsauQ=?Hsf1cn!r6tE11IK0NTN+26 ztfI;qTdlX0hN;pRla`1 z<@)WHe7=6`(_gJ`ecFw>f$i>n<0E?a{Ns8!E#c~FQc2mu^W@fWfE}!FO?BLAil{t&iQf(osK;9MVdYJa&0~R zALz(+pQ`EX62;&u#CjK73Y*K}Y3#yBVqRi+V-@OkoCd+qK8kfb;Xgxn&f?8;3bJ5gMdE8 z4L(W7CR=*VXML7_@%w&Izx`uBt5-bz1-j?%J9Xc~cW9t`J>Kl-==Ndkf{Y{-SHr@D zY`v6lIPobyXu6HggtI`uA+QnwHsbWe2|TrFi9`c?l{%*s@uLglM#UI4GN%v#IU7|K zvX;OYlcTqgx0orkjk;;8O!950mP0@u= z#}bAl&1Ov0f&_E--YLEJ%spy`xjy&m8}!33{B-^07k{1p=huFvzW<9~rGNGLpQ=}U z(hYjS)1Iaqj$NW1k1BoA0cWgpWLObw3#yj_F0r^G{)6z!*whT)vMAO{{3&YiPNnT+ zO|wZ@0y_tgSd4&i;uU+bP&aNW_}r2JNys~y9#P{*)rDY@Gz#)80ks<9#b6p@haOw%211`=cjTZYw1_)Afyaz!1IV8-s5 z@212U#FGURr0S0hZ0ikVWX!P~Bs$i4u5xIh?pU26YNMnTJD57W(k*mXJrY^Vj5)=X zxex9t3^JKU{Mr;Yd=mwX4N~S>@c#gTu8ru?FfZvO)o;YV4PTFj{aqT;fie}co2zK5 z(OVgUjwLD_xmM-UPtkmOg$8^$`BAl3yijFYUka|2M^LRKEDpd&}(GrS-Z^TythLrxJ@PYUmx{5R%ncIQwK{ z5JRz|S|Q#1@V;(&cq!#pTQTWUUL4l>t}Kfa^;AtG25jT8a%e* zpch{*)tf&2yL#&_f1-EZ`lovI^t6pM z>7t@FT60viqRNgQsc6>NA!|uuTrEdN21SxU)4B{=1I~5$@Rnj{@-8SqLpJCzFPo1Z z?O05MlA<1)ul3GT59qPQSzU?a{qtu&Lx1$zFV`=A&6n!?zwDLzH=p+s{ex#*rfUwH zw%SB;!(mCt6or~Eml|0tM=&<1O`1;88#S?!KslpqW(Sm6=n;pmCz?y90-okyNJbVYPlpN~W^;<8li!Fi9i=SCVrt5Haut zIW;^Fmcemb9lN4|piJOC!h5+o(CT1c^`FFD%=hJar6XgZC|2Li!NL99Db;#V9LBbDDb*$`wCro z=qXB6;%jy_3(kzsvg4JnwL_AP_=d25!5*W6W?=d3t1j22vj(~MRQ6i~gzNUi4rQ7}msc`O&DuDBTaH5l;!2QW?J7 zH!;XipV$<6LOs$4lJNWQd{`fOc&**_L=Dbu96{{KQP6a-H);=1))>?Xn#}xKU8VKD zwor2AD?R_}t90z}A@z7#Pk)A=hl%E_iXVUSwBB;-$MpN}f0zFJBk$Jx@BN7GJ$b** ztj}uM9{}WBskjc7YaLLvE!ZwKVWz`7$1rSzypD+zj@6?EMzjIPU(&bYIfkwJRnMiz z-IdU(9x)s{dQ4yQ`LEG`{^#GUJKpfC`n4bZY5j}O`N!Jx$93-s&Z?idPxqgFQVSM< zL~;osYip;KvHFrD&^^mN%LsrFCHxa7t=}aBn>q(SL$0ELp{uW;HqQd{f`lVXag=S& zR%0~!oyAg8YQ;LE%A-~;D&j>D)dmT&)BtH3C93EM@6P*8wK%W?85!uY$Z2i{P{3HGB*S$*L@`W$aD?jNey77v`I)u{}c04O~5i1t3 zn%Ja(Jhc_Y;EK^z|6`e|N3~s*%fV9UnRAe=Q`^kcQnJ)%BfFYH(k|Gds7f|U$S0^8 z$&P{*bB;A~`sr8KM)e<}nc*vH>m=uh; z*gNCPUE9gcrCj758QV$4W#-_MHDpu2rp-s~fa^0@&_3^1*L zKm8e>sn=e4H9M)LKCoPJEVk4@vfrjsoCH@}F_=ONdHAY`yd%jXm~9eV9Nj+z{ zW+G?SfDE87==x>Dib=4)8W6y}rnP$*-#Tdn8z86{&gB6;l zk`|^?yLE}i$#rTEUBRQ47G=Vl#siJ%Kq)OW@X}iNRs)9D9l1u=U2^@#_PhIYo#UnC z;X^yB)o)hD8w6bk57V@fo^jbBefra`(o3(qR97DlnzWe#StBv9zrWJ%!Kg*wYOxNe zkd!d&=|!Vk?mg8!dUDkw{6+uxMUQ0QR-6`cayc^1dck$aRIA<&i6^ZiWWa%u9)vQ< zXsCgTz?ndE1pqv*x844v?s{^eV~4kOHA-!Ts3W`$J*bs3iFVG)S)llJ$uT`ai84m3d4kyfDG%?QLAgY@xdnVJBGHe{7@JySJF@;f_ z1DAdfn*v^5UZ=17hhL!o`Yr!SAN#2{>o@Tl8KJ^O|5ADQ24w$ zcsRe!UBLrRGqRKD8CT@ye9s$)9@(X2Fx@0Awq<6Kadm8^S;Pd5B-QO)Y}$!9Vti8Zmc=f}o0-g}>d$9d(v$mZz5C(Ev|xwy@*~IeE1&%j z^h^KbRr=wtc!j?Fh0oC^UVfP_gUD8qdd?FUoFlGDuuue@jNAo%qMF#KehQcul1P=) zLr@42{^O=47OoH)wIt;jS`tB{0xd`XIn`^=In0{^3jY z$}5g(pQ`n}=hu3KU_RL2(_|%e>rVY%CQ=&Y#kVYI&5kD%7tuQfYtR9FGraLvcUQ$J zFs|y93Cw}hBQpDx&<;!>MKvo2U^C>kjmn&VmV$fz}V7rCrf z2i510p==H6t_;Nds6=1e;mgbyxUW}(V1Zw**s%?uX7F|e?9p{IMPjb)8hHpZgj!OJ z^~U-b(U3Qwr<7`ix0J5B>{7K9A0w-e!K=zFV;F8WS}AgTR~T#b zPgtyvOs0zAh}Pg$k8q#ylpTG>vo6;&uh>EUqU~9bQEZ(jPpvt!Gk63R0Vm!^Vr*Te zc0{Q_-~a#+07*naRDRBisEhyj#Sax#=?Oo3e$Tru==!Utdgk#004oJn#ER}n1-u5> zzOH}@B+5ojFqN^FTa>KR%@3W@`K9Z|OOEQ3E<2)Yj_qgw)TV6|FlY@BH&fdLIe~G0 zxz_V>>g%qU$yqt)snr9gN4@iohxMno-LAV?@y2FHS*_yMM9pkMa_Mz)_ngko7g|z* z?5~!puwpOYrsz(KX?P zTVcK%Mg^-vWmaXtpDyr$ZV0ZXZTT*Fpus;AG^JEo`0hx6yaO*Z0@&tM`x9eF)sM;H zmzAg*gGZU97z}JDB(YHiCz^4tPKWNcm6qwOzVsPa>SsUa`TCi!e6ha&t6r?vzUUfV zbI4Wy5w1ctv|{01b8H|*b4QQ#p^|ow%Bs*djH(B+Y`Km_gjC-!TRp_)%?}LHw$7J230I3H?Pi z0r&kJq!)6MX5}aFmKu3*Ilf!vXxeRHm??&^;n@5pg4_n*5Q6OgN85jg?UtnFedzsESnCa6*rE6C+&$fsb07>e3?LyL z5FjChB5*E10$CB+mVJC#mMvi~#0uu%pCNMKSGo5qXdG9a2 zX@#mfzj}Lg{_%DB>i*-r`&)ahcde?Yo^U^RJyq-5-Og0=tM67a%kti-J|CI<$B}W! zgV;u8{x~oE*lQ$@9JngT00cTuCF^-!N!5)0+)uo}$P`WWIU1^zqI&k|T9||~llkoY zZ6c7!tg-Kv>!d>*9WzPWwu*70+JD8wyc$p00z8sO=7s&4?%r?3k8a!F)ui3k;c3#6 z$?wemTzfmMf|-1n>gv%-kGyhSmktiph75KF-Enb8ciyqD!uFfk+KK4mnS^RCI4g3V zzx3Ka?zJ}+6J2LPUI{QxC%xYPK#P%S>zo8AYK%sbe#}lec_n7v9^x?<)dPkEsIDE1 zdgz6fJfPTbNBN=0jzJ*`XGD32NGd{0SrpgWnFM|119vg%QU?n9^~Z1O$3F2nJ#+I! zlQ4rgOsOQQ!9lEB7SKP$o=N=l4nJIog@HEYZEX;rNr-Xg3(9X-u6l36;A(bU$aZ4tr zo~qU$jKm&5PD#S1nz+G&MkcEbiJ3V!B=@D`oBG7{7xczi)_?!CAJ9Mhp5L#(^oKsE zZ~vNi>pgd#*M4L|p@LrzNhc)PYB*AKgF-efne;j(d_PKT8;uZmAyG!gEqPP|lmpAi zlm|KQ-JrJ z$5+2qKk%&|*8lRyeviKY{cqR(klz|Jey8t5-;j157Y_ZRV<9Ed17ibu+l<1wQZEghQKkv z8TJ3l(z8vp-elw!#Td6Z9Vsg0q$#UDF@bOG7N6>R!=^0;(7ND@+K?PH+KK%28qmFf zqD*Xf?xM^B`i(k1z5z}a2to#1t?$!bwIiSCYnxtsF(#~dO z1#ng@J#W8luKUinI)xO1kL=G(A*x2fw=(ZF67d3Xo4SN`f&0=lO!VlLjjpoqJaHz^ zQOtw6QDQj|gNJ7e4vjkH<;q(x%=JdzoL)U%=_h{k34Q#@%L?rt2{qwGoYa=0iqa;u za?H#c9kRTeo!nbI+~i4QZ#d*Ady-LZFnUCj;bHCBm}5H3@;xa=zn|pvwHLX8} z44^{r9Ea)E@uLFzW{9q7f~L$;hKaF)jKmXqIP^?_vDChd6{{TQl-V$Ju>vz~Wq64> z16$B>v)ahE{Tn@UbW2Bhq3?Y61N!kl^fmhaO3GjLX1(v;bK12aV;>N%Qz4m^1$Y4# zGw7Iu1~*k?xj$?7nH;AI#M908gac`x6?~A))8t& zAv1hR5ak>_a)(u;go6@*uZbQ)R17m@&?=1@@u7&Yb3p-esAO9-f}26!CUPW1t)!%0 zMzT5Zl{1DQIaj~gpfN4^#&gTcv@tOxcQTVp2*{0IRYrs`B&XWnu*p|^Zf)`!!PCeR z*bxIq-v-;K=o!_*TQwn_BJT(acoFRiMZyxo?Z9R3vd+YycieSW-}`l6roZt=zFvRt zYra}vch}we+~T?(sqOK>1YAe-kF+h$VP#5_E!K>x7eSlnq|Wrc5jb z#_raGHWWiuP9omc8b`L}~7mb`2pnf!3&HlACYkw?CGyc{I+@?5>J zIMVNa;6r-z{r4%OpE2GzbdnMld+sIr4}3RlT*?gGFfLzINEkD>m1F2%aU{z!4VqB( z^H`hiSh=i3sMX?|;GK5ndvdPb#=T`b;|d)0@Qc?azpImt>4Yt*K#}nx+f$#va!qQY zc9G|i?BPELCjfJBjgM-fiZALMCeFepE7jSk{5pQz>u4(hfHl+Ji1U4DP^8yA7pA&n zwQ4hU%MEquQj9*_gw9_E@1lWok@>c-18xzhS6`WJ!O<+?^ zU|gz_ci#6tJ$T0h@+_}hEiVXmMwaMfRD5E8%18lv$x0({h91Xy{>9(gp3O#iKM;;V zBFdQqW68Y}<$=mW;x>s41g+=Pyz(#ZcdIPLkb0wC;)7O zo^}7U0$31NLBB*5J9OCTqUb5PqH34NpUwkN`Xw=L%CanuAm7juJxpFxD)2Rx&*yJ$ z(CSow;r$QjNB{WO>(Bqeuh3V$_l>%3XCeYYta@!og8I9|g6uOoB@{707{f%fW};9e z1M~w))hOO(A{eOWL^6+5m$}p=lN^aKh3JEyqG%)Urh<<- z68_CHkXf;k%34*X$;+WjCGl5f2AP@0v}Sd*;#!(bXYxMCdwfBKoY)sG+qh9Cr_Rmv8nhr6`9a=6u26d`JU0Ea4z+XewnR@%#%8K z$BAh&b?EO^^D(wTAtDo13sxvM_$4co3!>r^dFaf^r6^C_U6V9}yqrn6NX`Q1Ms&15K8i9P2;eAXILCFDU(m?*+s86MiO z^|iwPD<;E^3Cf!=xnR5LbY*(_(o(&(s?c$DJnTAVf_>$1sn9^?No=7GT9F3{8dY)SN4Xe>!`O}1m8fx6aX~w+*W))%^xQ#`vqn)492fOe=7lIWPJnl<58i#760_2e z{L*83>c~S7JLr&fv`o5ou+lMaOP9QCFeKFFVDUvpUr?Y8&Y?)rYQ4efSzr0K59mwZ z^ft9Fg1G`XModpKCFO0^wp1zH*Hl99k)^U@`(wR1*5T2!=yqKWf-XapG20i&Mi8jn zD1(THT+p?bQ{lO(f@Or1p1*ua++?gQ$JimX3QXJU)u6%_NCr@~ZQD8peAl!^Qsyx9 z;lz)}=N^>w5Orc4F@|#{=cP3yGtfMM_*oM)XrcJyK0P>V z_2`Xj8@84KpLdqM>%*o(} z-ASWN?(}T+4q#|^7W0|iDN3T&Yy5GqvE8;=J830T*#H6;qiVn%dy>=LQSW*Cp5AoM zDX|PlkZQ*98_3ZEQV$^+!3;5|aDN~%PX@1tUpUZlXYvU35YUJb8Rs=k)EwjX&Rx(i zeGvTWUjz#5#m=;*eGcM@feVJ;k3v#3L_LHd^xnV@1H~_Q)aor0eB@-gV!D zdefbE%9a~Z?y3+hh9u#Oh^PYkN%cDMJy?&(IS<(F6Kl~;7>woqm z`j!vhskicCcS;$&Vh=I7plMzzLVV`Dz@G%@UM1-$&-291Kx`^`d0%y_?GkV>AUeigZqNOe&;}}swN6iuwdRFcf&j+6!j}hHZmw&&zOJzxW6xSbe-A!N z$*M##LbL=+RkwnzmDo5bLul~266EC3sck0mF3PdjS@p~$kHeRac!V&OnOBTn&PgFF#g0Fk3+<_O(^}p;^?@h?_0;##3q9F`2Gv#I0cvR$vcKh9Foh?+iItjXd~v z-eX77-u}L>ANRURu38(E%D?YF(Q0UPi&cz|QMKR5WYWf<=$sn*kDC+Sc4i-~oV@3r z^XkcW;~eql4%=OgUf&1ry}k}(+9Cl33l14oaKx(jIx}1A!|$E~s;L@`$|Uc6<`tP<48q2Fae5B zwb>j3N!A1-RaMPNb&rkC@>Le?jFpLy?ebKoOQ)wj&@F z9r&sTEXOq&Is1Y>bvr^qyx$;>S+$ZcrN$}OLAYm{OeV_6FB&Dw*fEHQJWF!u=80E* zUK6H(1ydvMT7?i*bziMckt?dy9W@YX+fHA7?q`Z&t|rdaHao}*B+?q&It}ASHGwAJ z!pzASvFf(H;5vjxj&VIcF5s!4n+IpPB$7jNW*lBcPU240Z(zkU;=@`6ryA>V$>ea0 ziT#L)W5aj}>_x6u9H$U!J+IN#%Q-_go|DK3++N4nY;B4$$a~tDtBW?@-Ezy0yGTo;gS`GiFi!B*;!R2IxByGvmw<_>(+U;ZK&MS{?~_gkU|Y)Vl_j z*UPl^Rsw$=SnTjsvuE;q89XOeo~>3_6C~{w#+Gdv?hi$o_64#W*_xms_$cIH7W1$~ zNeLAI8+?=bI`WtFs=x4sLEM5)3iqw*%@K*$^QSNA&%EpR>#j39*viC)E*L7ps#i_w zsDE4m8%}95TB8@10xOSvsU3x&#EEg>v~%HukUpTw3D~E@STeQZH zBfT@Y+@Y{+1A^+9uj6p9qb*H49D@Tg2Vkg4=#XIAphhbc(3>7;_2Kt6y7O#OCZVN7 zzl<{n1;EjIzM7LGVGDuh84hpI4w_cVSctow{^FB5 z(;dZ#UF<mu)HkTG(ej5@KpE>#8z!Xa}s{Ru~gGYMl7lxH9^O;)_QZq zvlFemBaLYR8Le>BfN3LRjC9DV@-p$wkl%DN)kyJ>nXHRe>RGN=9Vfgas1u?rDYJjw z@d8tfWzHDWVSD0is{RoAw^VLToHCAB!FAiSnG+$y-(L0i(i0$!5Md?e3Ch=T>IC7O z#}?v^%{7LJd@2P?`K#v4N`p4o(zubhnpSLEHdJ8meA5~I(cg8Se&|nrgTCjhzMObJ zrws(vFma%!s@!@2SV@LXRj3qs?m1+9=h*rK8$El>4MbE_6nH+Lwu&yOVVfy3nRXOJ zeom~|>T~2_>?c|u;@7oTw;u6HR^@<#{tTz!1NRsiOcsib1o75q45^&Ck8^_a^zjm7 z8l4;%GV`i!J8~hABcg)+-Cy?QI?GC?XA34&0qV%P9{-=R&wH#)k(9Y7^~AI1`=DBM z6{fnL7tj583@(lsrNb|ogCW28GlKENQ88D?4q$Zx0WDL|r=Pj1mh^@<9%o#DiN@(^$EwyX|w9| z-V1l>J3jbfebt*E)Q&fTw~+yY3NVK(bBSP{Di4P$5TYQftgN!kg_kVn+x+j4D-Mp^Zs!qar?PB7MD6PqJ5TGc!3AY#0TPVTbha zjY~SW)9Hu4?ZZ^eU#s8qW#@H%E(M6u0DO{^t*Af%UdA6U54f7xXoim+&j+>>3WQYs zQNio*S>oQn-d?0#(*n9-qYQbBR13qn##fTz(~kS9i88EIus;`U0Nh=_R<1rF8G494 zQMC-)dv#JGW^93(iVPwed6tNKW}?W|Rx&zs6dd8ePD1{_AG;flAmZaM#@5UEDDSb$ z28na=MVY6;hZFAMjtLq!8buYFG~xEHdpoY+OS>I zQ!q?fQF!wZRJEZ<=vT+y^b4)Kr4l-K{iwcHl#GyFL+ez!&kw%6Kel)|7jKB@QX9(QJ?HBycGT{@oUnXTpd z6OMy6S4S{DQT0vn_>KzPTR>lO3;Qun-b?0?&r!E@s?8ZJ4pg8$TaY923)jF;?WEdl zI={QC$JZzNUGM%fefit(m9b6aQi~vy;SV$9<$`S!D%h<04yX`Uvk@%x>e1hb*((Rv z^z!v<@C!3K0ly{N?t9yKT*SYh03;dfu@d9e-_jUYNd;mq1SbRf`tSXB*h9MSqSM#D@1lP9JI?6N zxg&sH-Wv|csxMKL3)iOL-L}8cLNy`yZlwU$B;oL$*=j6ajsOStqemRBIu=e_ELJ-1 zkMxEMjlSgrZ=-5_j~=-Ff^u~ZeQ*lFt8%>Xy6O-$Lkh&9ItGC-$8GC1q&2sgOmKVw zz`^!)?;Y>fUFW}C_ucWO+MQn{F;_HRsa9|q9S|WiC+9r6nuI*Wan3qi97{zSNY<2+ zm{`>iJke~!9#@Z!867+PIkLZ&u&ig^O@sir5vjfkKp#ku5u!EpOyI$j(9T79vasH(l|;*a zRb4Gqz_x66n0dj+iV>SQ{ML^}4x*{CPmt>faWf3$0vT{oF@*Z^$gM{Khgc@zS06pN z%-E7dQzc@VOCjuD&U;97&9y1Uxmv2`mMXuFH89}>X1q*gxmwpPFv$leb0QWaK1%pt zfT%ax-P@6Ix9+Le=i7>`;t9z-(9X`BSV#I4dB*fx{}d{}kz-K)qR$iOdhIIakyr3x z^-=Ur4#5F`MC@r}M2RBq%CLE4@|b9)554zJ{l)M2D*c^5@FDHCNzdInR8zOQkek3} zCKpLD4a8#P7??N)$gz@|4^h0L(aB+_0@gia)=9YbD_dQ%nrWjk4)S|axL{;rr@%IV zKh9{zqyPXA07*naRE}D|G$^cF751(fO5Kpa%&5fnNd|AF4qS??;zznoXU?m*Af?Oti+_;qjk9xQbT-;0^Wp(AdO0_8B#C080Gz_$1;1~Y_+Pp5 zicU5sIygPlGnZb`jpa%ne?@va^ca~8hceVUR<+n!gW>JVztrnx`0V-XH#MPZ7nb%+ zh86#GRcPw?S>6hMHqEC&`>^hriRnG}-L1FYeJ6gGZkwOg6iijS8t=tBL7m?XIzwAA zZXq}HIzR4pwpYPv!9`|sX~~9p+eFQGrpy)@$4EB_-sKxnmrrJT{wQk&IqlAq-tnfH z-t#8EC6rhL!7RSBH)pny8WJJ;ATWaS4ELZ`mf9!++^d;6Rbn%$A4gSmOfWi@F?(^N zkG$o)zUyn=u0Q;dx9Z(@?<=y8x2BTz%~T--c^rilnVB-do zlgzw?2KC5*sf=^oeaHKB=k0IP2(Y{#HKv6!WWH@$!7Ms*n4@oz;0yK4)=xh7l+^IU zKDiHG6;!b)fFLM_s9H^I)+8)=%N(24fE2I#_Pgtnx}LFAG@$ed@Y8B!sWs{g%e9_c zuk{~(?c4N&f9iw!j&FKUcb;+TiAR-bY!ysM?0`@IJ;)}^4sC2z{cL6$uz5peH!yI` zC5JHs%jMcWk~r0OZ5!phX|-8kbC13od4Rq%o2n)5srF4Hws=+hBKI8n)}+Z)gdsE# z)l@My3fP|*a~uZ}xl=uhwv`Z^^D^_2=w1C+vNFk(SjLJ9#zEF*ndgO=k&Y-#?$f4T zzj|NTtp(w z0j<|7l`2zGwPVDN3`P=(J&<&pZml)(sK-EJ_bh=UKAcc(R~hv>fW=pFLA)XJzRDo3 zRcmyQ0?(z)Sc#ZbS74bORr}q$y?*aU?$%%b;~&y@e#txZ z2-rUzH=4Le^){lnWG;xIWai1Ws6f6=cnL3=(&)8Xj;cBBos;4F*aWq+R+OsbBbGKv zc2@FtNNLt9vt6ixz8h&ECsvyc=gdk|8aG*GGOZWndH35T-pds5OE=Yc74oy|PBru#6ljK15HDt6Y)s_aT8rU? z{p06&4|AlIvL3zivJOvIis+JvbMYeuqxi*&o#O)C{&)heoMX4l1XVoK@`Aw zp?ETmQsdB=922<1`9|!`5(FG{)&)5#{Qy^7oXmA@XM$ZtExx^Yw$(0$skx+)G0Zt^ zL2mh(=HHDWJDiDbBaF5Po}NAgAURx*e!mr*_tyb=Eoo@y9S z_e;&fpbNZN{`z-Z(6@c{{rV$cd!K&Sm)r?4U(kfM+ohDFy%~bCI*6qw!OV;xW;xFM z2tV&v)D4|cspH#aLC5-_k7Fttjjl2gLsGLV)RduQ| zst*u^&LiVEh%IrQlo{*j_;~A+#bN=OcWT;3o-tw*<-FGfLJpzb>M~%@h7vX7mxRv^ z52K3A8J?A#nF4*hbHc(pq!MF{kJwD;)9K~oC6a@> zu~#8pEpZk4b*ywTN6t$vE571e4cwQH$`HT!?tDX&p!sB~JI>8D1!H&YPc@AK*BfMx z+dcz%nNsbH{;%<8YwK%lEAkL07DeZkZKh|h5Bl^|$9now(q;ChmSwInOw~iS<^=!) zvq@IMi6a2pcvBBKl;q@aYMrFtb>F_uPm;V_>;BuOx-iYU_qImwdSGAoUo3jh8>af2 zckJk!*;oC8U;Ti7-&eg!U;VDz^w!&h?%MS#%%&#*v~x}?oV(%WPEkiJH##C%Jps-T zX`&heJxXQJ2ndUUViT0}DNDUu3NA~4R_tRI&UMx^O8~o*dCtmQ(+1Gg`-v)5rI%wu zbcDw{$MaEEpQ~W^s1ugT8boEAq-|URiQrUHq-`PbM-@OUK%JR^6Ndm^6Qcq_A3F3~ zOH07>^wn4N!F%`gBmdFY>CgS4cj`lLyr^M$sFQA?HOtG9x74UB5t}9UZAgS3f7C6E zTCLULdw^OksYK92+GZvc(}pTyOi6)p#l1p|Or}Iaco*fVgm-rK6iL{g710q}HzO0# zNE$^dv3h;w+BF?RTm(`9zfK75nph+zG6x<)ESj=348b$rTA4|hp_I0S)T#PD5vyWZ z&KZYS(^8F4fps16%vlA!#Pfbj-UBYpL}gUQ@RZZ`dBTp`-^_R}8L}$5)>t=Ts@Zg( zdtMzc2RG< z`{trrR&LG${Y>6FRdNy^b7BS7Z|xn3!<0SHXeL)}lEc^7QY6nzW%fCd7r-TtKXrQmM6LdAw_(LQRpHW ze{hwY&x&nQ(>Tpo5t*49;@{%)oU%GrbKP>#u+HGsDGf!!o{_>Sk*`DSm|&Bv4=R08 zLZ7TGA-^7Groy$eD05Cm?mKMrG01@dnQiqMAnTpB(G!PH>3hHY8}yzB-=M;lW{tDR zW5?=d1j~Zl9DN+|7Za+;Gv_^H2oz>k^GltqPL-YU;&iQUvy|bxXAf@bu`8E&wt}En z8o-<6)P{-nCp+4m?&;<0$GXYN!@!R4p()ClqwqmCTaR@zAr)jEY{;pa$=~F7QqOn= zJ+covTpg=!HNE4G+jaMOu}@bShkA7bd+M)pCEAQO>oyn%Nvc5>Ns(m}w_S=jNu8j=8_oU3*^d zyMJH1Y(E%pO$#7j}8C}@2=(}4g%up`L<$ZB`Buj*FGyv8vhyFdcHa&^D-cV_Q5mi4i@switAu@qyiScXSQltQ}IOo*_^d-aQ^EvVpB+zj#+K$5{ zDL#b`BV<*5-Q#OR$$=`fR&2%aIR;Ltv;qkokY}p*0zTo2F}C;2s}g&aljv*62qA!> zje2Y!z;Xyqp6gc$Ym^&<`aIEMlXMp^4!`T0-=Tl>y0Flk=G!_KDew*qzRF zcD|z}lhE^QZ?v(MBjyDR4Cp+7bpt8YU77E5Ce*@~V`4In<^SdjR^P9^B z75g=VFuf6d!?3Im8%QIIy-8#&QE|3%B zmdql6t22buX*P43G>&H(BTP(ATCfyb0bC=1z3)lXLGQdhs{RMC>n!9$)Y;vrx7^<7 zzP-7w9d5KB@K>oGzwqLLe*2jldhY5mdq*2`(8+n^638XX*A3*BX*-+G)lMK@X2%Gi zJz#5MRKn3Co^C==26ze3<6*$JQHDGUL_2H_s1|O?ijY51aE&s3JOHwag5nudNXifh z<6Bm!_aTNnUQ-!QRB|UV=Eyxy%PFKKX8X{1$Y`cY!i(ibJ=JTK-SWaLG={%ss(|wz zndmrW%)?dI3)imd4}S1o{hhz?JM?Ye{5IWvHUfa9mgs-No@oI@Yy4XGsBJX05Rd_; z0BIIbi=k1#p2CC_+Z`$nX+>o-Pyr7l)d*mFRzR6# zO0?gw>>MEXu3xDqwj1^e3(s0~FelZv1AS|f7j!J>t1O5rs1U1>iP=cng2f)YtajqM z%I>K^Gwia&Z;AL!(WeC`!A%HR0FN7O7;^*iX&$R#eX%S zqxY)*2+pY>cf*RNjuTt(!`K}YWC!L}jDL81qOMDdKH_|bKfFc@Oy*ghKY?pfgpu9$ zSkt#>LRxI?2j>6)5CBO;K~$`kknfZ3v#SNV`y1MN9{1l@S{E61y#HHWtK7K z+aH+fPk-A7^d~?3Ry}_3oDO+u7G)we717t>A0M5ne@gw03NSNbn`6@S9L}Q$)ocQ< z-V0*PDqytG*ANm~JRJrBJEWQlM6aqNGODjst?)?=m`Ug@Ryvc`E8t(}O<_BB(RzJL zf9<>egx-GVZR}yM$=6<`Y&Z%ckZS|7Z_1Zb6=D+nOwqSr#97CXgN7hordmE&p6UR? z>6p|#`G|sN!9;R?_oB{D_jr-FOP&`dp-wLzoak`fX=+77Jw^xjjuN&98|8un zcfB6iJEJeV|E=2fJ2D0fn#!TWO^U*&$kSI&ZfZ%cZ?b6(4;(Fs8CY#69={<2tU4Ff z>;Lide|zZuZ+HG5ZY)MuZXK-4V#zQ#xj<5p1O$iMAlGv#PF0bB@?JsC9L|tNW*+Z? zHUw&5nf9JL&glL-S^~S0`Kg$kaZFDDlHUB_9eU?o`#^S34pHJb-Sm3&#VdLYl0JeE zItVFOP~i{{NqF?A#D`y1NwFq#vYxSv8b!5nx|Ndv(8%`0m`*gNQ-Zuu=Eb9PQX!y2 zf1tYUVH(gqaBV6$ekA!b$!nHrIC3N*9Q}i$+XFmKg_jtGS-y~{4dB~nh89Vm`mEY- zLa;(G2?!d_s{8Q9{`Z8yMS$UC@|gSL$*JCYcdP&APkyDo@7vy|x8EHA;X?CvrWvH) zdd4)T$l)Ni83Y?@hGKkzP#h#;+;7RMmiu)q@9Vhe;j%{l9M=EF1FMV-Q4%~gv4JQC z)&6S$J&vK=c8kWli;;b^3 zyx8;@XY#7DYX9vRX83rN_mT%w8U4AXDlar%x#9(&r>*ZD-$mS#R2$L0o-#?Bus4&> zqCoM^@jT<@QR{A{H8I$*r7hfp&NnKI>7A4Fo(W^v9v$}ewTR#_wi;&SR>gqPxG*74!9-Ph!-`g8I=f#EPQH8UsI(AMKCM_c0M7`YZFGvrSB{^G&Y z`l-M0SM*hHd5aPgh|o(AyF@%-ep8V_c!m2l1fB`icxI5Pz_F8*@b!pqymwsp=$161 zV;fs?Gbp>D=dWMYib-}HI~A~0U+i8#Ug+6NSMhaFGE-szhfXniX+ z3ji7E>mav+oGL4G&dkoDw<$9IQW`GP??lF!0gz3Q$rl9@FHstK?3)w7Y1^p!pj#Gg z7N!N{VQpkw&vNV~ebZN*(SP#C->2X8q4S!zy~fe0Y)nfksSSjB$~nZSrfC!z!~3Y4 zC&#*Wa-%iT8upF(N{7inHIccnHkSV zEqMpfF|lq~9(NoCCay9vgt%el5x|qVC>I*ML?4nmm1>#5ZqZ2MXGk~l+{`I(mW)3R zy<2R+542C%81X^HfAys~r;e(#ZuuDf8v0G!nHp@KO`fno?HZG)jITWHMk-}Nh@}z<dm>-mRtGQdoNytwH!?+5e_6pd8o|`lm!HzV`v3gb`rRLRmqNFe znIN@6V(gL)u|W=5X4QN({kcOudaY=gymkO-gS-_l zJeE`fyid|Q?zvkNV7vh^BQJ{*p74yjM%XJyN9_INTIX(o?VVG7i`w`=0yIp3rdOCD zKmXE8I!aTIjJJaeTt|fvC84kU_PgX%!ECld=CM>31mU`rmuj)0P=VEBOU#gS z>W@jRbv0LGS?S>5MaW|fe=|8eVqKrFG9gUky48~|_+DY)R(K$<=_av><0shLbAAF#?a zR-m$Pwyyp|pThG*Wn|82Hk--JsVA1B#4NwjhB)>F$Rl?O$VRSJ#{;m=J6XL3@JCDp zYqlzC73iI(x2Pz4)%aup;u*g|*OTKTjl`_>GXwZMFT?U>F<#U$s|BY(AC$yB!fY4BZxkIcNBId!ZG zmQ>a?4v0dZQJyhel|i3?{`ES>sCtM`;CckViKFVbWR;P*-{X%3M7c&LoFW9Nj$I0^ zyptHfkh|geilpy}Nj*ArTyNG2kZrB(>+^az+SbH2^iUm(xMj$!>OCXY)lb!Shdpv$ z?R>f=glRLCGh$&5@&;?fY#`RbxAMfXt|a#6qyE&lzC+*h)$i7mH(sdOtN;N<$j2!Y z!$ICVwUemnyiwZ*HEhF8cUuY3nKqvMtM}4i6kJ);Q6Uc#Tw{DLKXu zqQ+68(AW=Cy?As*FKwRJPyFS-sXz3kU!n$I4ER4=?K{HHqeS5(wipU|R_JV2l(DJi zo@0XW5MIUiNFId7qgm0KO4iV)iH!_9dK7ZbD~3pJL_K@$2F1`Q&pqR~d9r3(!c?yd zV{=8gOu;!#XDzvr)gk}(Gke;_2OB1ZoCclQoy!wm$ApS1s`wLq5;5OU(DVRf!1rcS z69SKdBqEx#btgS{^_o8*=ry-h4#i5zmPzXBtO8LuR~0~!=rCLgD3o|ESCw+Aj`B@_ zW{WalFt!UkI!+x#0xAFYGne$j^+d^ zLPr0FzW9%=5$8IMRMTU3RV72Zm7I}ZfgOJ<_Uv@03`W zK^^A-|0E1djAiKRm1M3SMaF&SJ+@9%#}aLT0V|G~DxUUu2|H*1zgutA;DO7tznF-dMz-9j_xRqbiKX%eL0`!#p!zx~c{)Jb=OJu6LSCJ`$u0h^Kv z`D^&9r>Y)^|7uC0Kzz6a+xTVF);A8E8uH8)HEk5`mfP+*J8Y?5$oSkVHjNlsdo+IL z+GG0Q9S`bb|K&f_@B6Y3kV+e5O0t$C2v?wg+WMr_`0x@wIH7-$*~oc(^%*)k-J4~ET~j5zT(#TyaE-~EP(hqhO!m^(T8Gmd%QXLU+0YNUWNrdUi$};uf6g4bB*17$F7~*_X+?@7%8-BaZ)9bnqVTaJp^VL+U_@kC*@dP-gC}v zjm94mfFG%hJjNF_fTHM$=WghQqd^0eTc%<-6EB;6YVlK8onHX*?=#j zHCSHcu&ojLN~+bHc_$Pj9wtwsVvM>0Uvc5Q2do9VygF>}0c(MP%)C50#?YwxppI3O zO?(Q64@8&t-b`(b>H%NDZ`_g#E%J-Z1rJ88(ope9rh1-C zW-@acu&I()t)iV16>|@Pck<}!(WilETFBTe$h)Y9m;|n?+yRoWh@=5=cFI zCYe+Q)rz!MGTDr=k}^tHJquA4I8FGql3#>PGB`8yY8Z1q+mp$uJ}Xpe3E5d9Zv2fb zL%$9JSnyJ)qbM+BVLrAs+JUU+*f&Ma2nqY(!9%M=9-J~!E$Np`ss&2V_#3{5N&=IX z5ltg8trG<$1110f5CBO;K~zAXU*egp$T4QiDr;vtlV`G`8}=AmSc3|>B>DvITd6t{ zZ*=x;P#rJhS0XQu%>#aM&MTm7L%$8}1&+V$eY^Ur-}Mn)j6v6UN!HGzMr0Jra@5v6 z`@seInSIo%Th-BfC&OR3RMq}=5;cMG>j4<(U*y5dB$_6zQ~em@P9UlyCbFkrcv64y z8~&L7)nEB9^x-!?C}Tn|v@aD1kYlyEY4M2(ah-15dPSG6zoa!J{mjkF`s}lh>9{|U zcUe~dfQ`Z+jwB15yHeEQ|DJ*-Aur>!B`duSUya%6COBQOMGBnMq1UbB40o7v@%T2$ z%v4-pHJLSbExs;W-`{tJ;?Xy1BDx`W%A|e)@*cs+hDmhI7G);Z-I&#vsH1hz^H)cG z?wJ!k{mQAX-{^GnR;Q!m(N0%55nW&F&#PX1jjcl}m@wLGBJIs0$`2SdYA`QSL-pJ% zs}jlxFbY6!LZ>(0Gt-;y-_^7xJ^}9;&jH4gyc~VoD7|tr=ocQorl+o+D&mOlbOY~< zEG=ftaJ|IrNw#XktZqal0IcLp*A^#w@%jzP2el-)Z=znlcA%FIj@7a!)e!s+=WPIA zV9Y9K;6+|ffJ87#l?8AMZB}WiR}L=g(!nKNJ-MWV{)R3czoIJ#&&ym#L36qVj=2E@ z6|RdcuL$NPiP>J7ORm}4B zBp4?(=-|xJ%@mu4vr|AOV$ff-&Z-5$h6;Giq`9Ud?jg_EMwzSrPpbA+qU?DdV#M|= z!~fOh$amu?V6Mg~ORD!dF_wwHfP7^*I76gqYM!izd^kwsoq|BsJAQz`=|nqm#@rz9~nq05%)!4NX)J<{Kv3 zHne);(j&U>%o+WgAN!e&jJkF_=;UaL7&@wIRmGOk~SyFM3Z}`s5x1jveBm=xus7%eoc?Oe5A|AqZVBT$3}c3yfzVk>-BsH?Q3uDyp`f|G01Xn z$8-n#q~~`A?S!??PI}!o+vvi)gLs_Yc&FE$J3)8uO}1d@Hb**79~+CDd8wF2042av zAeh*;8q!Rke(I*4zH*}T=kHP7b8A^L;S+5Xv|$N6kl=-D@(jhDoNx5<;Y}SbPZb(R z@RFW*<%%3ZHzft{G(x@$_9ROU8p&>!W%P3{Zi6*gr^`o|_0V&l(NotR(WR4@bjbF` z_2a8L*&HY~lJ`Xo39MR$rWNC^wx3c0h-O z{4^#N{xtZmhb-&zs%M#PhF{F+ALR@;(RSK2nnd6MSe9{)k?2GCFtt+j4ybeIXfR%;%_8p^ePg%bYx4)QV5Nv z(_O>|d{#KJI&`k~tZRish1#=~vSQ1jqhhY-k(E-hMB*kj_+-*d5$R;k(|;r+8~~#F zCX?LWo0`TGGfCTiqYS(i+GKoJ4@^_yubv91Pd&D5Ag4h|QZdtr9mgjN4OIMan`*T^ zsk%2Uwr4GvP^!-C*~_+9$aJA+<1TxbS9v+q>fS@HO}j-OTiShn6tGM6>t zH-}A|#YWu;xj)GSS^mo4d&+BLY*5A-Ke((d@P3T1)WOC7r zYZbID%Gi;{6UNzaC(9JHGuhVz_dcle`}=CVS7^Ns#v?s*{TV%b=}Fx>dPOJX=$iZ* zS)v@BoDx%0N6c!0kda({ZQ!s455YHj;p9ZwTH;c43(^f#_ou5)i?q-DLaX(HBni*vp-+t|m&XuG=GpF$(UP{BA}RtlBNSC@}@)@BZ%7BL6v#T(bODAE&`Z|R6hz&DM|OOA00{cfBbX@F=l01`yn zfL3n?35_`R89jdE8J(7;T-{HqRTl}7AzBBZdkAA-rmnv7jAumz>dG_^x;B&G$Nu!y)ZW@=@kY%z@-- zk(sIRXAOut`q#;6RTIED)!&aT=4A9))>)l>WDKSH=;>E9X&Y5yk)c*a1xebF@J)=! zuHYBvFOC68CEE1(rbvp!WZ*i}cLT|6YGp?CT*sR2?4V{)rcc#L=rW8+34b(zbs+J2 z{9F6hiKSE@Gp6A~$*F7BBqv1Izv%G}o82?Xxs zROQw&jr)XE4m~p1%zP;_6?}*p^3KVl8=@2#Z>SRr4|-xyCxuCD6=M!4??T2Ai)h20i-HA(I6HN~K4 z!x&Lf^&Sd+PuGy}zLhf#2k9=U@1vx9eNK>7p+3VkQp-f3>U(lIn{z=d(u0 zvI;IFWj(L4%giyH30^~QuYn;8U@F}20KuB-qY^{J4{g(IeOZZZsFRF}Wa3lMf22xG z%JsW*=ej3z|y?kJNh@&G$E6BE>$B>I!_Fd1Ny-oA^uIjJk)tJQy zIAq8*0T(6wQ}{k$e_?DNf>O>ppz0+W5~)yV*x|IUPsC>A7?jbmn9)|iW}-Gu>79<3eY7egB-d4m+uA&tip*QLfRn@aA`k(tA~?R|%RXPKBT zvezFfrooRIQ*ND>Y0ac@sy()0TrRqGyw>SDsb{ZdsMwIx(NV8U^t*2-+^=fAmb#X_Jy>h6Bo_R$#S4E>5 z^eu8WDEeN(XBiVs79Y+A>5Kl}rMx=}BX?i(4{mzRZE4st_^c$Vlx~w*0u%+;h>16)01O47j^4or45tCW(c};xo49+X+AHy=gwUvmabBUFp|u02ZWbMR!a@sHfp(E zLt5wbtDkum2dvdV=0aA&6@!Xog7A~73`gG{04xZ`i-(8WaCab?y3I-z2%(9RnT}a* zjzf|alk-&ZuJvHpV<`b@Ctj!Mv%lFsc0{g;3G-TED0K_$>M*gh&dCl62<9X}?5 zJSd@S;pic-0U$PQ3z=kLHJ~c59wgy<&4e)k0mFYXh%f=lUI{-8BzEoBk&v}thkq(L zY`P75V#_Vr)cfAkHz7lkLvJ(N>NP+ni4g+?)8aqF!h%n$T!vC0;0Cc8zK^kyicz&} zApcRGK2^5KDfl2H^W)V(b`JRt;>k>y3BZml*)v36Nv~GzJ-QsT5jar77G#>d+BjiS z5PB62#IKfREy@4@5CBO;K~$Jim0yrmHLu_048QOVG|%@ST;f(^m>8#HoStH$R+E{Y zE2^InTdGe+VxCB9VN_(CdhYu{-g{NMg7cz_svqhYb(^fKJ~eKavW-il_4}S0W=gJU3zvFsfqFmvJF^HTVwKs{H`G)N>VRumrXOuM6 z$+1y6o5c7@Q~FQ!%B>S^aBd-H8;**h1P&vZ-kr|ndJjkSx_D-)DP$|SATn7s(NTSG z^od8F({DfhqFlS9hRJjUW5qk{iqO;)-N{G1i{aHORog_tc-#4%a>t!J7{aaB_~Tw< zyZ>La1gOXClq}$gpy{;TlF}Li>><`ZN8MTkJ@LY*&pqDj(dQTJ@2()oq{Xt+9eaZ= z?Cz*bsExDR(8@W_v#b+ly#-ZfVM#UhPOq#oj<_mk4qJOIvmo$ZJ%D3%p;MMAnFQMb zz(uA7RbOFm4i6F7!T>d?2dy_FM@J=etVF<1HmCZ`^H1o2Wt9cfHd#e;WY7!wNj(AF zj2S>h#4#{G!3C89aA=#&OC(I-FzZQ9ar(IT257GDZGbW6FNZWWt{N{=@0=@z`y)#;7S-r{kYD2*K44H{1 zO$f*tHAcSmleK~LS5z3WZRCh!@Z2ZMI;#4fSY$}D@ zgPa-iM$Kl8RxEutX`>FgmAJ*TfuycUVDw86TSjj6ouk{DWbLpuFn(hJG9O96UR#pJ zM;j`#DlcJAg0O1a0dnY4rxD$XdCm-v?BX%dikc7=kX0+io5__Z&!0&%*KWIC#h~TD)?^P(R@#8= zg8p;xckj7#%J|4=zai(37FpL1Osl>Tl@NYiD--V*4|+X$qnFq>2TI$*VvWCC<1}OY zc4t2-v8C~rd-k={6h-2b;Ji!hHUI2Guc-EK+PPMYH3A4HqRY>Su)?*gJ_So{K$F;N z#OB-2Ho9$}r0H)ty^hc8YP=pd*7wOu_TZvGY=c%*07CgiA9T>o^~kfQ`mN6|_59VS zm#=MPZpO0Dw7cWf_bc_hth?ik$&Q=0o2Tsm zG~2hS6Dp$&c??5hS;N?Coa`Ei!*mS!A08iXoozU%Rthx{Ym#J~Hf1820Ll&HKK$a- zI$kdn+Ll1@)J!WTml42^W7dXc--0FglI?{7fTNj004UxoaCm^OyigjiD5WJJOdKP@ z2jU)`2y_9IgB@#k|3p9V?cb;e-!M^!?YDRt@#4)y6~ZL(=q-Kfv19$pCokzI|J|ed z)lXf~uYBwkee&}Mdi?o~o@eE;=o^R%@j1&wx&|Pp6OTosYNDD?%35i%JKfqNz^QDC zO``_;0%S0qOw~iAu1M#0&&V0^lbBRSg(5l9!iB&c$;<@HP-%?B!-ets*n$jm7qYl_Z$ zY-@Sv<+vxtK%$&;d}kzN8Dn_a_PcjZJd`_=3%Bvv;)Ms!Uw*I zs*{PCi61%dv?2D3#i=@;*WVzizuPRUHucI?mK=$n(9df+pD8(0hYg8XMD(wI@!qS6 zLG?Vs;E9Q$D638gi6k}z24Yc&Dv9eu2<2O*ECKZS_;nR++HLoYTN0 zRH`g&TdfBfRsXtA^3FlJMH6!hLsa0sNW{2Mq!ywZdJ_v1T|3FTbddGbHcxMo`}Q_No5X_x(=Yf2ZjizxKoW_HX_s{fTe={raA7{T6-4H+{W+ z=lkEQFMH=Z^uc$(TW`4IZiVEOAmkC9MzELAqr%XK0aK18s&xm^w%)j2sjw~KJosWl zF$$T)>U0>F%4I1`QLZwQxOQ^bK_?l$aY)M}3RWj^n8Zr`SeH-NI^+fOIV#>VdYw?U zZz!!#mIHB0>R2^d9yKNQ4Swyw=J9GHGq09=wJ52vuQ`9?@EGDvx_xJ&i>!L9f7hka zEw)KM`i09{ccZ2gDt&mU>gAki(gqcE9N>lfW~NO3Crews&rQ9~?qsmAF0u&*=rw=b zYi>!y5E^Tz(G1V5c=LJ88(f?0>6ks7hoA2BYroy=^Dpga(Ib{BaHJgRaOmDF=(aO6 zNIm25MsK`luDi}ejW}IFGLjCzY1#(u%%=d|%Rq{e#K}$c+_h84J*mau42UKZr#Uj) z)~i-1^*;i$NktnHrU2%e0AsBxB*sXz=}E34q4!JIuj_@At5moS&@OpaWX|!-X+r`e z+#HyVnT9l?SBDOsL^l8-J|?F;u4Uti%6KZ34L};z1Y@S%S8#HmJ#ET!- z!RlDQ`MFE_ncups|M90E)8GE7hxMQT%xCp?eu?W}`ka3Blh5nN9=WL>`sh>o8y|gI zf92mkr62e|KBxcYUw=&h!&~dj2^qP(Uncs3A%VlcM|8Cxm|fk+@&aQ zQ3Dm#itU}6+-t~i=tr5MwK+|i1|J6XOhn1~t-TT3y~BQ)28gB5wh|1u!_xw2MMhu8 zjQ|-5{|xvx#;CS!Rqr{TdkCtie!LvO522O!fg4Vav++CxC&bG+e25JNS(UtNzlsIi z%`*=Xjan_&%Cs@s*5_5e(Y}xXuO3ASJG^;h5+Q6e=QU;ACNye&qsWmN&_dKm!VDb8 zr%TnBthvgz!g#1?LWW^)QbX)3EPJpZXWPjpaU=SuCbruxiKE4d9JaJEs``2)`Dw^c z(Ex#V^i5Q>=7bF-DQAJ_%=obhh&63}KpU(8H*wpyV!6sHjF*T-!qyS|m8u`QIF6Hk zr4uUA4)#6VoaktMKq0u+P;)MLX*X&K7N59$pkMj?3;LBuuIk5s^$GpR&woyT`zJo7 zzx5NJ(ck!)PwNMN`XT+`Pkli@{C|F4fA8OXK|lU$&+F%Y?UFwI)ItwG^`dsql80wr z(#g#$aydi5oq9;Er*Ij`{me10dSxaEXF&x&t3*ZH!M0WotPHG#`sFQo+el?e-W;i< zQx)`g!Us$wmOOo0@wddaDE74d6lMHX{ZkLVXn$uo7V}m^YO_*g3L)eDKP6g z>(w*`M~uo;M=p52Pel~e0Nys50y<<=cFfr4_U1ZfJ^GKNKd5l9qsPP<7H+q-xiQ zLpSV63mZqO!BSg5Y}?;yW>V{E=e*bH=|U%LQ)PVOXd6K_k5mkcIn0?hRH_LX^;vC` zAdv#mc^~xZr^w@T=N++VxMlc?6QIR)+sW{YH>B!+bj#v{@3YX1af^%!WvU5@#yqD1 z`2%B`Il0=dm=yeWrBvhB^*#5U%$#CuG(aM)dDYm}*dI_CA1xP5CR4W2R$#J@SO{fS zVt|6Yrjon&ay}|Ht#%;-VYBm21$zdR@6dq;s>GeKTYUO{qjk4d^+7$jh1?>K?;N-& z$napO9=S-~d2khJlQ~jtF5_<=2~N;R3Xgo>P-u+gjp1`2y%K&JhMvA2P)IZIHRqDu zcJ0KOns!guhDINM{D%IwUwlG8`ZJH}?}C#b{nTas&0l|9|Mt0?dahQLzGZ75>FjK( z-Dy+<$=t}&Q|uT1+RMlK-=DdqAO5W;^xysb^ZKD5e?mX|&{h5BQxEe(>MKzBj<`Ah~k3$Jzx*_s!mUy*J^!5>&?p=hRYhq1FY+5gOv`;Yu+Nq zxg^KxI8=Yvdt4hbGuNdQ`i-(;ir#Z}6nY66Qcup+?X7~W(Mj2jy8GfxN-KOOIg9f6 zs>RL%)<=$vyv#Uj)Nf*gyislN+T-P4=J|Rv==!ajx^2JFxw+E`1<}Xx%K@v9Lcaw? z&xQ)zxj@_v-NfM;FDlVLv6|qO57$|Vb`RV$)x`^qI`*~KT`Y>Y;C1}C*U^?Gww19< zGJ|B9u^)coRR8u92Rc|dHGU$Y;AVA7fB+Lt3xIUecwL}c4&_uWRo}}uPxRuAQ|;`S z-v8iP#e%~j#C8&7kaz|B>0&{mOk_UFtf=An$wn+aoC3?DM6%##WhxaO%UNIAZs-~$t>OWMlixbIsK=2#N!L;lI{NdYfG*LQCeG>1 zbf1~fNi{v!QkDP!5CBO;K~&2m751l6TF7|j<5|h}xQD!U)QPS2fByET^d0~6f3N@W zm;Nd{eAp$+uU^b zt^-ISQM`WRg+u-D&p)KU_mf}HFMj44Z_w8o<5Z?h0!bdfCm?Lzt5%SMvPLt;+US(3 zX3es>PDX2H{#(l>WLFfMsK8;^)HIE%A0p)9_?}9Z^Fc8*3Nd1@a7C6GBjOXwCNW;2 zU9Ea5nH0w4u|$85T{28!d#Sjh#GgWYI&I~Y8ce=aF&#RMW0q&UEop6^IogTpG*^g$ zgHe}n-Pn?iL&uRmeK)9>QwDFRENK&@D)a%2iuj>E-`n5U8ICG@5+|(K=JhejROEeB zKO`#mjWT`5F{vj15?M#91>0;%M-&qiFub?3ODw!%8`Vr&{O#qPQ?(4}3|`eQ%$m-Nnm`fd7e{?C7`kG=GHEy_Yu z%M$u}Cm9MAboA()`V-#sT-UgHMZ+o8_t7OCo*ZboyakpA1#GS%G0NDZ*+@ z#NCweXF|swpJf*{5Zh#qeDDx_ubo*dk5Z9ir)A1Z(*AU&d)SADtMwaCEO}u%YA0l| zQJ_)u1E1@4r*)ShUZ;#-MQpb})hSLIu;&f8PuWszw4i7>9UWDDR@$JjUi$}8uf6eN z*%P)VFBef?c=)=Wcx9uR1LOp2fZ!Y;3c;%*U_Be-=7Ss#agb77y_`8W*TviR6`A#` z(}sSnx^HC6p!N5YJ&v2iMlA%9=obNSj6rG0IMeIq;Xx9d9bm1X=!d&m$EU$}Nd1#+&lX(nh9;t%M&W*>bZ!FvdQ&0d!AtUezglv&(S z(*)K2ssaI`5vF3JI!HR>lIZs3dGYzsi;wHS`WOFDfB46KP+wpx7H{^zTmhz1S>=vLGhpo)|7f{V2l*)%5)tG1Da566re#x&S8yI&7CD~Pqy8pC0t<=cuVdhF zRl*ZVW#%;UEF-U0D~>Vixk$9ZM(%H)JK^E1s{5L%y;jNtIu+Uv44#n_*>0e@vUiHp zDdgHCdn7Tyin5X`3dDR{EkaO*aYy{xGZ`ix%dgQ0-Z>qBhz}Mu(V^`MtiC=wO-~TUuN&nR^{H7jy z>56Wn2)UDq{QS(TFxiY z+^8l7ec9gI^saJ;e(=$Mu8;hyKdHa^um5L#_R15|6vc^?h_grM5sU}?5y@?zC*Zzi zJ7TWQl6>4$eBiTUa$t|qbh19s%STV@bI*N(ZIzGdv(J2j6~J%m(@%a%PrdStZZ2== zusdWDUI{46;aKmyT1Yfg)NI&d*}yZExP?6h#B4ZsRL`}F-fc&9@W24e*2#H9LbbmT zoh)ep$L(|?A0qA2Hf}XH8VaI2_W~=79ewhN6Fqb_X~(J`lOpyZhPlm@%&TIj0e^<9 zo{3xpx@=?E>E7FSw96j$3cp^y*=fc03ES5?WF^G#uldKl=9V-ZLSO2((-Tjf=&5T( z7v^)#<~%T|qblR7>QCZI&IK)bX<#UH^>D2xuXK9m#RHysMU1;~FzU*2ox!5c0lMOh zWl&+pE5c`Tbq_mjR0wLZO2$0LLMu~Q3F?@=)?LvOh3Kk`a}t1dmgR9I_{1rrGBfAx z`{#B2Xrb$io;^xr(E)#v#!{wF+Xng|pIN#Pz(VO(&m$n-i@T6#Ccy^TcJdq0N%Bk- zmJ9ZD8e*rP{p=(9w*Tcv_4l88NMCm6J9XhqBb#Q81WZX_$keZsx<%3E)KpNw@t`GP z%{$e^8PLTAuPN34eBJ`acH3tJ;FUCbh^34H$SZ(_+NiR zKl@uR>LwLYRdRy91!C%%;e&6~8uqfvDS*8K8#icgXNE0K&N&jQ5&5$vx>FMO#^I61 zY9p0TMvj^BJwcoUeiL!9ZTsqb@d++$K^vK54dHbH^qgxY*x_5}gK(mpc?E)Oh98K+ zNTSv6;*;=6PunFEWo^4*!Z;?&yOOjVHmbjay@6;Z?X;SxQ})|e90noxOsIvJCH%Mn zcL~|urjwUxf4-|}Y!ySJ*fbiDHToRHXqMp^VK z=-m>Bx{vOP)Z(L(qXM$p#+2DUeC2qfUm(VR;-CDo{^rLX(WXSbXYYb`npRbxI(|>p z-cgO8fcf>wN+Yq{7CzWNXWW-f%`%4-hYeU zJA0%4hbMnR-}b-$RsD;P{+tfFQ~Ab$35p?ye&8*oEKkl97m*Y7<_GWB#oI3^l|f=X zX*PnLfxKPm5=G3-%|i9Jsje(;=<@Qi9=r9F9=iUpe)*AK(yu@9QGMq5hp1pLL)06g zq0UZsG<7q!!FJTc2L-(c)3(B{`d&L(YZ#C8z+JnI`Q^^JiW3pq|p9=~wwSO-*R>oN*VVboIlI{`BC zyzJ?#5ks-;{hswv!PV`TH{g?GEKk-jlDoynMor|jfM+JFgd3@5CCbMc^lwp(e5pQ}`qHSaQjuDc$Mf7cX6`{Yyl z+yDDR`t;K$DsBpilWfy{S(wVI?dvtNy&95#9FqoCCzmi9HlkfnsHIpcZU87}nD3-mTRjEf_K|dF`VWtiO>Uoh<`%lShfFp-0 z8@(1J;TrsOv>BKL%$Xc|&8d1@7q&ZXC17S8yFPoy@m3jrgo%?4IWtESh;L*Nu)&jc{8lifxvvXs$X z_^Vds=A_tq&38T6Gqsb6ma9RP&{Ui{wmA|c#MjqE3Hb)cW zx8Qycyn4=sHZF$RSiwb2rn_<;fpMy1i1Z6DKc$a8{%L*crAKxB;Hvf}jV|1NQ5z<1 zZ%!R;wiBTw!(Y*vMr^romhGK8C(4%PCo}c*c<3GfQcfWMJY~=tE9yJ7XLUV zfJdIaq-K6jBe7hQGaJsQZ10vthf&I0Z#G$n%N|b2;CEDDtT)|1*M&1lEqt;+515TV z*LjO|(l0Cdq}TCrucNI#``IVrr5i)zJ%FU6na4rH7O08jbgq$wMitZ#+~)N5H?}&z zmvwRPtS;{F>bBjiJ)E%zD9-ZIt~xY3Q`%qUTmv=hxD(1 z(CL6@PDV8=p#mrTYHjttPmpvRER1vjKm~HMN&5~rbj9^ z_l#SKehpC!$POW>j+430kc5Gmk#mpS8hek7wr$n0l`&#xvBJLksAINwk{Q=Yr}c#% z`kz3M8$giIpiTmd^^(b@D8`2PG>Z5pqGJFb$Fx0Jo~Q{yjSoT;hf{6qyr-Y3R*cn0 zpp7KVI$kW9n5uMB{S^EYV<7%nzRE#v^+`v3H%yA&MOIg0q7;4}w80Nuzfi~OaikzP z;YIq#Upmm=`RRxC_kZy@m0@4+Is?{R!-Uv_wVo7Xt;m7(a?m8~C`0IZDP=00fyj5>^yL@dt-ts5 zPwPMar~fV66OXIGMw|noZSd@d$zUnxgj`}x^;|Ju$0?;q9Q(U_JjX9wFNo8IZMeiEjd5vwHNA>jOm$k!t0Ea#Ge^F(`A;S*{qn~Xtu%>9+Z#w8AQMX{9*91H{^q6qJj{oorFXPUoUjeS<{=}Fkxl8B$i2HyxG)&DW~-|w~CmM zQlFt|z4niL?M($}h0Ynpm(h`uQZZuh#Xp~o7nx_7i-t*R(&dz%|lFZ}4NyHIR zx@&K$bJ1xx1~nG2W1@_+olCmN(qeBfYBF&O04tf3xgbHtH3%6eCXPbjCctZ8PpC14y56(Jb9hZZ_0UuL(a$}v_uuv=wo?YxYIX`?`j|DH zM(ywI$eUDHGCOP;w#nD2;E#S-Gf}K~8038e;R+qHLWrnYI5G*?$zi^Wrj#?}T>wPU zDSL!H_5>0>G!y(FHEpk+H_W0kn#W$}s1pCd=U&#|_{oR#3!i*R$17wz$Jj|Cg#vmt z4gQCW#xyF|yJWO6{)EbDh973L8FwWgyqxz69F^1u2(HE=GPe1uU{K+FyjlyMRwekt z7~a=@4!;`7VDjk8H=dBBPS~6j9rklL@31+k$4BNxA1Q(5TKRhKxB7|C4mwR@qXJnM ze6pR)nJ6|?R444EFPVr9{~B#8IU9W?Qx6H%O1}74uSY8ykYDW{uzy2^TM4_P;vVR~ zqH6NaYljK4P7c+sp{_Q#FhhBXcn#>4WmpuPljnXD8ZZ~-oNe#Vrmb4^^%xaG)QH|| zCd3*Ak8YFdL)R+O57k5`8L`_~&}W`q=zsdD=k!1P?3226JVF99wXJv$(aTeH8@f6< zMc=6ExY?i&f=Bjfd4zvW9b?s8X(69L9IGB4ed}cBDSgvuGV-BfvPkGxkmwb7W)`(N z>FSFu<=&`5la_~_PHuHtfa!O-2lbiDSMI@%q}w2&xX_)CHi5hyd$ z_78q2y78C$asaARQ3r z~&*5I_Y!Ip6JmR*ZRVT_h)9Ml8U6J%UJN$OKWm6jpvjjW;13OGV$ z4KrZ@%@g))3e^`Vk_QL_V849znr>FWc$0TdBZS{2ucI;OmmhsafBl!gAm;|DO_diF zW209;=x5TJZ63ovO*5r0S2p2Z;+igTwoRZOK#hGtPT>9s(24@UslV;e@WNt9ojkxQ zfUTgMku;v*^c?|UsQrnrBcP2(78S9XX#z8FOO>40+^fe46T&ez9zx6;s_#(k$9Fz> z88U@TkpN?rhoT-|c)&eU`DRHGQnF$SF356Grkzv(IryS)j9yjGO1_T%&T3mPiW-tT z%A3=K_)VEiD#@;>^y|GD1Xs`}eoJbN&nf8eO#Y?>X!CwswTD?m_!oURJS|?$%M&744@h- zv}BxCh)@Dg@R2I6GMK3P=Bf{|PS{=Z*h{>?>*W|##K3z~Y>OsTeYLnK;0ya969_DXpsr4|_@h&( z;{+Zn8INwFC6KIJr-wI-u3lS1wj0gx{|DZFm)?9|quoiC@qWyc*A7|ij|1rYIhhj)j^8dN zwq4aX`Nyl3PLt3v<3wqP{lcAg5brlttEDw$d%^~Ot)`NYPhj?UceUV!QLVCC5}`nn z8ITk=swLZ(o%C~$9BE3j5nGmuy*Ah$y;lW`1KEj6y#pkDO%@V%8*nCzj@gwwQ~mBF zCTx$dOnpTb=~96J(=)D-_`ydc zM~J70Y}LEkx&HaXm-Hh)^N21p0R~Ho+sV$OM$)Ve$nbLBDVvk0e;|Q+_K!mh^3Ev% z+yU7u-j1BIBwayd>wd+A*E2y5OpqHAV9*kOqb9ndAJyFohI|qr=Rw|kRhQJN zb;B6-w>454RS9S$LF;le;uTzt9AQHynY_%r5)P{Vb@<9T&&p+^ZDow@O$fZyX*D1# zQMqBFa7>M=f^XDd6Iv)>kHb#m?9s){d9~~dzuTgdUf-YUH!~_VPQ9M-Q8Z}4P)|(QQCc{aLZ;XF*+Uv)D z?hE>%kG`n8C;OUBBECxMDK^T$_@k3~tVb3qXzaiSqBF`S3KZv}oS8EC3x&4a&%_~c zueMFRQNzU=x)ImlbBJ#M|6^ye;^}7)Y_I5o8k3qzHWjeQSsbsoTOY3C65`*0*$r4a z7iK!#IQ`w9{z)A!PU+jJ=BZPZuy5>7RR7nU|bo<%cz}T?j25f!f8}^zrWVdAh+M`Wl zCNYx-=cFPj^A~!Uvu)+p(dIEGk7syW9H=q|W^K>CNjue=igM^TEQ{ylTR;b|z4@LJ zGlu~J2Gxuiy`fS`OfU)39U-t0z?kzg#ssILqv~?C+a%>}y9`i-k6N_=c&N*+Q5peN zJyy##z^4ts0KeO|BmFv_SMu7hzxPg->YsS&mcIYTKdE1P;z;IO?V|G@73f|Q)j|-$ zq4fpInwKdnmiPs!Ax70>V9b;g1@s0g|oTLymrdBQ_1hJ!;KXU)9BsVPILHW-u9dB>x~mu$hU!fQ=0=bse{CEXpZ!aK@IZ zlNmmPi5*JqP>nRiF%v(}q)^b?>bNe*JEx3JYkaae=^^K$`kO{aH&zr4{g;n?O#kjP56iP+M0GjZ)Uh(>fUO2$CooxkodH9O ze`d%X$itpVAT(~k}za-c{jtJ~e)?xw)~H8ku=2$jwa7MX5*`Ryger_0?Z~R?@D1;}b{vnNQ!;7p_Pr zLsXmuO?Wxj(i|LVs8uEX3Y+cb3W-;=`r=z=heHBKeO4vim)8I` zD-s|BiEYd}yA!C6o#1(vm_<$NRcEeS2WylAv?NtW#jscp0@vzt&_ESXK@%C@LDqMk z-_^V^!jo0fs7&%EjH@rdR*<-tsS?dgRG`23+(K_=FWsYqAooJMN*b}PGj3!lvs5Bb z_1AJ1B+<}k0vhoXlZO+!N9^roE@-wllLfC31K%CK7&Q*)fPXT{CdS1~`f@`~BYT0R zP6|{T4uG3^dGCn7S0(hGu?GB`vs6gPX&W`A3JdIs-+5+N*H?3W-;Y0`U;4yLIvzGu zz@1iXQ#j|;#HfheVvOZ%t4k%bN-jN#(E`c|gfnSc&89PKOn`3`d5UkM=99^`{~BN) zElxC`#JrhE5CRsdCfErT_VMym8)5>0)bO0qRO3FI%;X(@BD)htCng9?R-GApFg8*$ zZkPZ&h&}Vnp<~r0c(33*_GTg>Rnkq6aR(_@B9=@-Uck1DPIdCg_@*{)W7M|4nX2vH zoQ%UE&pW4WJGvXhR>@&tGOhR@>7VK2y^|w;b(}RXQfvE2^)x~d)eqGN#W@Kb>p#Nk z!BuvF;v;L*FR6e#4cz$iupwGO+q_NEPx%@G|B8*)uc9HxSPx zLM@}XSuctfM}rDOgN3>P01yC4L_t)TTO3l4X3-|{tZehciLpaa%S3(3M0?l%-TG_4 z`ty4B>IqvN6J;eiBmUONEJ_ym?$rd-dT}5};d1wFcOcWFbJhxOSy1^`jMR5hqJM_S zH~6wfb_7SOe$a{-Y&aMEe?oy?+5BkbC?dzM~f0u6Gqc5)8Cr2=mzQRTWG7Wk;rIB2PBhIyr-hSVX?!G;1jZZ6g zot>EOoJ_S#A@&llEXRAa*g6&PHll07j^cuqjPZKyANSgu(lQWI?~7FA zoNWOen?|Rx)BEn;r?MUOb05E@&s`2`aN7Kf;IB*AQG+s->l!jx@P;^z42_Hs9kRU4 zITCtgnLJ9|_s#?21jvfYSd_bk-gTeTSAJlo+issKzzX<_1RphF57@h+HE*3)%MCIT z&m#d}X}LM&`@lZXM#WkkvmbpE&>cX!r&L1b6V5g5A|p_aSg3jx732+-s4Z*C{+suv zjzvXlO_)eo(XO?Nmutd}ylbtF~=bC%x)dv%-@M&P+9qEt6J^ z9^Ymee;jt#b}>fU_-%4}9~nD|yy8GN=qB)d#>5n{t;$a%f03{`fwN957G%y;b$5nj z?9P%l5KqT;z%nJa+eW_kYy}-CHdzjtUwCGvzx7j(=#|lQj(gp5M7IDjWa69fi_p@` z`zW)ZAvui@_(1y+=k(wwCuA{Z#!uCjOzgxn6^ER{)-XoLH$#JJ8dX8?Y#wESweAHLJ^`buf zrd{2;H>z%LMyllV`;F$ISBs9J8ML$Ov_I?h*1Jq!_0BVT<89L3q|@yer07S*CP?_L z!!L=-@4}=ZSEPrYInZ;j^jf1siA8mVl&IK8^y<;@_58Tk)3{CuO>o6QUL#A8j)WU= z%sxrJzyZJg`IQpOjGbAhf#uO=0ccqUcugUI7F$|nvMK{EiVbCgQB;&z#sq{nO_cYZ z`&o%9(7C8#>@;zwdi%YN-udQTo!wol>yAl^PMN+<8~D3$EjjuxR$0rXbVAUkv$?XL zYRTT6i!;UvT0;nh#JafK>h|46cVF1moUse`6m;u{k?~V4+Ssybw8I1hpGoG}E8pl- zmydMDOZ8WLDuJ7CG<2CtXcSwrR3A059AHe!5SS+sVhpNe*F@~A-|^9G-XgMSq+AIE zS@lyy;G=31780ulG{X>yqdv~5$~4tyiSnxI>kMV+SI2TL;FnR(iKXA5+Ajl&1=}(G zChO$1Q^!`2LZ>&lJ^koUJ*<~r9yFQGA(wIM$HG_*F%XGf2`F)QgO2sunIn!)m3#up zo>3~3%pRL^jewa@UV%7Fky)3`^?yZn0s*X$Q3<-G+6wrgPP!pPRSv0DW|omCBT|Yy z{xFOv*%ZJ)Ny0BJ*Fm1|;(pFWBmOY@dGtjM1H4}&GtAVQB%(nMa z?d$L*Sgy8Z5yR?J6$KY$Mmyxu6%3B5pOHzf;1`F|CI?Q6`b57XCG+Udckq*H8^%V6 z2F4jNV8hs`t&$}}XyJbHyL#f4Q9tw(PpGA0J>Rs*!Df#RG>mOj&5;FI&UDT!!AI=N z$QX%}Sx`SD4a7LnzM#7gCd^{OOA=|);@Jo-(P;EI8xW9%OO zIIh3R#PymL`U&rK*6>ikf6fJU*6PM8u&ru(`Bu=fOvI}uopeR-xN}b*zJH?ocL$1t zm2Qy}r*MMtQgfGDTBlk=mtTnw}*9Y&tp7yO|xrAtHA;yknjRSlQ zK)8CcRGdZyX7`DosF`_!(5wDysRD2^1XzR7@IrCSIDVZ0S5{7RMuztV+5F&OM{qK? zSIpk5y!6hup3{BzO=V>x?<4!vK@CanENUcZBLNfrfSw9q7}=+*FBOkYM%_9}x^zVej`#M$3KmG+Ox0@rqu!ez}HbTNCXI_;+o2F4cGWvAY7HPYk(GmwQuQVq0 zU8kPp>{$xdN`19lDd#N1j^qvK7QtzXF=)Z;*|FD~P3qBeLq$J82urH+TA3F!F_cVB z-UUV9$eZUIcbwyUy>_ZjiTKwczY_9TNzt4F@e`k(F~%)fW_cfIUKl^hAtPgFW{|3H z&-fz=G@^qXlLT=|#6al55A990az}vXOfD1r;jNBuG~rz3(J%}7JnQK%o}2UAebdBP zr@W`BW7df;fc1uZO|u7qB>n4u_k^Chxz@P$%A2t|g;;p?Or(Y#m?crYW*nBmM+1p^ zbf1K%CIryvaLQcV78Cy-l=K+EqxGP@6 zf)6bTO3}qV)7j}L&z44nvpVv6Me%vMaXMkL=vWDf&iCKFqX|{FcTTkToCbRM=CCO zGkZxRO9XGG-8SmtnYmh@1y0rR2D)SR?3mqCA0&d9nE5v>u};@TYZ5TFjaJBCQq}iF z`^K$P9h?rja=Ow2f^o&P*G_bPKG%Kc&+CmBZ`bWRyJE)(odz`|1KR5nFL6X9*2Ae* z^f!Vl0F@xF;9J!oRB(-;Ds#_)OwST)B=Iu(Rl?|awgFTfuB=Ja#DL#8%rgW|LYHlS zl8U>kF%9NaFg7#scvqBIRTSh7*q!Try45T&lCerbB>?HvIL{H~@LLQ9xjC+#0x_t+ ziLgi2_Q=a8`o}-{qz;y}1IQTy={7xyRpU`()XOo3cTQ{MAFfwgLw;gR=e=BLWG+hR zUH7hQe}+;ak-)KXmTJXTleu-KHUXt{M ztvSaSWh=`}+OFb;jYcY%qRe^ZNUE|Y?NovO(`Kev^;1z~?2P^$6Ul}N#jN@?(;oa$ zT_*JBv5YwMv|qB1zG2en^MG_G7lNEa2a_u4R=qOqyo`8^YI#2As1tLM(}sAT;-}-) zQWI<=6e?h?jV2cdU8iXHk)L=-pTE4&nm*TV;a;pWdGCm0R>DCAK?MZ^@=aL=HZr($(l2 zVG2{Ku)M!9h&2YJ?guLPp{VblhP9peBS7Rq6xKlhRQ^S#%jPj%(sK$mXZ)Delf0tg$DxsYTPJUt0JQqClf$!u3vlfX

F}3J}QhMX8L;G@UpXK?|Z=CBhS3N3btqZI`fbb-$|T0u5(^JWZ%&qQB>`j@uiV3 zb+S6w2L0q09@5YJ%5z$eQGW2iFsQ=^Xs8Y%Snwid9J9cygu)&>9lF*VBUMJRq=EJW zKAKD#?d)JbT5f6a_b0~h7hf*>bVm-U>h>V{vu4_mp_?=ePV%}N_|K}k81xsZ%P*``4*b}iY;85nQin$eL}6hp%V z&m>lr@mUk}JYBZ>$zOe1U%0fUWQP-kmoJ4db#`E5jev&tR+Ku#TUJTft^JBEP7P7r;Y}QyM!Z(6f)=J*k*>V#Pq`cj&47HK?!26SZbZul!Q%SywE

7F# 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 t^}zoE00960e%+>{00006Nkl, 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 { @@ -37,6 +42,7 @@ pub struct App { pub main_state: crate::views::main::MainState, pub add_popup_state: Option, pub delete_popup_state: Option, + pub rename_popup_state: Option, } impl App { @@ -55,6 +61,7 @@ impl App { app_focus: AppFocus::Main, add_popup_state: None, delete_popup_state: None, + rename_popup_state: None, main_state: MainState::new(), }; @@ -78,6 +85,7 @@ impl App { 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), } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ef0c554 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,393 @@ +use anyhow::Context; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +use crate::utils::Preset; + +pub const DEFAULT_FOLDER: &str = "src/assets/icons"; +pub const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = + "export { default as Icon%name% } from './%icon%%ext%';"; + +const PRESET_VALUES: [&str; 6] = ["", "react", "svelte", "solid", "vue", "emptysvg"]; + +#[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: Option, + 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()) + }) + .and_then(|value| { + if value.trim().is_empty() { + None + } else { + Some(value) + } + }); + + 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, + &[ + "folder", + "preset", + "output_line_template", + "svg_view_cmd", + "svg_viewer_cmd", + ], + path, + warnings, + ); + + let folder = read_string_field(&object, path, "folder", false)?; + + let preset = read_string_field(&object, path, "preset", true)?; + if let Some(value) = preset.as_deref() { + if !PRESET_VALUES.contains(&value) { + anyhow::bail!( + "Invalid config at {}: key 'preset' must be one of [{}], got '{}'.", + path.display(), + PRESET_VALUES.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, &["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 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")); + } +} diff --git a/src/main.rs b/src/main.rs index 4e34de2..bb4ff26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ mod app_state; +mod config; mod form_input; mod iconify; mod tui; mod utils; +mod viewer; mod views; use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse}; @@ -46,11 +48,8 @@ struct CliArgs { /// 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, - default_value = "export { default as Icon%name% } from './%icon%%ext%';" - )] - output_line_template: String, + #[arg(long)] + output_line_template: Option, } #[derive(Debug, Subcommand)] @@ -708,7 +707,10 @@ 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 @@ -867,17 +869,27 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).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: args - .folder - .unwrap_or_else(|| PathBuf::from("src/assets/icons")) - .display() - .to_string(), - preset: match args.preset { - Some(p) => Some(format!("{:?}", p)), - None => None, - }, - template: Some(args.output_line_template), + 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 } diff --git a/src/tui.rs b/src/tui.rs index fdb4e6a..4b2e3c4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,7 +3,7 @@ use crate::{ views::main::render_main_view, }; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -66,6 +66,7 @@ fn ui(f: &mut Frame, app: &mut App) { 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), _ => {} } diff --git a/src/utils.rs b/src/utils.rs index 1b79907..3bc01de 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use clap::ValueEnum; -use ratatui::layout::{Constraint, Rect}; +use ratatui::layout::Rect; use reqwest::Url; use crate::iconify::IconifyClient; @@ -524,9 +524,146 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { Ok(()) } +fn normalize_icon_relative_path(value: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .replace('\\', "/") + .trim_start_matches("./") + .to_string() +} + +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]; + + if normalize_icon_relative_path(matched_path) == current_relative_path { + let with_dot_prefix = matched_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, + 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() { @@ -569,4 +706,76 @@ mod tests { Some(("ArrowLeft".to_string(), "mdi:arrow-left".to_string())) ); } + + #[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" + ); + } } diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..49c999e --- /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]; + return 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")) +} + +fn open_url_in_browser(url: &str) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + return 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/help_popup.rs b/src/views/help_popup.rs index 9316f38..5fe4ea4 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -3,6 +3,7 @@ use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use tui_textarea::{Input, Key}; @@ -22,7 +23,7 @@ impl App { } pub fn render_help_popup(f: &mut Frame, app: &App) { - let area = popup_area(f.area(), 62, 12); + let area = popup_area(f.area(), 72, 17); f.render_widget(ratatui::widgets::Clear, area); let layout = ratatui::layout::Layout::default() @@ -30,6 +31,8 @@ pub fn render_help_popup(f: &mut Frame, app: &App) { .margin(2) .constraints([ Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), @@ -45,27 +48,61 @@ pub fn render_help_popup(f: &mut Frame, app: &App) { .title_alignment(Alignment::Center); f.render_widget(title, area); + let config_lines = vec![ + Line::from(vec![ + Span::styled( + "• ", + Style::default().fg(if app.config.global_config_loaded { + Color::Green + } else { + Color::DarkGray + }), + ), + Span::styled("Global Config", Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled( + "• ", + Style::default().fg(if app.config.project_config_loaded { + Color::Green + } else { + Color::DarkGray + }), + ), + Span::styled("Project Config", Style::default().fg(Color::White)), + ]), + ]; + let config_status = Paragraph::new(config_lines).alignment(Alignment::Left); + f.render_widget(config_status, layout[0]); + let status = Paragraph::new(format!( - "Folder: {}\nPreset: {}", + " Folder: {}\n✦ Preset: {}\n[O] Viewer source: {}", app.config.folder, - app.config.preset.as_deref().unwrap_or("") + app.config.preset.as_deref().unwrap_or(""), + app.config.svg_viewer_cmd_source )) .alignment(Alignment::Left) .style(Style::default().fg(Color::White)); - f.render_widget(status, layout[0]); + f.render_widget(status, layout[1]); - let divider = Paragraph::new("a Add | d Delete | / Search | ? Help | q Quit") + let divider = + Paragraph::new("a Add | d Delete | r Rename | o Open | / Search | ? Help | q Quit") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(divider, layout[3]); + + let nav = Paragraph::new("r Rename file path (alias stays the same)") .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(divider, layout[2]); + .style(Style::default().fg(Color::Gray)); + f.render_widget(nav, layout[4]); - let nav = Paragraph::new("Up/Down or j/k to navigate") + let ide_tip = Paragraph::new("Need to rename the icon symbol? Use your IDE Rename Symbol.") .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); - f.render_widget(nav, layout[3]); + f.render_widget(ide_tip, layout[5]); - let help_text = Paragraph::new("Esc or ? to close") + let help_text = Paragraph::new("Up/Down or j/k to navigate | Esc or ? to close") .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[4]); + f.render_widget(help_text, layout[6]); } diff --git a/src/views/main.rs b/src/views/main.rs index 8b3d1a3..ec29fc1 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,8 +1,8 @@ use ratatui::{ - Frame, layout::{Alignment, Constraint, Rect}, style::{Color, Style}, widgets::{Block, Borders, Paragraph}, + Frame, }; use tui_textarea::{Input, Key, TextArea}; @@ -18,6 +18,8 @@ pub enum MainStateFocus { 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>, } @@ -27,10 +29,22 @@ impl MainState { 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; + } + pub fn handlekeys_search(&mut self, input: &Input, app: &mut App) { match input.key { Key::Esc => { @@ -89,6 +103,23 @@ impl MainState { Key::Char('d') => { app.init_delete_popup(); } + Key::Char('r') => { + app.init_rename_popup(); + } + Key::Char('o') => match app.open_selected_icon() { + Ok(crate::viewer::OpenSvgOutcome::OpenedWithCustomCommand) => self.clear_status(), + Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefault) => self.clear_status(), + Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure) => self + .set_status( + "svg_viewer_cmd failed; opened icon via OS default viewer".to_string(), + false, + ), + Ok(crate::viewer::OpenSvgOutcome::OpenedWithWebPreview(url)) => self.set_status( + format!("Local open failed; opened web preview: {url}"), + false, + ), + Err(error) => self.set_status(format!("Failed to open icon: {}", error), true), + }, Key::Char('/') => { self.main_state_focus = MainStateFocus::Search; } @@ -148,6 +179,28 @@ impl MainState { } impl App { + 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) { let main_state_ptr = &mut self.main_state as *mut MainState; // Replace MainState with your actual type match self.main_state.main_state_focus { @@ -200,6 +253,17 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .alignment(Alignment::Center); f.render_widget(tagline_paragraph, main_chunks[1]); + let status_text = main_state.status_message.as_deref().unwrap_or(""); + let status_color = if main_state.status_is_error { + Color::Red + } else { + Color::DarkGray + }; + let status_paragraph = Paragraph::new(status_text) + .style(Style::default().fg(status_color)) + .alignment(Alignment::Center); + f.render_widget(status_paragraph, main_chunks[2]); + if is_searching { let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) @@ -274,7 +338,8 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { } f.render_stateful_widget(table, main_chunks[4], &mut state); - let instructions = "a Add | d Delete | / Search | ? Help | q Quit | Up/Down (j/k)"; + let instructions = + "a Add | d Delete | r Rename | o Open | / Search | ? Help | q Quit | Up/Down (j/k)"; let version_label = format!("iconmate v{}", env!("CARGO_PKG_VERSION")); let footer_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) diff --git a/src/views/mod.rs b/src/views/mod.rs index 2fd50f8..5e0e929 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,3 +2,4 @@ pub mod add_popup; pub mod delete_popup; pub mod help_popup; pub mod main; +pub mod rename_popup; diff --git a/src/views/rename_popup.rs b/src/views/rename_popup.rs new file mode 100644 index 0000000..2688829 --- /dev/null +++ b/src/views/rename_popup.rs @@ -0,0 +1,188 @@ +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::{Color, Style}; +use ratatui::widgets::{Block, Borders, Paragraph}; +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(Color::White)); + + 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) { + let area = popup_area(f.area(), 72, 12); + f.render_widget(ratatui::widgets::Clear, area); + + let layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + let title = Block::bordered() + .title("Rename File") + .title_style(Style::default().fg(Color::White)) + .border_type(ratatui::widgets::BorderType::Rounded) + .title_alignment(Alignment::Center); + f.render_widget(title, 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(Color::Gray)); + f.render_widget(status_paragraph, layout[0]); + + let input_block = Block::default() + .borders(Borders::TOP) + .title("New filename") + .border_style(Style::default().fg(Color::Yellow)); + state.filename_input.set_block(input_block); + state.filename_input.set_cursor_line_style(Style::default()); + f.render_widget(&state.filename_input, layout[1]); + + let tip = Paragraph::new( + "Renames only the file path export target. For alias rename, use your IDE Rename Symbol.", + ) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(tip, layout[2]); + + let footer_text = state + .status_message + .clone() + .unwrap_or_else(|| "Enter to rename, Esc to cancel".to_string()); + let footer_color = if state.status_is_error { + Color::Red + } else { + Color::DarkGray + }; + let footer = Paragraph::new(footer_text) + .alignment(Alignment::Center) + .style(Style::default().fg(footer_color)); + f.render_widget(footer, layout[3]); + } +} From c0fd220850cd5f23502c8144c290007ec81b43e8 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 14:09:24 +0800 Subject: [PATCH 16/28] docs: readme updates. --- README.md | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4085e01..1753bb3 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,11 @@ Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. # 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 icons 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! @@ -62,19 +57,21 @@ 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 ``` -## Configuration - -Iconmate now includes a config schema + TS type definitions in the repo: +Iconmate now includes config schemas + TS type definitions in the repo: -- Local config schema: `iconmatelocal.schema.json` -- Global config schema: `iconmateglobal.schema.json` +- 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` @@ -90,7 +87,7 @@ just config-schema ```json { - "$schema": "./iconmatelocal.schema.json", + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", "folder": "src/assets/icons", "preset": "", "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';", @@ -98,6 +95,8 @@ just config-schema } ``` +Use the raw URL for `$schema` so editors can fetch JSON directly. + Local config keys: - `folder` (default: `src/assets/icons`) @@ -128,6 +127,7 @@ Example global config: ```json { + "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", "svg_view_cmd": "code %filename%" } ``` @@ -283,12 +283,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", } ``` From 6cb4456924b0e51aeed01d24b1bbc32d4b8ed299 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 6 Feb 2026 14:27:09 +0800 Subject: [PATCH 17/28] docs: more doc polish. --- README.md | 85 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1753bb3..8fbcebc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ · CLI Commands · + AI Ready + · Configuration

@@ -28,10 +30,11 @@ Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. ## Why iconmate? +- **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 follow guided prompts -- **Multiple sources** 🌐: Pull icons from Iconify names, URLs, or raw 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 @@ -140,37 +143,22 @@ Example global config: ### 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 . @@ -190,33 +178,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 @@ -235,6 +214,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 @@ -275,6 +260,23 @@ 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. @@ -294,9 +296,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) @@ -323,16 +325,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] 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. +- [ ] 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] From 8cd8c60967a842db1e6776a9be0b06b4b51fb468 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 13:31:05 +0800 Subject: [PATCH 18/28] feat: most ideal iconify search experience in the TUI. --- src/app_state.rs | 62 ++- src/tui.rs | 26 +- src/views/add_popup.rs | 63 +++ src/views/help_popup.rs | 9 +- src/views/iconify_search_popup.rs | 880 ++++++++++++++++++++++++++++++ src/views/main.rs | 7 +- src/views/mod.rs | 1 + 7 files changed, 1027 insertions(+), 21 deletions(-) create mode 100644 src/views/iconify_search_popup.rs diff --git a/src/app_state.rs b/src/app_state.rs index cecd50c..02d70ab 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,5 +1,8 @@ use crate::{utils::IconEntry, views::main::MainState}; -use std::sync::mpsc::Receiver; +use std::{ + path::PathBuf, + sync::mpsc::{Receiver, Sender}, +}; use tui_textarea::Input; #[derive(Debug, Clone, Copy, PartialEq)] @@ -9,6 +12,42 @@ pub enum AppFocus { 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>, + }, + IconifyPreviewOpened { + icon_name: String, + temp_file: Option, + result: Result, + }, } #[derive(Debug, Clone)] @@ -26,7 +65,8 @@ pub struct App { pub config: AppConfig, // App state - pub rx: Receiver<()>, + pub tx: Sender, + pub rx: Receiver, pub should_quit: bool, @@ -43,15 +83,20 @@ pub struct App { 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 (tx, rx) = std::sync::mpsc::channel(); let mut app = Self { config, should_quit: false, + tx, rx: rx, selected_index: 0, @@ -62,6 +107,8 @@ impl App { 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(), }; @@ -78,7 +125,13 @@ impl App { self.filtered_items = self.items.clone(); } - pub fn update(&mut self) {} + 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 { @@ -87,6 +140,7 @@ impl App { 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/tui.rs b/src/tui.rs index 4b2e3c4..0cbfb10 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -8,7 +8,7 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Frame, Terminal, backend::CrosstermBackend, layout::Constraint}; -use std::io; +use std::{io, time::Duration}; use tui_textarea::{Input, Key}; pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { @@ -23,20 +23,21 @@ pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> { loop { terminal.draw(|f| ui(f, &mut app))?; - match ratatui::crossterm::event::read()?.into() { - Input { - key: Key::Char('q'), - .. + 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), } - | Input { - key: Key::Char('c'), - ctrl: true, - .. - } => break, - input => app.handlekeys(input), } app.update(); + if app.should_quit { + break; + } } disable_raw_mode()?; @@ -68,6 +69,9 @@ fn ui(f: &mut Frame, app: &mut 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/views/add_popup.rs b/src/views/add_popup.rs index d83abbf..df40812 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -272,6 +272,19 @@ impl App { .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 { @@ -563,3 +576,53 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { f.render_widget(footer, layout[FOOTER_FIELD_IDX]); } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config(folder: String) -> crate::app_state::AppConfig { + crate::app_state::AppConfig { + folder, + preset: None, + 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/help_popup.rs b/src/views/help_popup.rs index 5fe4ea4..4e1aa38 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -85,10 +85,11 @@ pub fn render_help_popup(f: &mut Frame, app: &App) { .style(Style::default().fg(Color::White)); f.render_widget(status, layout[1]); - let divider = - Paragraph::new("a Add | d Delete | r Rename | o Open | / Search | ? Help | q Quit") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)); + let divider = Paragraph::new( + "a Add | i Iconify Search | d Delete | r Rename | o Open | / Search | ? Help | q Quit", + ) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); f.render_widget(divider, layout[3]); let nav = Paragraph::new("r Rename file path (alias stays the same)") diff --git a/src/views/iconify_search_popup.rs b/src/views/iconify_search_popup.rs new file mode 100644 index 0000000..f071661 --- /dev/null +++ b/src/views/iconify_search_popup.rs @@ -0,0 +1,880 @@ +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + path::PathBuf, + time::{Duration, Instant}, +}; + +use ratatui::{ + Frame, + layout::{Alignment, Constraint}, + style::{Color, Style}, + widgets::{Block, Borders, 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, 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 is_opening_preview: bool, + + pub status_message: Option, + pub status_is_error: bool, + + pub preview_cache: HashMap, +} + +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, + is_opening_preview: false, + status_message: None, + status_is_error: false, + preview_cache: HashMap::new(), + } + } + + 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().to_lowercase(); + if query.is_empty() { + self.filtered_collections.clear(); + return; + } + + self.filtered_collections = self + .all_collections + .iter() + .filter(|item| { + item.prefix.to_lowercase().contains(&query) + || item.name.to_lowercase().contains(&query) + }) + .cloned() + .collect(); + } + + 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.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.search_value.trim().is_empty() { + let prefix_filter = format!("{prefix}:"); + self.visible_icons = self + .search_icons + .iter() + .filter(|icon| icon.starts_with(&prefix_filter)) + .cloned() + .collect(); + } else if self.collection_icons_prefix.as_deref() == Some(prefix.as_str()) { + self.visible_icons = self.collection_icons.clone(); + } else { + self.visible_icons.clear(); + } + } 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), + OpenIconPreview(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 | Key::Char('k') => match state.active_tab { + IconifySearchTab::Collections => state.move_collection_selection(-1), + IconifySearchTab::Icons => state.move_icon_selection(-1), + }, + Key::Down | Key::Char('j') => 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::OpenIconPreview(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::OpenIconPreview(icon_name) => { + self.open_icon_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 { + 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; + } + + 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.visible_icons.is_empty() { + state.set_status("No 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); + } + } + } + } + AppEvent::IconifyPreviewOpened { + icon_name, + temp_file, + result, + } => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.is_opening_preview = false; + + match result { + Ok(outcome) => { + if let Some(path) = temp_file { + state.preview_cache.insert(icon_name, path); + } + + match outcome { + crate::viewer::OpenSvgOutcome::OpenedWithCustomCommand => { + state.set_status("Opened icon preview.".to_string(), false) + } + crate::viewer::OpenSvgOutcome::OpenedWithOsDefault => { + state.set_status("Opened icon preview.".to_string(), false) + } + crate::viewer::OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure => { + state.set_status( + "svg_viewer_cmd failed; opened preview via OS default" + .to_string(), + false, + ) + } + crate::viewer::OpenSvgOutcome::OpenedWithWebPreview(url) => { + state.set_status( + format!( + "Local open failed; opened Iconify web preview: {url}" + ), + false, + ) + } + } + } + Err(error) => { + state.set_status(format!("Failed to open preview: {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; + }; + + 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(); + + if state.search_value.trim().is_empty() { + 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_preview(&mut self, icon_name: String) { + let cached_path = self + .iconify_search_popup_state + .as_ref() + .and_then(|state| state.preview_cache.get(&icon_name).cloned()) + .filter(|path| path.exists()); + + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.is_opening_preview = true; + if cached_path.is_some() { + state.set_status( + format!("Opening cached preview for '{icon_name}'..."), + false, + ); + } else { + state.set_status(format!("Downloading preview for '{icon_name}'..."), false); + } + } + + let tx = self.tx.clone(); + let viewer_cmd = self.config.svg_viewer_cmd.clone(); + + tokio::spawn(async move { + let operation = async { + if let Some(path) = cached_path { + let outcome = + crate::viewer::open_svg_with_fallback(&path, viewer_cmd.as_deref()) + .map_err(|error| error.to_string())?; + return Ok::<(crate::viewer::OpenSvgOutcome, Option), String>(( + outcome, None, + )); + } + + let client = IconifyClient::from_env().map_err(|error| error.to_string())?; + let svg = client + .svg(&icon_name) + .await + .map_err(|error| error.to_string())?; + let path = icon_preview_temp_path(&icon_name); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write(&path, svg).map_err(|error| error.to_string())?; + + let outcome = crate::viewer::open_svg_with_fallback(&path, viewer_cmd.as_deref()) + .map_err(|error| error.to_string())?; + + Ok((outcome, Some(path))) + } + .await; + + match operation { + Ok((outcome, temp_file)) => { + let _ = tx.send(AppEvent::IconifyPreviewOpened { + icon_name, + temp_file, + result: Ok(outcome), + }); + } + Err(error) => { + let _ = tx.send(AppEvent::IconifyPreviewOpened { + icon_name, + temp_file: None, + result: Err(error), + }); + } + } + }); + } +} + +fn icon_preview_temp_path(icon_name: &str) -> PathBuf { + let safe_stem: String = icon_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + icon_name.hash(&mut hasher); + let suffix = hasher.finish(); + + std::env::temp_dir() + .join("iconmate-preview") + .join(format!("{}-{suffix:x}.svg", safe_stem)) +} + +pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { + let area = popup_area(f.area(), 90, 24); + f.render_widget(ratatui::widgets::Clear, area); + + let title = Block::bordered() + .title("Iconify Search") + .border_type(ratatui::widgets::BorderType::Rounded) + .title_style(Style::default().fg(Color::White)) + .title_alignment(Alignment::Center); + f.render_widget(title, area); + + let inner = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + let Some(state) = app.iconify_search_popup_state.as_mut() else { + return; + }; + + let search_block = Block::default().borders(Borders::TOP).title("Search"); + state.search_textarea.set_block(search_block); + state + .search_textarea + .set_cursor_line_style(Style::default()); + f.render_widget(&state.search_textarea, inner[0]); + + let tabs_label = match state.active_tab { + IconifySearchTab::Collections => "[Collections] Icons", + IconifySearchTab::Icons => "Collections [Icons]", + }; + let tabs = Paragraph::new(tabs_label) + .style(Style::default().fg(Color::White)) + .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("No collections")] + } else { + collection_items + .iter() + .map(|item| { + let label = match item.total { + Some(total) => format!("{} ({}) - {}", item.prefix, total, item.name), + None => format!("{} - {}", item.prefix, item.name), + }; + ListItem::new(label) + }) + .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().borders(Borders::TOP).title("Collections")) + .highlight_symbol("> ") + .highlight_style(Style::default().bg(Color::DarkGray)); + f.render_stateful_widget(list, inner[2], &mut list_state); + } + IconifySearchTab::Icons => { + let items: Vec = if state.visible_icons.is_empty() { + vec![ListItem::new("No icons")] + } else { + state + .visible_icons + .iter() + .map(|icon| ListItem::new(icon.clone())) + .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().borders(Borders::TOP).title(title)) + .highlight_symbol("> ") + .highlight_style(Style::default().bg(Color::DarkGray)); + f.render_stateful_widget(list, inner[2], &mut list_state); + } + } + + let loading_message = if state.is_opening_preview { + Some("Downloading/opening preview...") + } else 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 { + Color::Red + } else { + Color::DarkGray + }; + let status = Paragraph::new(status_message) + .alignment(Alignment::Left) + .style(Style::default().fg(status_color)); + f.render_widget(status, inner[3]); + + let help_text = if state.active_tab == IconifySearchTab::Collections { + "Tab switch tabs | Enter view icons | Up/Down move | Esc close" + } else { + "Enter autofill Add popup | Ctrl+o open | Up/Down move | Tab switch | Esc close" + }; + let help = Paragraph::new(help_text) + .alignment(Alignment::Left) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(help, inner[4]); +} diff --git a/src/views/main.rs b/src/views/main.rs index ec29fc1..995fd95 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,8 +1,8 @@ use ratatui::{ + Frame, layout::{Alignment, Constraint, Rect}, style::{Color, Style}, widgets::{Block, Borders, Paragraph}, - Frame, }; use tui_textarea::{Input, Key, TextArea}; @@ -100,6 +100,9 @@ impl MainState { Key::Char('a') => { app.init_add_popup(); } + Key::Char('i') => { + app.init_iconify_search_popup(); + } Key::Char('d') => { app.init_delete_popup(); } @@ -339,7 +342,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { f.render_stateful_widget(table, main_chunks[4], &mut state); let instructions = - "a Add | d Delete | r Rename | o Open | / Search | ? Help | q Quit | Up/Down (j/k)"; + "a Add | i Iconify | d Delete | r Rename | o Open | / Search | ? Help | q Quit | Up/Down"; let version_label = format!("iconmate v{}", env!("CARGO_PKG_VERSION")); let footer_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) diff --git a/src/views/mod.rs b/src/views/mod.rs index 5e0e929..4d5d7a2 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,5 +1,6 @@ pub mod add_popup; pub mod delete_popup; pub mod help_popup; +pub mod iconify_search_popup; pub mod main; pub mod rename_popup; From 7453a68446aaf0183db24899923a718c60fd75fc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 13:52:20 +0800 Subject: [PATCH 19/28] feat: majorly better search experience with nucleo. --- Cargo.lock | 11 + Cargo.toml | 1 + TODOS.md | 23 +- src/app_state.rs | 10 +- src/viewer.rs | 2 +- src/views/iconify_search_popup.rs | 472 +++++++++++++++++++----------- 6 files changed, 326 insertions(+), 193 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index facbea6..e4acad5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1051,6 +1051,7 @@ dependencies = [ "dirs", "inquire", "json5", + "nucleo-matcher", "predicates", "ratatui", "regex", @@ -1452,6 +1453,16 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index c6ac604..62e1cf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ 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/TODOS.md b/TODOS.md index d732fb7..175352c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -43,28 +43,25 @@ ## Add-flow product direction -- [ ] During add: allow icon discovery using Iconify API inside TUI +- [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. -- [ ] Dedicated icon search interface (TUI) - - Build a collection picker + icon search + preview + select-to-fill flow. - - Reuse `iconify` module for data fetch and pagination. - - Wire selected result back into add form. +- [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. -- [ ] Search as standalone CLI for AI/tooling +- [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. -## Config surfaces - -- [ ] Local config file support (or package.json key) - - Decide one canonical local format first, then optional package.json bridge. - - Implement precedence with CLI flags and global config. - - Document with copy/paste examples. - ## Notes - Keep TUI minimal-first: less chrome, more keyboard speed. diff --git a/src/app_state.rs b/src/app_state.rs index 02d70ab..c7dcff0 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,8 +1,5 @@ use crate::{utils::IconEntry, views::main::MainState}; -use std::{ - path::PathBuf, - sync::mpsc::{Receiver, Sender}, -}; +use std::sync::mpsc::{Receiver, Sender}; use tui_textarea::Input; #[derive(Debug, Clone, Copy, PartialEq)] @@ -43,11 +40,6 @@ pub enum AppEvent { prefix: String, result: Result, String>, }, - IconifyPreviewOpened { - icon_name: String, - temp_file: Option, - result: Result, - }, } #[derive(Debug, Clone)] diff --git a/src/viewer.rs b/src/viewer.rs index 49c999e..a6fb3f4 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -150,7 +150,7 @@ fn iconify_web_preview_url(svg_path: &Path) -> Option { Some(format!("https://api.iconify.design/{encoded}.svg")) } -fn open_url_in_browser(url: &str) -> anyhow::Result<()> { +pub fn open_url_in_browser(url: &str) -> anyhow::Result<()> { #[cfg(target_os = "macos")] { return spawn_background("open", &[url.to_string()]).context("Failed to open URL via open"); diff --git a/src/views/iconify_search_popup.rs b/src/views/iconify_search_popup.rs index f071661..8a5c3a8 100644 --- a/src/views/iconify_search_popup.rs +++ b/src/views/iconify_search_popup.rs @@ -1,10 +1,13 @@ use std::{ - collections::HashMap, - hash::{Hash, Hasher}, - path::PathBuf, + borrow::Cow, time::{Duration, Instant}, }; +use nucleo_matcher::{ + Config, Matcher, + pattern::{CaseMatching, Normalization, Pattern}, +}; + use ratatui::{ Frame, layout::{Alignment, Constraint}, @@ -22,6 +25,72 @@ use crate::{ 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, @@ -55,12 +124,9 @@ pub struct IconifySearchPopupState { pub is_loading_collections: bool, pub is_loading_search: bool, pub is_loading_collection_icons: bool, - pub is_opening_preview: bool, pub status_message: Option, pub status_is_error: bool, - - pub preview_cache: HashMap, } impl IconifySearchPopupState { @@ -86,10 +152,8 @@ impl IconifySearchPopupState { is_loading_collections: false, is_loading_search: false, is_loading_collection_icons: false, - is_opening_preview: false, status_message: None, status_is_error: false, - preview_cache: HashMap::new(), } } @@ -102,21 +166,13 @@ impl IconifySearchPopupState { } fn refresh_filtered_collections(&mut self) { - let query = self.search_value.trim().to_lowercase(); + let query = self.search_value.trim(); if query.is_empty() { self.filtered_collections.clear(); return; } - self.filtered_collections = self - .all_collections - .iter() - .filter(|item| { - item.prefix.to_lowercase().contains(&query) - || item.name.to_lowercase().contains(&query) - }) - .cloned() - .collect(); + self.filtered_collections = fuzzy_filter_collections(&self.all_collections, query); } fn sync_search_dispatch_state(&mut self) { @@ -124,7 +180,10 @@ impl IconifySearchPopupState { self.debounce_deadline = None; let query = self.search_value.trim().to_string(); - if query.is_empty() || self.active_tab != IconifySearchTab::Icons { + if query.is_empty() + || self.active_tab != IconifySearchTab::Icons + || self.selected_collection_filter.is_some() + { self.is_loading_search = false; return; } @@ -207,18 +266,17 @@ impl IconifySearchPopupState { fn refresh_visible_icons(&mut self) { if let Some(prefix) = &self.selected_collection_filter { - if !self.search_value.trim().is_empty() { - let prefix_filter = format!("{prefix}:"); - self.visible_icons = self - .search_icons - .iter() - .filter(|icon| icon.starts_with(&prefix_filter)) - .cloned() - .collect(); - } else if self.collection_icons_prefix.as_deref() == Some(prefix.as_str()) { + 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.clear(); + self.visible_icons = fuzzy_filter_icons(&self.collection_icons, query); } } else { self.visible_icons = self.search_icons.clone(); @@ -255,7 +313,7 @@ enum PopupAction { Close, OpenCollection(String), FillAddPopup(String), - OpenIconPreview(String), + OpenIconInBrowser(String), } impl App { @@ -296,11 +354,11 @@ impl App { } state.sync_search_dispatch_state(); } - Key::Up | Key::Char('k') => match state.active_tab { + Key::Up => match state.active_tab { IconifySearchTab::Collections => state.move_collection_selection(-1), IconifySearchTab::Icons => state.move_icon_selection(-1), }, - Key::Down | Key::Char('j') => match state.active_tab { + Key::Down => match state.active_tab { IconifySearchTab::Collections => state.move_collection_selection(1), IconifySearchTab::Icons => state.move_icon_selection(1), }, @@ -322,7 +380,7 @@ impl App { Key::Char('o') if input.ctrl => { if state.active_tab == IconifySearchTab::Icons { if let Some(icon_name) = state.selected_icon_name() { - action = PopupAction::OpenIconPreview(icon_name); + action = PopupAction::OpenIconInBrowser(icon_name); } else { state.set_status("No icon selected.".to_string(), true); } @@ -343,15 +401,17 @@ impl App { self.close_iconify_search_popup(); self.init_add_popup_with_icon_source(&icon_name); } - PopupAction::OpenIconPreview(icon_name) => { - self.open_icon_preview(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 { + if state.active_tab != IconifySearchTab::Icons + || state.selected_collection_filter.is_some() + { return None; } @@ -407,6 +467,13 @@ impl App { 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 { @@ -452,8 +519,15 @@ impl App { state.collection_icons = icons; state.refresh_visible_icons(); - if state.visible_icons.is_empty() { + 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(); } @@ -467,50 +541,6 @@ impl App { } } } - AppEvent::IconifyPreviewOpened { - icon_name, - temp_file, - result, - } => { - if let Some(state) = self.iconify_search_popup_state.as_mut() { - state.is_opening_preview = false; - - match result { - Ok(outcome) => { - if let Some(path) = temp_file { - state.preview_cache.insert(icon_name, path); - } - - match outcome { - crate::viewer::OpenSvgOutcome::OpenedWithCustomCommand => { - state.set_status("Opened icon preview.".to_string(), false) - } - crate::viewer::OpenSvgOutcome::OpenedWithOsDefault => { - state.set_status("Opened icon preview.".to_string(), false) - } - crate::viewer::OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure => { - state.set_status( - "svg_viewer_cmd failed; opened preview via OS default" - .to_string(), - false, - ) - } - crate::viewer::OpenSvgOutcome::OpenedWithWebPreview(url) => { - state.set_status( - format!( - "Local open failed; opened Iconify web preview: {url}" - ), - false, - ) - } - } - } - Err(error) => { - state.set_status(format!("Failed to open preview: {error}"), true); - } - } - } - } } } @@ -560,6 +590,14 @@ impl App { 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; @@ -596,13 +634,12 @@ impl App { state.selected_collection_filter = Some(prefix.clone()); state.selected_icon_index = 0; state.refresh_visible_icons(); + state.sync_search_dispatch_state(); - if state.search_value.trim().is_empty() { - should_fetch = state.collection_icons_prefix.as_deref() != Some(prefix.as_str()); - if should_fetch { - state.collection_icons.clear(); - state.refresh_visible_icons(); - } + should_fetch = state.collection_icons_prefix.as_deref() != Some(prefix.as_str()); + if should_fetch { + state.collection_icons.clear(); + state.refresh_visible_icons(); } } @@ -647,97 +684,41 @@ impl App { }); } - fn open_icon_preview(&mut self, icon_name: String) { - let cached_path = self - .iconify_search_popup_state - .as_ref() - .and_then(|state| state.preview_cache.get(&icon_name).cloned()) - .filter(|path| path.exists()); - - if let Some(state) = self.iconify_search_popup_state.as_mut() { - state.is_opening_preview = true; - if cached_path.is_some() { + 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!("Opening cached preview for '{icon_name}'..."), - false, + format!("Cannot open Icones page for invalid icon name '{icon_name}'."), + true, ); - } else { - state.set_status(format!("Downloading preview for '{icon_name}'..."), false); } - } - - let tx = self.tx.clone(); - let viewer_cmd = self.config.svg_viewer_cmd.clone(); - - tokio::spawn(async move { - let operation = async { - if let Some(path) = cached_path { - let outcome = - crate::viewer::open_svg_with_fallback(&path, viewer_cmd.as_deref()) - .map_err(|error| error.to_string())?; - return Ok::<(crate::viewer::OpenSvgOutcome, Option), String>(( - outcome, None, - )); - } + return; + }; - let client = IconifyClient::from_env().map_err(|error| error.to_string())?; - let svg = client - .svg(&icon_name) - .await - .map_err(|error| error.to_string())?; - let path = icon_preview_temp_path(&icon_name); + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status("Opening icon in browser...".to_string(), false); + } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + 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); } - std::fs::write(&path, svg).map_err(|error| error.to_string())?; - - let outcome = crate::viewer::open_svg_with_fallback(&path, viewer_cmd.as_deref()) - .map_err(|error| error.to_string())?; - - Ok((outcome, Some(path))) } - .await; - - match operation { - Ok((outcome, temp_file)) => { - let _ = tx.send(AppEvent::IconifyPreviewOpened { - icon_name, - temp_file, - result: Ok(outcome), - }); - } - Err(error) => { - let _ = tx.send(AppEvent::IconifyPreviewOpened { - icon_name, - temp_file: None, - result: Err(error), - }); + Err(error) => { + if let Some(state) = self.iconify_search_popup_state.as_mut() { + state.set_status(format!("Failed to open browser: {error}"), true); } } - }); + } } } -fn icon_preview_temp_path(icon_name: &str) -> PathBuf { - let safe_stem: String = icon_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect(); - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - icon_name.hash(&mut hasher); - let suffix = hasher.finish(); - - std::env::temp_dir() - .join("iconmate-preview") - .join(format!("{}-{suffix:x}.svg", safe_stem)) +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) { @@ -842,9 +823,7 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { } } - let loading_message = if state.is_opening_preview { - Some("Downloading/opening preview...") - } else if state.is_loading_collection_icons { + let loading_message = if state.is_loading_collection_icons { Some("Loading collection icons...") } else if state.is_loading_search { Some("Searching Iconify...") @@ -871,10 +850,163 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { let help_text = if state.active_tab == IconifySearchTab::Collections { "Tab switch tabs | Enter view icons | Up/Down move | Esc close" } else { - "Enter autofill Add popup | Ctrl+o open | Up/Down move | Tab switch | Esc close" + "Enter autofill Add popup | Ctrl+o open in browser | Up/Down move | Tab switch | Esc close" }; let help = Paragraph::new(help_text) .alignment(Alignment::Left) .style(Style::default().fg(Color::DarkGray)); f.render_widget(help, inner[4]); } + +#[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: None, + 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"); + } +} From bef437b4d543d4fc57b69834cb8fc3daf8dc36ad Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 14:22:33 +0800 Subject: [PATCH 20/28] fix: Rename preset `""` to `normal` for clarity - Replaces the ambiguous empty-string preset with an explicit `"normal"` value - Treats legacy `preset: ""` as deprecated, warns user and upgrades to `"normal"` - Updates all documentation, JSON schema, config parsing and UI to use the new name - Adjusts CLI/TUI logic so `"normal"` requires an icon source (like the old `""` did) - Keeps `"emptysvg"` unchanged for blank placeholder generation --- README.md | 8 +- config-gen/src/schema.ts | 8 +- config-gen/src/types.ts | 6 +- iconmatelocal.schema.json | 8 +- npm/README.md | 2 +- src/app_state.rs | 2 +- src/config.rs | 51 ++++++++---- src/main.rs | 83 +++++--------------- src/utils.rs | 124 ++++++++++++++++++++++-------- src/views/add_popup.rs | 57 +++++++------- src/views/help_popup.rs | 2 +- src/views/iconify_search_popup.rs | 2 +- tests/integration_test.rs | 62 +++++++++++++++ 13 files changed, 256 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 8fbcebc..76e986a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ just config-schema { "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json", "folder": "src/assets/icons", - "preset": "", + "preset": "normal", "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';", "svg_view_cmd": "zed %filename%" } @@ -103,13 +103,13 @@ Use the raw URL for `$schema` so editors can fetch JSON directly. Local config keys: - `folder` (default: `src/assets/icons`) -- `preset` (default: `""`, meaning plain `.svg` mode) +- `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: -- `""` (plain SVG mode) +- `normal` (plain SVG mode) - `react` - `svelte` - `solid` @@ -329,7 +329,7 @@ Contributions are welcome—pull requests for bug fixes, new framework presets, - [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. diff --git a/config-gen/src/schema.ts b/config-gen/src/schema.ts index be96d86..3e8c6fc 100644 --- a/config-gen/src/schema.ts +++ b/config-gen/src/schema.ts @@ -1,18 +1,18 @@ import * as z from "zod"; export const DEFAULT_FOLDER = "src/assets/icons"; -export const DEFAULT_PRESET = ""; +export const DEFAULT_PRESET = "normal"; export const DEFAULT_OUTPUT_LINE_TEMPLATE = "export { default as Icon%name% } from './%icon%%ext%';"; -export const PRESET_VALUES = ["", "react", "svelte", "solid", "vue", "emptysvg"] as const; +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. Empty string means plain SVG mode. Other values are framework or emptysvg presets.", + "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.", default: DEFAULT_PRESET, - examples: ["", "react", "solid", "emptysvg"] + examples: ["normal", "react", "solid", "emptysvg"] }); export const SvgViewCommandSchema = z.string().min(1).meta({ diff --git a/config-gen/src/types.ts b/config-gen/src/types.ts index 3207a05..9e0b941 100644 --- a/config-gen/src/types.ts +++ b/config-gen/src/types.ts @@ -1,4 +1,4 @@ -export type IconmatePreset = "" | "react" | "svelte" | "solid" | "vue" | "emptysvg"; +export type IconmatePreset = "normal" | "react" | "svelte" | "solid" | "vue" | "emptysvg"; /** * Project-level Iconmate config loaded from `iconmate.config.json`. @@ -11,8 +11,8 @@ export interface IconmateLocalConfig { folder?: string; /** - * Output preset. Empty string means plain `.svg` mode. - * Default: `""`. + * Output preset. `normal` means plain `.svg` mode. + * Default: `"normal"`. */ preset?: IconmatePreset; diff --git a/iconmatelocal.schema.json b/iconmatelocal.schema.json index 413f55d..261786c 100644 --- a/iconmatelocal.schema.json +++ b/iconmatelocal.schema.json @@ -16,7 +16,7 @@ "preset": { "type": "string", "enum": [ - "", + "normal", "react", "svelte", "solid", @@ -24,10 +24,10 @@ "emptysvg" ], "title": "Preset", - "description": "Icon output preset. Empty string means plain SVG mode. Other values are framework or emptysvg presets.", - "default": "", + "description": "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.", + "default": "normal", "examples": [ - "", + "normal", "react", "solid", "emptysvg" diff --git a/npm/README.md b/npm/README.md index 6c1df90..0a3f18a 100644 --- a/npm/README.md +++ b/npm/README.md @@ -219,7 +219,7 @@ Contributions are welcome—pull requests for bug fixes, new framework presets, - [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` - [ ] Other frameworks i.e. --preset=flutter or Go/Rust GUI apps? (Not sure how they work yet though). diff --git a/src/app_state.rs b/src/app_state.rs index c7dcff0..04acdcd 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -45,7 +45,7 @@ pub enum AppEvent { #[derive(Debug, Clone)] pub struct AppConfig { pub folder: String, - pub preset: Option, + pub preset: String, pub template: Option, pub svg_viewer_cmd: Option, pub svg_viewer_cmd_source: String, diff --git a/src/config.rs b/src/config.rs index ef0c554..eba8448 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,14 +2,12 @@ use anyhow::Context; use serde_json::{Map, Value}; use std::path::{Path, PathBuf}; -use crate::utils::Preset; +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%';"; -const PRESET_VALUES: [&str; 6] = ["", "react", "svelte", "solid", "vue", "emptysvg"]; - #[derive(Debug, Clone, Default)] struct LocalConfigFile { folder: Option, @@ -32,7 +30,7 @@ struct LoadedConfigFile { #[derive(Debug, Clone)] pub struct ResolvedTuiConfig { pub folder: String, - pub preset: Option, + pub preset: String, pub output_line_template: String, pub svg_viewer_cmd: Option, pub svg_viewer_cmd_source: String, @@ -82,13 +80,7 @@ pub fn resolve_tui_config( .as_ref() .and_then(|config| config.value.preset.clone()) }) - .and_then(|value| { - if value.trim().is_empty() { - None - } else { - Some(value) - } - }); + .unwrap_or_else(|| "normal".to_string()); let output_line_template = cli_output_line_template .cloned() @@ -234,13 +226,26 @@ fn parse_local_value( let folder = read_string_field(&object, path, "folder", false)?; - let preset = read_string_field(&object, path, "preset", true)?; + 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() { - if !PRESET_VALUES.contains(&value) { + 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(), - PRESET_VALUES.join(", "), + valid_presets.join(", "), value ); } @@ -390,4 +395,22 @@ mod tests { .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/main.rs b/src/main.rs index bb4ff26..86b2ad3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ mod views; use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse}; use crate::utils::{ _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry, - IconSourceType, Preset, default_name_and_filename_from_icon_source, + IconSourceType, PRESETS_OPTIONS, Preset, default_name_and_filename_from_icon_source, }; use clap::{Parser, Subcommand, ValueEnum}; use serde::Serialize; @@ -370,20 +370,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::Svg)) => { + (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::Svg)) => { + (None, Preset::EmptySvg) => { let content = r#""#.to_string(); let (file_stem, ext) = _make_svg_filename( config.filename.as_ref(), @@ -400,7 +402,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 @@ -424,7 +426,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 @@ -448,7 +450,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 @@ -472,7 +474,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 @@ -495,8 +497,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( @@ -508,9 +510,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."); } }?; @@ -574,61 +576,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::Svg), - "react" => Some(Preset::React), - "svelte" => Some(Preset::Svelte), - "solid" => Some(Preset::Solid), - "vue" => Some(Preset::Vue), - _ => None, - } + Some(preset_opt.preset) } }; @@ -638,7 +593,7 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { Some(i.clone()) } None => { - if matches!(preset, Some(Preset::Svg)) { + if matches!(preset, Some(Preset::EmptySvg)) { None } else { let icon_raw = Text::new( diff --git a/src/utils.rs b/src/utils.rs index 3bc01de..f42c297 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,9 +6,13 @@ 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")] - Svg, + EmptySvg, /// React Component .tsx #[value(name = "react")] @@ -30,7 +34,8 @@ pub enum Preset { impl Preset { pub fn to_str(&self) -> &'static str { match self { - Preset::Svg => "emptysvg", + Preset::Normal => "normal", + Preset::EmptySvg => "emptysvg", Preset::React => "react", Preset::Svelte => "svelte", Preset::Solid => "solid", @@ -46,10 +51,20 @@ pub struct PresetOption { 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::Svg, - description: "Outputs an svg (.svg)", + 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, @@ -361,7 +376,8 @@ pub fn _make_svg_filename( pub fn filename_from_preset(file_name: Option, preset: Option) -> String { if let Some(preset) = preset { let ext = match preset { - Preset::Svg => "svg", + Preset::Normal => "svg", + Preset::EmptySvg => "svg", Preset::React => "tsx", Preset::Svelte => "svelte", Preset::Solid => "tsx", @@ -421,7 +437,6 @@ pub fn get_existing_icons(folder_path: &str) -> anyhow::Result> { pub fn parse_export_line_ts(line: &str) -> Option { use std::path::Path; - // Log the line let line = line.trim(); // Skip empty lines and comments @@ -429,34 +444,46 @@ pub fn parse_export_line_ts(line: &str) -> Option { return None; } - // Example line: export { default as IconGitHub } from "./devicon:github.svg"; - // We look for: export { default as } from ""; - let parts: Vec<&str> = line.splitn(5, ' ').collect(); - if parts.len() >= 5 - && parts[0] == "export" - && parts[1] == "{" - && parts[3] == "as" - && parts[2] == "default" - { - if let Some(name_end) = line.find('}') { - let name = line["export { default as ".len()..name_end].trim(); - if let Some(from_start) = line[name_end..].find("from \"") { - let from_start = name_end + from_start + "from \"".len(); - if let Some(path_end) = line[from_start..].find('"') { - let relative_path = &line[from_start..from_start + path_end]; - let abs_path = Path::new(relative_path) - .canonicalize() - .unwrap_or_else(|_| Path::new(relative_path).to_path_buf()); - - return Some(IconEntry { - name: name.to_string(), - file_path: abs_path.to_string_lossy().to_string(), - }); - } - } - } + // 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; } - 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 abs_path = Path::new(relative_path) + .canonicalize() + .unwrap_or_else(|_| Path::new(relative_path).to_path_buf()); + + Some(IconEntry { + name: name.to_string(), + file_path: abs_path.to_string_lossy().to_string(), + }) } // FUTURE: @@ -707,6 +734,37 @@ mod tests { ); } + #[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 renames_file_and_updates_index_path() { let temp_dir = TempDir::new().expect("temp dir should be created"); diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index df40812..da99091 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -227,19 +227,14 @@ impl AddPopupState { impl App { pub fn init_add_popup(&mut self) { - let configured_preset = self.config.preset.as_ref().and_then(|preset_str| { - PRESETS_OPTIONS - .iter() - .find(|option| option.preset.to_str() == preset_str) - .map(|option| option.preset.clone()) - }); - let selected_index = configured_preset - .as_ref() - .and_then(|preset| { - PRESETS_OPTIONS - .iter() - .position(|option| option.preset == *preset) - }) + 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; @@ -247,7 +242,7 @@ impl App { // folder: None, icon: None, name: None, - preset: configured_preset, + preset: Some(configured_preset), filename: None, preset_index: selected_index, @@ -293,14 +288,19 @@ impl App { state.apply_icon_based_defaults(); - if state.preset.is_none() - && !state.presets_filtered.is_empty() - && state.preset_index < state.presets_filtered.len() - { - state.preset = Some(state.presets_filtered[state.preset_index].preset.clone()); - } - - let preset = state.preset.clone(); + 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") @@ -340,8 +340,9 @@ impl App { ); } - if icon.is_empty() && preset.is_none() { - return Err("Choose a preset or provide an icon source first.".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())?); @@ -359,9 +360,7 @@ impl App { .unwrap_or(DEFAULT_OUTPUT_LINE_TEMPLATE), ); - if let Some(preset) = preset { - command.arg("--preset").arg(preset.to_str()); - } + command.arg("--preset").arg(preset.to_str()); if !icon.is_empty() { command.arg("--icon").arg(icon); @@ -473,7 +472,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { ListItem::new(format!( // https://stackoverflow.com/questions/50458144/what-is-the-easiest-way-to-pad-a-string-with-0-to-the-left "{:<8} - {}", - format!("{:?}", p.preset), + p.preset.to_str(), p.description )) }) @@ -585,7 +584,7 @@ mod tests { fn test_config(folder: String) -> crate::app_state::AppConfig { crate::app_state::AppConfig { folder, - preset: None, + preset: "normal".to_string(), template: None, svg_viewer_cmd: None, svg_viewer_cmd_source: "test".to_string(), diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs index 4e1aa38..38fe286 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -78,7 +78,7 @@ pub fn render_help_popup(f: &mut Frame, app: &App) { let status = Paragraph::new(format!( " Folder: {}\n✦ Preset: {}\n[O] Viewer source: {}", app.config.folder, - app.config.preset.as_deref().unwrap_or(""), + app.config.preset.as_str(), app.config.svg_viewer_cmd_source )) .alignment(Alignment::Left) diff --git a/src/views/iconify_search_popup.rs b/src/views/iconify_search_popup.rs index 8a5c3a8..4a4f2f5 100644 --- a/src/views/iconify_search_popup.rs +++ b/src/views/iconify_search_popup.rs @@ -874,7 +874,7 @@ mod tests { let config = AppConfig { folder: folder.to_string_lossy().into_owned(), - preset: None, + preset: "normal".to_string(), template: None, svg_viewer_cmd: None, svg_viewer_cmd_source: "test".to_string(), diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a3eb1a5..ab27c59 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -238,3 +238,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" + ); +} From 6817719b3f017f451124f9a34901016ed4c40fef Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 14:24:28 +0800 Subject: [PATCH 21/28] feat: better search experience on homepage. --- src/views/main.rs | 108 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/src/views/main.rs b/src/views/main.rs index 995fd95..150d7c4 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,3 +1,7 @@ +use nucleo_matcher::{ + Config, Matcher, + pattern::{CaseMatching, Normalization, Pattern}, +}; use ratatui::{ Frame, layout::{Alignment, Constraint, Rect}, @@ -6,7 +10,49 @@ use ratatui::{ }; use tui_textarea::{Input, Key, TextArea}; -use crate::app_state::{App, AppFocus}; +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 { @@ -161,18 +207,7 @@ impl MainState { } } pub fn update_filtered_items(&mut self, app: &mut App) { - let filter = self.search_items_value.to_lowercase(); - app.filtered_items = app - .items - .iter() - .filter(|entry| { - let case1 = entry.name.to_lowercase().contains(&filter); - let case2 = entry.file_path.contains(&filter); - - case1 || case2 - }) - .cloned() - .collect(); + app.filtered_items = fuzzy_filter_home_items(&app.items, &self.search_items_value); if app.filtered_items.is_empty() { app.selected_index = 0; } else if app.selected_index >= app.filtered_items.len() { @@ -360,3 +395,50 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { f.render_widget(instructions_paragraph, footer_layout[0]); f.render_widget(version_paragraph, 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); + } +} From e71d6236b3b6bb04f8f6f64235d13a182e657509 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 15:56:47 +0800 Subject: [PATCH 22/28] feat: redesign everything (similar to crabcode). --- src/views/add_popup.rs | 173 ++++++----- src/views/delete_popup.rs | 100 +++--- src/views/help_popup.rs | 91 +++--- src/views/iconify_search_popup.rs | 491 +++++++++++++++++++++++++++--- src/views/main.rs | 195 +++++++----- src/views/mod.rs | 1 + src/views/rename_popup.rs | 76 ++--- src/views/theme.rs | 107 +++++++ 8 files changed, 922 insertions(+), 312 deletions(-) create mode 100644 src/views/theme.rs diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index da99091..361709b 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -4,9 +4,9 @@ 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::{Color, Style}; +use ratatui::style::Style; use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, ListItem, Paragraph}; +use ratatui::widgets::{Block, ListItem, Paragraph}; use tui_textarea::{Input, Key, TextArea}; // Constants @@ -15,7 +15,6 @@ 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 FOOTER_FIELD_IDX: usize = 4; const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = "export { default as Icon%name% } from './%icon%%ext%';"; #[derive(Debug)] @@ -100,7 +99,11 @@ impl AddPopupState { for (_i, textarea) in self.inputs.iter_mut().enumerate() { // Stay if index == _i { - textarea.set_cursor_style(Style::default().bg(Color::White)); + textarea.set_cursor_style( + Style::default() + .bg(crate::views::theme::ACCENT) + .fg(crate::views::theme::BASE_BG), + ); continue; } // Remove @@ -428,27 +431,30 @@ impl App { } pub fn render_add_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 70, 24); - f.render_widget(ratatui::widgets::Clear, area); + 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) - .margin(2) .constraints([ - Constraint::Length(6), // Preset - Constraint::Length(4), // Icon - Constraint::Length(3), // Filename - Constraint::Length(3), // Name - Constraint::Min(0), // Footer + 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(area); - - let title = Block::bordered() - .title("Add Icon") - .border_type(ratatui::widgets::BorderType::Rounded) - .title_style(Style::default().fg(Color::White)) - .title_alignment(Alignment::Center); - f.render_widget(title, area); + .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![ @@ -458,10 +464,21 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { format!("Preset filter: {}", state.preset_filter) }, String::from("Icon source (name, URL, SVG, or empty)"), - String::from("Filename"), - String::from("Name"), + 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 = { @@ -481,98 +498,108 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }; if items.is_empty() { state_store.select(None); - items = - vec![ListItem::new("No presets found").style(Style::default().fg(Color::DarkGray))]; + 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 mut list = ratatui::widgets::List::new(items) + + let (preset_bg, preset_title) = field_theme(state.current_input == PRESET_FIELD_IDX); + let list = ratatui::widgets::List::new(items) .block( Block::default() - .borders(Borders::TOP) .title(labels[PRESET_FIELD_IDX].clone()) - .border_style(if state.current_input == PRESET_FIELD_IDX { - Style::default().fg(Color::Green) - } else { + .title_style( Style::default() - }), + .fg(preset_title) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(preset_bg).fg(crate::views::theme::TEXT)), ) - .highlight_symbol("→ "); - if state.current_input == PRESET_FIELD_IDX { - list = list.highlight_style(Style::default().bg(Color::DarkGray)) - } + .highlight_symbol(" ") + .highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD), + ); - f.render_stateful_widget(list, layout[PRESET_FIELD_IDX], &mut state_store); + 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() - .borders(Borders::TOP) - .border_style(if state.current_input == ICON_FIELD_IDX { - Style::default().fg(Color::Green) - } else { - Style::default() - }) - .title(labels[ICON_FIELD_IDX].clone()); + .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], layout[ICON_FIELD_IDX]); + 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() - .borders(Borders::TOP) - .border_style(if state.current_input == FILENAME_FIELD_IDX { - Style::default().fg(Color::Yellow) - } else { - Style::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], - layout[FILENAME_FIELD_IDX], - ); + 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() - .borders(Borders::TOP) - .border_style(if state.current_input == NAME_FIELD_IDX { - Style::default().fg(Color::Yellow) - } else { - Style::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], layout[NAME_FIELD_IDX]); + f.render_widget(&state.inputs[NAME_FIELD_IDX], name_area); - let footer_text = if let Some(message) = &state.status_message { - message.clone() - } else { - String::from("Tab to move, Enter on Name to submit, Esc to cancel, Cmd/Ctrl+V to paste") - }; - let footer_color = if state.status_is_error { - Color::Red + 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 { - Color::DarkGray + Paragraph::new(crate::views::theme::shortcut_line(&[ + ("Next", "tab"), + ("Submit", "enter"), + ("Close", "esc"), + ("Paste", "cmd/ctrl+v"), + ])) + .alignment(Alignment::Left) }; - let footer = Paragraph::new(footer_text) - .alignment(Alignment::Center) - .style(Style::default().fg(footer_color)); - f.render_widget(footer, layout[FOOTER_FIELD_IDX]); + f.render_widget(footer, footer_area); } } diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index 976ec91..efbf439 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -2,8 +2,8 @@ use crate::app_state::{App, AppFocus}; use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Paragraph}; +use ratatui::style::Style; +use ratatui::widgets::Paragraph; use tui_textarea::{Input, Key}; #[derive(Debug)] @@ -82,10 +82,10 @@ impl App { self.close_delete_popup(); } } - Key::Up | Key::Char('k') => { + Key::Left | Key::Char('h') | Key::Up | Key::Char('k') => { state.selected_index = state.selected_index.saturating_sub(1); } - Key::Down | Key::Char('j') => { + Key::Right | Key::Char('l') | Key::Down | Key::Char('j') => { state.selected_index = (state.selected_index + 1).min(1); } _ => {} @@ -94,26 +94,22 @@ impl App { } pub fn render_delete_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 56, 10); - f.render_widget(ratatui::widgets::Clear, area); + 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) - .margin(2) .constraints([ - Constraint::Length(2), // Prompt - Constraint::Length(1), // Actions - Constraint::Length(1), // Help - Constraint::Min(0), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), ]) - .split(area); - - let title = Block::bordered() - .title("Delete Icon") - .title_style(Style::default().fg(Color::White)) - .border_type(ratatui::widgets::BorderType::Rounded) - .title_alignment(Alignment::Center); - f.render_widget(title, area); + .split(body_area); if let Some(state) = &mut app.delete_popup_state { let icon_name = state @@ -122,24 +118,60 @@ pub fn render_delete_popup(f: &mut Frame, app: &mut App) { .map(|item| item.name.as_str()) .unwrap_or("this icon"); let prompt = Paragraph::new(format!("Delete '{icon_name}'?")) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::White)); + .alignment(Alignment::Left) + .style( + Style::default() + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD), + ); f.render_widget(prompt, layout[0]); - let actions = if state.selected_index == 0 { - Paragraph::new("[y] Delete n Cancel") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Red)) + 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 { - Paragraph::new("y Delete [n] Cancel") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)) + 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::TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("n", Style::default().fg(crate::views::theme::MUTED_TEXT)), + ]) }; - f.render_widget(actions, layout[1]); + f.render_widget( + Paragraph::new(action_line).alignment(Alignment::Left), + layout[2], + ); } - - let help_text = Paragraph::new("Enter to confirm, Esc to cancel") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(help_text, layout[2]); } diff --git a/src/views/help_popup.rs b/src/views/help_popup.rs index 38fe286..843ccc1 100644 --- a/src/views/help_popup.rs +++ b/src/views/help_popup.rs @@ -2,9 +2,9 @@ use crate::app_state::{App, AppFocus}; use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph}; +use ratatui::widgets::Paragraph; use tui_textarea::{Input, Key}; impl App { @@ -23,87 +23,74 @@ impl App { } pub fn render_help_popup(f: &mut Frame, app: &App) { - let area = popup_area(f.area(), 72, 17); - f.render_widget(ratatui::widgets::Clear, area); + 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) - .margin(2) .constraints([ Constraint::Length(2), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), + Constraint::Length(4), ]) - .split(area); - - let title = Block::bordered() - .title("Help") - .title_style(Style::default().fg(Color::White)) - .border_type(ratatui::widgets::BorderType::Rounded) - .title_alignment(Alignment::Center); - f.render_widget(title, area); + .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 { - Color::Green + crate::views::theme::ACCENT } else { - Color::DarkGray + crate::views::theme::SUBTLE_TEXT }), ), - Span::styled("Global Config", Style::default().fg(Color::White)), + 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 { - Color::Green + crate::views::theme::ACCENT } else { - Color::DarkGray + crate::views::theme::SUBTLE_TEXT }), ), - Span::styled("Project Config", Style::default().fg(Color::White)), + 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: {}\n✦ Preset: {}\n[O] Viewer source: {}", + "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(Color::White)); - f.render_widget(status, layout[1]); - - let divider = Paragraph::new( - "a Add | i Iconify Search | d Delete | r Rename | o Open | / Search | ? Help | q Quit", - ) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(divider, layout[3]); - - let nav = Paragraph::new("r Rename file path (alias stays the same)") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - f.render_widget(nav, layout[4]); - - let ide_tip = Paragraph::new("Need to rename the icon symbol? Use your IDE Rename Symbol.") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - f.render_widget(ide_tip, layout[5]); - - let help_text = Paragraph::new("Up/Down or j/k to navigate | Esc or ? to close") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - f.render_widget(help_text, layout[6]); + .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 index 4a4f2f5..35ea390 100644 --- a/src/views/iconify_search_popup.rs +++ b/src/views/iconify_search_popup.rs @@ -11,8 +11,8 @@ use nucleo_matcher::{ use ratatui::{ Frame, layout::{Alignment, Constraint}, - style::{Color, Style}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + style::Style, + widgets::{Block, List, ListItem, Paragraph}, }; use tui_textarea::{Input, Key, TextArea}; @@ -722,62 +722,119 @@ fn icones_collection_url(icon_name: &str) -> Option { } pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 90, 24); - f.render_widget(ratatui::widgets::Clear, area); + use ratatui::{ + style::Modifier, + text::{Line, Span}, + }; - let title = Block::bordered() - .title("Iconify Search") - .border_type(ratatui::widgets::BorderType::Rounded) - .title_style(Style::default().fg(Color::White)) - .title_alignment(Alignment::Center); - f.render_widget(title, area); + 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) - .margin(2) .constraints([ Constraint::Length(3), Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ]) - .split(area); + .split(body_area); let Some(state) = app.iconify_search_popup_state.as_mut() else { return; }; - let search_block = Block::default().borders(Borders::TOP).title("Search"); + 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 tabs_label = match state.active_tab { - IconifySearchTab::Collections => "[Collections] Icons", - IconifySearchTab::Icons => "Collections [Icons]", + let collections_style = if state.active_tab == IconifySearchTab::Collections { + Style::default() + .bg(crate::views::theme::TAB_BG_ACTIVE) + .fg(crate::views::theme::TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(crate::views::theme::TAB_BG) + .fg(crate::views::theme::MUTED_TEXT) }; - let tabs = Paragraph::new(tabs_label) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Left); + let icons_style = if state.active_tab == IconifySearchTab::Icons { + Style::default() + .bg(crate::views::theme::TAB_BG_ACTIVE) + .fg(crate::views::theme::TEXT) + .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("No collections")] + vec![ListItem::new(Line::from(Span::styled( + "No collections", + Style::default().fg(crate::views::theme::SUBTLE_TEXT), + )))] } else { collection_items .iter() .map(|item| { - let label = match item.total { - Some(total) => format!("{} ({}) - {}", item.prefix, total, item.name), - None => format!("{} - {}", item.prefix, item.name), + let total_label = match item.total { + Some(total) => format!("{total}"), + None => "-".to_string(), }; - ListItem::new(label) + 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() }; @@ -788,19 +845,45 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { } let list = List::new(items) - .block(Block::default().borders(Borders::TOP).title("Collections")) - .highlight_symbol("> ") - .highlight_style(Style::default().bg(Color::DarkGray)); - f.render_stateful_widget(list, inner[2], &mut list_state); + .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::TEXT) + .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("No icons")] + 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(icon.clone())) + .map(|icon| { + ListItem::new(Line::from(Span::styled( + icon.clone(), + Style::default().fg(crate::views::theme::TEXT), + ))) + }) .collect() }; @@ -816,10 +899,28 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { }; let list = List::new(items) - .block(Block::default().borders(Borders::TOP).title(title)) - .highlight_symbol("> ") - .highlight_style(Style::default().bg(Color::DarkGray)); - f.render_stateful_widget(list, inner[2], &mut list_state); + .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::TEXT) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, inner[3], &mut list_state); } } @@ -838,26 +939,330 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { .or_else(|| state.status_message.clone()) .unwrap_or_default(); let status_color = if state.status_is_error { - Color::Red + crate::views::theme::ERROR } else { - Color::DarkGray + 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[3]); + 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(); - let help_text = if state.active_tab == IconifySearchTab::Collections { - "Tab switch tabs | Enter view icons | Up/Down move | Esc close" + 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 { - "Enter autofill Add popup | Ctrl+o open in browser | Up/Down move | Tab switch | Esc close" + None }; - let help = Paragraph::new(help_text) + + 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(Color::DarkGray)); - f.render_widget(help, inner[4]); + .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::{ diff --git a/src/views/main.rs b/src/views/main.rs index 150d7c4..3daaeff 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -5,8 +5,8 @@ use nucleo_matcher::{ use ratatui::{ Frame, layout::{Alignment, Constraint, Rect}, - style::{Color, Style}, - widgets::{Block, Borders, Paragraph}, + style::Style, + widgets::{Block, Paragraph}, }; use tui_textarea::{Input, Key, TextArea}; @@ -252,19 +252,15 @@ impl App { } } -pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { - use ratatui::widgets::{Cell, Row, Table}; +pub fn render_main_view(f: &mut Frame, area: Rect, app: &mut App) { + use ratatui::{ + style::Modifier, + widgets::{Cell, Row, Table}, + }; - let main_state = &app.main_state; + let main_state = &mut app.main_state; let is_searching = main_state.main_state_focus == MainStateFocus::Search; - let header_cells = ["Name", "File"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow))); - let header = Row::new(header_cells) - .style(Style::default().fg(Color::White)) - .height(1); - let main_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ @@ -272,6 +268,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]) @@ -280,56 +277,83 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { let ascii_art = "░▀█▀░█▀▀░█▀█░█▀█░█▄█░█▀█░▀█▀░█▀▀░\n\ ░░█░░█░░░█░█░█░█░█░█░█▀█░░█░░█▀▀░\n\ ░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░▀░▀░░▀░░▀▀▀░"; - let ascii_paragraph = Paragraph::new(ascii_art) - .style(Style::default().fg(Color::Rgb(74, 222, 128))) - .alignment(Alignment::Center); - f.render_widget(ascii_paragraph, main_chunks[0]); - - let tagline = "Add svg icons to your js apps without any dependencies"; - let tagline_paragraph = Paragraph::new(tagline) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - f.render_widget(tagline_paragraph, main_chunks[1]); - - let status_text = main_state.status_message.as_deref().unwrap_or(""); + 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 { - Color::Red + crate::views::theme::ERROR } else { - Color::DarkGray + crate::views::theme::SUBTLE_TEXT }; - let status_paragraph = Paragraph::new(status_text) - .style(Style::default().fg(status_color)) - .alignment(Alignment::Center); - f.render_widget(status_paragraph, main_chunks[2]); + 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 { - let chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Length(11), - Constraint::Fill(1), - Constraint::Min(0), - ]) - .split(main_chunks[3]); - let search_label = Paragraph::new("🔍 Search:") - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Left); - let search_enter_paragraph = Paragraph::new("[Enter]") - .style(Style::default().fg(Color::Green)) - .alignment(Alignment::Right); - f.render_widget(search_label, chunks[0]); - f.render_widget(&main_state.search_textarea, chunks[1]); - f.render_widget(search_enter_paragraph, chunks[2]); + 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 { - format!("🔍 {}", main_state.search_items_value) + main_state.search_items_value.clone() }; - let search_paragraph = Paragraph::new(search_display.as_str()) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Left); - f.render_widget(search_paragraph, main_chunks[3]); + 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() { @@ -338,9 +362,16 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { &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 results").style(Style::default().fg(Color::DarkGray)), + Cell::from("No icons match your search") + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)), Cell::from(""), ])] } else { @@ -348,8 +379,10 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { .iter() .map(|item| { Row::new(vec![ - Cell::from(item.name.as_str()), - Cell::from(item.file_path.as_str()), + 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() @@ -361,39 +394,49 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &App) { [Constraint::Percentage(50), Constraint::Percentage(50)], ) .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded), - ) + .block(Block::default()) .column_spacing(2) - .highlight_symbol("→ ") - .row_highlight_style(Style::default().bg(Color::DarkGray)); + .highlight_symbol(" ") + .row_highlight_style( + Style::default() + .bg(crate::views::theme::ROW_HIGHLIGHT_BG) + .fg(crate::views::theme::TEXT) + .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[4], &mut state); - - let instructions = - "a Add | i Iconify | d Delete | r Rename | o Open | / Search | ? Help | q Quit | Up/Down"; - let version_label = format!("iconmate v{}", env!("CARGO_PKG_VERSION")); + 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[5]); - let instructions_paragraph = Paragraph::new(instructions) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Left); - let version_paragraph = Paragraph::new(version_label) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - f.render_widget(instructions_paragraph, footer_layout[0]); - f.render_widget(version_paragraph, footer_layout[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)] diff --git a/src/views/mod.rs b/src/views/mod.rs index 4d5d7a2..42c09c9 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -4,3 +4,4 @@ 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 index 2688829..6aca4d3 100644 --- a/src/views/rename_popup.rs +++ b/src/views/rename_popup.rs @@ -4,8 +4,8 @@ use crate::app_state::{App, AppFocus}; use crate::utils::popup_area; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint}; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::style::Style; +use ratatui::widgets::{Block, Paragraph, Wrap}; use tui_textarea::{Input, Key, TextArea}; #[derive(Debug)] @@ -58,7 +58,11 @@ impl App { } } - filename_input.set_cursor_style(Style::default().bg(Color::White)); + 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, @@ -123,27 +127,21 @@ impl App { } pub fn render_rename_popup(f: &mut Frame, app: &mut App) { - let area = popup_area(f.area(), 72, 12); - f.render_widget(ratatui::widgets::Clear, area); + use ratatui::style::Modifier; + + let area = popup_area(f.area(), 74, 15); + let body_area = crate::views::theme::render_popup_shell(f, area, "Rename File"); let layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .margin(2) .constraints([ Constraint::Length(2), Constraint::Length(3), + Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), - Constraint::Min(0), ]) - .split(area); - - let title = Block::bordered() - .title("Rename File") - .title_style(Style::default().fg(Color::White)) - .border_type(ratatui::widgets::BorderType::Rounded) - .title_alignment(Alignment::Center); - f.render_widget(title, area); + .split(body_area); if let Some(state) = app.rename_popup_state.as_mut() { let status = if let Some(item) = &state.item_to_rename { @@ -153,36 +151,46 @@ pub fn render_rename_popup(f: &mut Frame, app: &mut App) { }; let status_paragraph = Paragraph::new(status) .alignment(Alignment::Left) - .style(Style::default().fg(Color::Gray)); + .style(Style::default().fg(crate::views::theme::MUTED_TEXT)); f.render_widget(status_paragraph, layout[0]); let input_block = Block::default() - .borders(Borders::TOP) .title("New filename") - .border_style(Style::default().fg(Color::Yellow)); + .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[1]); let tip = Paragraph::new( - "Renames only the file path export target. For alias rename, use your IDE Rename Symbol.", + "Renames only the file path export target.\nFor alias rename, use your IDE Rename Symbol.", ) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(tip, layout[2]); - - let footer_text = state - .status_message - .clone() - .unwrap_or_else(|| "Enter to rename, Esc to cancel".to_string()); - let footer_color = if state.status_is_error { - Color::Red + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)); + f.render_widget(tip, layout[3]); + + 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 { - Color::DarkGray + Paragraph::new(crate::views::theme::shortcut_line(&[ + ("Rename", "enter"), + ("Cancel", "esc"), + ("Paste", "cmd/ctrl+v"), + ])) + .alignment(Alignment::Left) }; - let footer = Paragraph::new(footer_text) - .alignment(Alignment::Center) - .style(Style::default().fg(footer_color)); - f.render_widget(footer, layout[3]); + f.render_widget(footer, layout[4]); } } diff --git a/src/views/theme.rs b/src/views/theme.rs new file mode 100644 index 0000000..ae7fe57 --- /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 INPUT_BG_FOCUSED: Color = Color::Rgb(33, 44, 63); +pub const TAB_BG: Color = Color::Reset; +pub const TAB_BG_ACTIVE: Color = Color::Rgb(41, 78, 57); +pub const ROW_HIGHLIGHT_BG: Color = Color::Rgb(40, 73, 54); + +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 = Color::Rgb(74, 222, 128); +pub const ACCENT_SOFT: Color = Color::Rgb(137, 244, 173); +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) +} From ec169cabc7835c835bc7fdbf0e86bc60bed9a6b4 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:04:33 +0800 Subject: [PATCH 23/28] fix: spacing rename. --- src/views/rename_popup.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/views/rename_popup.rs b/src/views/rename_popup.rs index 6aca4d3..3a7d5c8 100644 --- a/src/views/rename_popup.rs +++ b/src/views/rename_popup.rs @@ -129,13 +129,14 @@ impl App { pub fn render_rename_popup(f: &mut Frame, app: &mut App) { use ratatui::style::Modifier; - let area = popup_area(f.area(), 74, 15); + 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), @@ -164,15 +165,15 @@ pub fn render_rename_popup(f: &mut Frame, app: &mut App) { .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[1]); + 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 Rename Symbol.", + "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[3]); + f.render_widget(tip, layout[4]); let footer = if let Some(message) = &state.status_message { let color = if state.status_is_error { @@ -191,6 +192,6 @@ pub fn render_rename_popup(f: &mut Frame, app: &mut App) { ])) .alignment(Alignment::Left) }; - f.render_widget(footer, layout[4]); + f.render_widget(footer, layout[5]); } } From f85612b40527e22ea676b8a7ba1ac6b7914a9308 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:11:42 +0800 Subject: [PATCH 24/28] feat: better colors. --- README.md | 2 +- src/views/add_popup.rs | 28 ++++++++++++++++------------ src/views/delete_popup.rs | 4 ++-- src/views/iconify_search_popup.rs | 8 ++++---- src/views/main.rs | 6 +++--- src/views/theme.rs | 11 ++++++----- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 76e986a..694ac2c 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ Contributions are welcome—pull requests for bug fixes, new framework presets, - [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. -- [ ] Search and add Iconify icons directly inside the TUI (no need to open https://icones.js.org). +- [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) diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index 361709b..4dd7a92 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -1,12 +1,12 @@ use std::process::Command; use crate::app_state::{App, AppFocus}; -use crate::utils::{PRESETS_OPTIONS, Preset, PresetOption, popup_area}; -use ratatui::Frame; +use crate::utils::{popup_area, Preset, PresetOption, PRESETS_OPTIONS}; use ratatui::layout::{Alignment, Constraint}; use ratatui::style::Style; use ratatui::text::Line; use ratatui::widgets::{Block, ListItem, Paragraph}; +use ratatui::Frame; use tui_textarea::{Input, Key, TextArea}; // Constants @@ -458,11 +458,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { if let Some(state) = &mut app.add_popup_state { let labels: Vec = vec![ - if state.preset_filter.is_empty() { - String::from("Preset") - } else { - format!("Preset filter: {}", state.preset_filter) - }, + String::from("Preset"), String::from("Icon source (name, URL, SVG, or empty)"), String::from("Filename (auto keeps extension)"), String::from("Component name"), @@ -498,19 +494,27 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }; if items.is_empty() { state_store.select(None); - items = vec![ - ListItem::new("No presets found") - .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)), - ]; + 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) @@ -522,7 +526,7 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { .highlight_style( Style::default() .bg(crate::views::theme::ROW_HIGHLIGHT_BG) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD), ); diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index efbf439..1ed3b82 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -1,9 +1,9 @@ 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 ratatui::Frame; use tui_textarea::{Input, Key}; #[derive(Debug)] @@ -162,7 +162,7 @@ pub fn render_delete_popup(f: &mut Frame, app: &mut App) { " Cancel ", Style::default() .bg(crate::views::theme::ROW_HIGHLIGHT_BG) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD), ), Span::raw(" "), diff --git a/src/views/iconify_search_popup.rs b/src/views/iconify_search_popup.rs index 35ea390..7c35dbf 100644 --- a/src/views/iconify_search_popup.rs +++ b/src/views/iconify_search_popup.rs @@ -768,7 +768,7 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { let collections_style = if state.active_tab == IconifySearchTab::Collections { Style::default() .bg(crate::views::theme::TAB_BG_ACTIVE) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD) } else { Style::default() @@ -778,7 +778,7 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { let icons_style = if state.active_tab == IconifySearchTab::Icons { Style::default() .bg(crate::views::theme::TAB_BG_ACTIVE) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD) } else { Style::default() @@ -863,7 +863,7 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { .highlight_style( Style::default() .bg(crate::views::theme::ROW_HIGHLIGHT_BG) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, inner[3], &mut list_state); @@ -917,7 +917,7 @@ pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) { .highlight_style( Style::default() .bg(crate::views::theme::ROW_HIGHLIGHT_BG) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, inner[3], &mut list_state); diff --git a/src/views/main.rs b/src/views/main.rs index 3daaeff..692d098 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,12 +1,12 @@ use nucleo_matcher::{ - Config, Matcher, pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, }; use ratatui::{ - Frame, layout::{Alignment, Constraint, Rect}, style::Style, widgets::{Block, Paragraph}, + Frame, }; use tui_textarea::{Input, Key, TextArea}; @@ -400,7 +400,7 @@ pub fn render_main_view(f: &mut Frame, area: Rect, app: &mut App) { .row_highlight_style( Style::default() .bg(crate::views::theme::ROW_HIGHLIGHT_BG) - .fg(crate::views::theme::TEXT) + .fg(crate::views::theme::BASE_BG) .add_modifier(Modifier::BOLD), ); diff --git a/src/views/theme.rs b/src/views/theme.rs index ae7fe57..f88741c 100644 --- a/src/views/theme.rs +++ b/src/views/theme.rs @@ -1,24 +1,25 @@ use ratatui::{ - Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Clear, Paragraph}, + Frame, }; 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 INPUT_BG_FOCUSED: Color = Color::Rgb(33, 44, 63); +pub const LOGO_GREEN: Color = Color::Rgb(74, 222, 128); pub const TAB_BG: Color = Color::Reset; -pub const TAB_BG_ACTIVE: Color = Color::Rgb(41, 78, 57); -pub const ROW_HIGHLIGHT_BG: Color = Color::Rgb(40, 73, 54); +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 = Color::Rgb(74, 222, 128); -pub const ACCENT_SOFT: Color = Color::Rgb(137, 244, 173); +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 { From 80101ee1c3803a5dd62baa881085e47aae7b2f9d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:25:36 +0800 Subject: [PATCH 25/28] refactor: main view and remove unused code --- src/app_state.rs | 7 +- src/form_input.rs | 94 ------------------ src/main.rs | 95 ++++++++++++------- src/utils.rs | 107 ++++++++++++++++++--- src/viewer.rs | 6 +- src/views/add_popup.rs | 10 +- src/views/delete_popup.rs | 2 +- src/views/main.rs | 195 ++++++++++++++++++-------------------- src/views/theme.rs | 3 +- tests/integration_test.rs | 68 +++++++++++-- 10 files changed, 320 insertions(+), 267 deletions(-) delete mode 100644 src/form_input.rs diff --git a/src/app_state.rs b/src/app_state.rs index 04acdcd..f01b18b 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -89,7 +89,7 @@ impl App { should_quit: false, tx, - rx: rx, + rx, selected_index: 0, filtered_items: Vec::new(), @@ -110,10 +110,7 @@ impl App { pub fn init_icons(&mut self) { // Try to read the current project's export file - self.items = match crate::utils::get_existing_icons(&self.config.folder) { - Ok(icons) => icons, - Err(_) => Vec::new(), - }; + self.items = crate::utils::get_existing_icons(&self.config.folder).unwrap_or_default(); self.filtered_items = self.items.clone(); } diff --git a/src/form_input.rs b/src/form_input.rs deleted file mode 100644 index 988c118..0000000 --- a/src/form_input.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - style::{Color, Style}, - widgets::{Block, Borders}, -}; -use tui_textarea::TextArea; - -#[derive(Debug)] -pub struct FormInput { - label: String, - textarea: TextArea<'static>, - is_focused: bool, -} -impl FormInput { - pub fn new(label: impl Into) -> Self { - Self { - label: label.into(), - textarea: TextArea::default(), - is_focused: false, - } - } - - pub fn with_placeholder(mut self, text: &str) -> Self { - self.textarea.insert_str(text); - self - } - - pub fn set_focused(&mut self, focused: bool) { - self.is_focused = focused; - } - - pub fn handle_key(&mut self, key: KeyCode) { - use tui_textarea::{Input, Key}; - - let input = match key { - KeyCode::Char(c) => Input { - key: Key::Char(c), - ..Default::default() - }, - KeyCode::Backspace => Input { - key: Key::Backspace, - ..Default::default() - }, - KeyCode::Delete => Input { - key: Key::Delete, - ..Default::default() - }, - KeyCode::Left => Input { - key: Key::Left, - ..Default::default() - }, - KeyCode::Right => Input { - key: Key::Right, - ..Default::default() - }, - KeyCode::Home => Input { - key: Key::Home, - ..Default::default() - }, - KeyCode::End => Input { - key: Key::End, - ..Default::default() - }, - _ => return, - }; - - self.textarea.input(input); - } - - pub fn value(&self) -> String { - self.textarea.lines().join("\n") - } - - pub fn clear(&mut self) { - self.textarea = TextArea::default(); - } - - pub fn widget(&mut self) -> impl ratatui::widgets::Widget + '_ { - let border_color = if self.is_focused { - Color::Yellow - } else { - Color::Gray - }; - - self.textarea.set_block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .title(self.label.clone()), - ); - - &self.textarea - } -} diff --git a/src/main.rs b/src/main.rs index 86b2ad3..53da9ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ mod app_state; mod config; -mod form_input; mod iconify; mod tui; mod utils; @@ -536,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()); @@ -677,6 +684,31 @@ impl std::fmt::Display for IconEntry { } } +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'); + } + + updated +} + /// Interactive mode: deleting an icon from a select list of icons. async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { use inquire::{Confirm, MultiSelect, Text, ui::RenderConfig}; @@ -744,8 +776,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); @@ -759,38 +789,10 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> { } else { eprintln!("File not found: {}", full_path.display()); } - - // Remove the export line from index.ts content - if let Some(line_to_remove) = crate::utils::parse_export_line_ts(&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); - } - } - } } + 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)?; @@ -850,3 +852,28 @@ async fn main() -> anyhow::Result<()> { } } } + +#[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")); + } +} diff --git a/src/utils.rs b/src/utils.rs index f42c297..41542a8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -404,7 +404,7 @@ pub fn filename_from_preset(file_name: Option, preset: Option) - /// 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 name and absolute file path. +/// 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}; @@ -420,13 +420,23 @@ pub fn get_existing_icons(folder_path: &str) -> anyhow::Result> { for line in reader.lines() { let line = line?; + // Skip empty lines and comments if line.trim().is_empty() || line.trim_start().starts_with("//") { continue; } - if let Some(icon_entry) = parse_export_line_ts(&line) { - icons.push(icon_entry); + // 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); + } } } @@ -435,8 +445,6 @@ pub fn get_existing_icons(folder_path: &str) -> anyhow::Result> { /// For parsing a single export line in typescript. pub fn parse_export_line_ts(line: &str) -> Option { - use std::path::Path; - let line = line.trim(); // Skip empty lines and comments @@ -475,14 +483,21 @@ pub fn parse_export_line_ts(line: &str) -> Option { 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 abs_path = Path::new(relative_path) - .canonicalize() - .unwrap_or_else(|_| Path::new(relative_path).to_path_buf()); + 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: abs_path.to_string_lossy().to_string(), + file_path: import_path.to_string(), }) } @@ -541,7 +556,10 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { if found_export { // Write the updated content back - let updated_content = lines_to_keep.join("\n"); + 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"); } @@ -765,6 +783,17 @@ mod tests { 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"); @@ -836,4 +865,60 @@ mod tests { "icon alias should stay unchanged" ); } + + #[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")); + } } diff --git a/src/viewer.rs b/src/viewer.rs index a6fb3f4..87e32dd 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -116,8 +116,8 @@ fn open_with_os_default(svg_path: &Path) -> anyhow::Result<()> { } let open_args = vec![file_name]; - return spawn_background("open", &open_args) - .context("Failed to open icon via macOS Quick Look/open"); + spawn_background("open", &open_args) + .context("Failed to open icon via macOS Quick Look/open") } #[cfg(target_os = "linux")] @@ -153,7 +153,7 @@ fn iconify_web_preview_url(svg_path: &Path) -> Option { pub fn open_url_in_browser(url: &str) -> anyhow::Result<()> { #[cfg(target_os = "macos")] { - return spawn_background("open", &[url.to_string()]).context("Failed to open URL via open"); + spawn_background("open", &[url.to_string()]).context("Failed to open URL via open") } #[cfg(target_os = "linux")] diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs index 4dd7a92..5ad7d2c 100644 --- a/src/views/add_popup.rs +++ b/src/views/add_popup.rs @@ -1,12 +1,12 @@ use std::process::Command; use crate::app_state::{App, AppFocus}; -use crate::utils::{popup_area, Preset, PresetOption, PRESETS_OPTIONS}; +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 ratatui::Frame; use tui_textarea::{Input, Key, TextArea}; // Constants @@ -494,8 +494,10 @@ pub fn render_add_popup(f: &mut Frame, app: &mut App) { }; if items.is_empty() { state_store.select(None); - items = vec![ListItem::new("No presets found") - .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT))]; + items = vec![ + ListItem::new("No presets found") + .style(Style::default().fg(crate::views::theme::SUBTLE_TEXT)), + ]; } else { state_store.select(Some(state.preset_index)) }; diff --git a/src/views/delete_popup.rs b/src/views/delete_popup.rs index 1ed3b82..894f715 100644 --- a/src/views/delete_popup.rs +++ b/src/views/delete_popup.rs @@ -1,9 +1,9 @@ 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 ratatui::Frame; use tui_textarea::{Input, Key}; #[derive(Debug)] diff --git a/src/views/main.rs b/src/views/main.rs index 692d098..459bcb8 100644 --- a/src/views/main.rs +++ b/src/views/main.rs @@ -1,12 +1,12 @@ use nucleo_matcher::{ - pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, + pattern::{CaseMatching, Normalization, Pattern}, }; use ratatui::{ + Frame, layout::{Alignment, Constraint, Rect}, style::Style, widgets::{Block, Paragraph}, - Frame, }; use tui_textarea::{Input, Key, TextArea}; @@ -90,133 +90,123 @@ impl MainState { 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); + } + } - pub fn handlekeys_search(&mut self, input: &Input, app: &mut App) { + fn handlekeys_main_search(&mut self, input: &Input) { match input.key { Key::Esc => { - self.main_state_focus = MainStateFocus::Normal; - app.app_focus = AppFocus::Main; - self.search_textarea = TextArea::default(); - self.search_items_value = String::from(""); - self.update_filtered_items(app); + 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 => { - app.app_focus = AppFocus::Main; - self.main_state_focus = MainStateFocus::Normal; - } - Key::Up => { - let item_count = if self.search_items_value.is_empty() { - app.items.len() - } else { - app.filtered_items.len() - }; - if item_count == 0 { - app.selected_index = 0; - } else if app.selected_index > 0 { - app.selected_index -= 1; - } else { - app.selected_index = item_count - 1; - } - } - Key::Down => { - let item_count = if self.search_items_value.is_empty() { - app.items.len() - } else { - app.filtered_items.len() - }; - if item_count == 0 { - app.selected_index = 0; - } else if app.selected_index < item_count.saturating_sub(1) { - app.selected_index += 1; - } else { - app.selected_index = 0; - } + 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.search_textarea.input(input.clone()); - self.search_items_value = self.search_textarea.lines().join(""); - self.update_filtered_items(app); + 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_normal(&mut self, input: &Input, app: &mut App) { + fn handlekeys_main_normal(&mut self, input: &Input) { match input.key { - Key::Char('q') => app.should_quit = true, + Key::Char('q') => self.should_quit = true, Key::Char('a') => { - app.init_add_popup(); + self.init_add_popup(); } Key::Char('i') => { - app.init_iconify_search_popup(); + self.init_iconify_search_popup(); } Key::Char('d') => { - app.init_delete_popup(); + self.init_delete_popup(); } Key::Char('r') => { - app.init_rename_popup(); + self.init_rename_popup(); } - Key::Char('o') => match app.open_selected_icon() { - Ok(crate::viewer::OpenSvgOutcome::OpenedWithCustomCommand) => self.clear_status(), - Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefault) => self.clear_status(), - Ok(crate::viewer::OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure) => self - .set_status( + 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.set_status( - format!("Local open failed; opened web preview: {url}"), - false, - ), - Err(error) => self.set_status(format!("Failed to open icon: {}", error), true), + ) + } + 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_focus = MainStateFocus::Search; + self.main_state.main_state_focus = MainStateFocus::Search; } Key::Char('?') => { - app.init_help_popup(); - } - Key::Up | Key::Char('k') => { - let item_count = if self.search_items_value.is_empty() { - app.items.len() - } else { - app.filtered_items.len() - }; - if item_count == 0 { - app.selected_index = 0; - } else if app.selected_index > 0 { - app.selected_index -= 1; - } else { - app.selected_index = item_count - 1; - } - } - Key::Down | Key::Char('j') => { - let item_count = if self.search_items_value.is_empty() { - app.items.len() - } else { - app.filtered_items.len() - }; - if item_count == 0 { - app.selected_index = 0; - } else if app.selected_index < item_count.saturating_sub(1) { - app.selected_index += 1; - } else { - app.selected_index = 0; - } + 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 update_filtered_items(&mut self, app: &mut App) { - app.filtered_items = fuzzy_filter_home_items(&app.items, &self.search_items_value); - if app.filtered_items.is_empty() { - app.selected_index = 0; - } else if app.selected_index >= app.filtered_items.len() { - app.selected_index = app.filtered_items.len().saturating_sub(1); - } - } -} -impl App { pub fn open_selected_icon(&self) -> anyhow::Result { use std::path::Path; @@ -240,14 +230,9 @@ impl App { } pub fn handlekeys_main(&mut self, input: Input) { - let main_state_ptr = &mut self.main_state as *mut MainState; // Replace MainState with your actual type match self.main_state.main_state_focus { - MainStateFocus::Search => { - unsafe { (*main_state_ptr).handlekeys_search(&input, self) }; - } - MainStateFocus::Normal => { - unsafe { (*main_state_ptr).handlekeys_normal(&input, self) }; - } + MainStateFocus::Search => self.handlekeys_main_search(&input), + MainStateFocus::Normal => self.handlekeys_main_normal(&input), } } } diff --git a/src/views/theme.rs b/src/views/theme.rs index f88741c..882f822 100644 --- a/src/views/theme.rs +++ b/src/views/theme.rs @@ -1,15 +1,14 @@ use ratatui::{ + Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Clear, Paragraph}, - Frame, }; 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 INPUT_BG_FOCUSED: Color = Color::Rgb(33, 44, 63); 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; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ab27c59..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(), @@ -247,7 +299,7 @@ fn test_add_command_preset_normal_with_icon() { let binary_path = env!("CARGO_BIN_EXE_iconmate"); let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), @@ -280,7 +332,7 @@ fn test_add_command_preset_normal_requires_icon() { let binary_path = env!("CARGO_BIN_EXE_iconmate"); let output = Command::new(binary_path) - .args(&[ + .args([ "add", "--folder", test_folder.to_str().unwrap(), From 6f9264b27898c925a327d3dad47cf8960ddbcd81 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:32:56 +0800 Subject: [PATCH 26/28] docs: better readme for npm. --- npm/README.md | 249 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 183 insertions(+), 66 deletions(-) diff --git a/npm/README.md b/npm/README.md index 0a3f18a..a762b84 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,19 +1,42 @@ -# 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? + +- **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 +44,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 +60,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 +178,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 +214,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 +232,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 +285,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 +296,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 +325,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] 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] From f2dacf9884404e7101a0e544c45dde4c6b90eed5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:36:04 +0800 Subject: [PATCH 27/28] docs: made w/ rust! --- README.md | 1 + npm/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 694ac2c..941aa59 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. ## 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 diff --git a/npm/README.md b/npm/README.md index a762b84..24d875b 100644 --- a/npm/README.md +++ b/npm/README.md @@ -30,6 +30,7 @@ Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. ## 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 From 6710b3bce65f3f399f52d1bfc27e2c5cc8dbae67 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 7 Feb 2026 16:45:52 +0800 Subject: [PATCH 28/28] fix: delete issues edge cases. --- src/config.rs | 40 +++++++++++- src/main.rs | 53 +++++++++++++++- src/utils.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 234 insertions(+), 24 deletions(-) diff --git a/src/config.rs b/src/config.rs index eba8448..0e2f390 100644 --- a/src/config.rs +++ b/src/config.rs @@ -214,6 +214,7 @@ fn parse_local_value( warn_unknown_keys( &object, &[ + "$schema", "folder", "preset", "output_line_template", @@ -268,7 +269,12 @@ fn parse_global_value( warnings: &mut Vec, ) -> anyhow::Result { let object = as_object(value, path)?; - warn_unknown_keys(&object, &["svg_view_cmd", "svg_viewer_cmd"], path, warnings); + 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 }) @@ -381,6 +387,38 @@ mod tests { 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!({ diff --git a/src/main.rs b/src/main.rs index 53da9ff..259ab7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -710,13 +710,23 @@ fn remove_selected_exports_from_index(contents: &str, selected_icons: &[IconEntr } /// 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() @@ -823,7 +833,9 @@ async fn main() -> anyhow::Result<()> { run_app(config).await } Some(Commands::Tui {}) => run_prompt_mode(&args).await, - Some(Commands::Delete { folder: _ }) => run_delete_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( @@ -876,4 +888,39 @@ mod tests { 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/utils.rs b/src/utils.rs index 41542a8..667ae27 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -511,13 +511,10 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { let path = Path::new(file_path); - // Delete the icon file + // 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)?; - // eprintln!("Deleted icon file: {}", path.display()); - } else { - // eprintln!("Icon file not found: {}", path.display()); - return Ok(()); } // Find the parent folder and index.ts @@ -529,25 +526,26 @@ pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> { let contents = fs::read_to_string(&index_path)?; // Generate the file path relative to the parent folder - let relative_path = if file_path.starts_with(parent.to_string_lossy().as_ref()) { - &file_path[parent.to_string_lossy().len() + 1..] - } else { - file_path - }; + 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 = relative_path.replace('\\', "/"); - let file_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - + 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() { - // Check if this line exports our file - if line.contains(&normalized_relative_path) - || (line.contains(file_name) && line.contains("export")) - { + 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) } @@ -579,6 +577,22 @@ fn normalize_icon_relative_path(value: &str) -> String { .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, @@ -651,18 +665,21 @@ pub fn rename_icon_entry( 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_path) == current_relative_path { - let with_dot_prefix = matched_path.starts_with("./"); + 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..] ); @@ -866,6 +883,38 @@ mod tests { ); } + #[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"); @@ -921,4 +970,80 @@ mod tests { 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")); + } }