diff --git a/Cargo.lock b/Cargo.lock index 7f2b269..0843b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,76 +20,91 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.12.3" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74a4b14f3d99c1255dcba8f45621ab1a2e7540a0009652d33989005a4d0bfc6b" +checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" + +[[package]] +name = "accesskit_atspi_common" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5393c75d4666f580f4cac0a968bc97c36076bb536a129f28210dac54ee127ed" dependencies = [ - "enumn", + "accesskit", + "accesskit_consumer", + "atspi-common", "serde", + "thiserror 1.0.69", + "zvariant 4.2.0", ] [[package]] name = "accesskit_consumer" -version = "0.16.1" +version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c17cca53c09fbd7288667b22a201274b9becaa27f0b91bf52a526db95de45e6" +checksum = "7a12dc159d52233c43d9fe5415969433cbdd52c3d6e0df51bda7d447427b9986" dependencies = [ "accesskit", + "immutable-chunkmap", ] [[package]] name = "accesskit_macos" -version = "0.10.1" +version = "0.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3b6ae1eabbfbced10e840fd3fce8a93ae84f174b3e4ba892ab7bcb42e477a7" +checksum = "bfc6c1ecd82053d127961ad80a8beaa6004fb851a3a5b96506d7a6bd462403f6" dependencies = [ "accesskit", "accesskit_consumer", - "objc2 0.3.0-beta.3.patch-leaks.3", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "once_cell", ] [[package]] name = "accesskit_unix" -version = "0.6.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f46c18d99ba61ad7123dd13eeb0c104436ab6af1df6a1cd8c11054ed394a08" +checksum = "be7f5cf6165be10a54b2655fa2e0e12b2509f38ed6fc43e11c31fdb7ee6230bb" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_atspi_common", "async-channel 2.5.0", - "async-once-cell", + "async-executor", + "async-task", "atspi", - "futures-lite 1.13.0", - "once_cell", + "futures-lite 2.6.1", + "futures-util", "serde", - "zbus", + "zbus 4.4.0", ] [[package]] name = "accesskit_windows" -version = "0.15.1" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afcae27ec0974fc7c3b0b318783be89fd1b2e66dd702179fe600166a38ff4a0b" +checksum = "974e96c347384d9133427167fb8a58c340cb0496988dacceebdc1ed27071023b" dependencies = [ "accesskit", "accesskit_consumer", - "once_cell", "paste", "static_assertions", - "windows 0.48.0", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] name = "accesskit_winit" -version = "0.16.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5284218aca17d9e150164428a0ebc7b955f70e3a9a78b4c20894513aabf98a67" +checksum = "aea3522719f1c44564d03e9469a8e2f3a98b3a8a880bd66d0789c6b9c4a669dd" dependencies = [ "accesskit", "accesskit_macos", "accesskit_unix", "accesskit_windows", + "raw-window-handle", "winit", ] @@ -119,7 +134,6 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", - "serde", "version_check", "zerocopy", ] @@ -153,23 +167,21 @@ dependencies = [ [[package]] name = "android-activity" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", "bitflags 2.13.0", "cc", - "cesu8", - "jni 0.21.1", - "jni-sys 0.3.1", + "jni", "libc", "log", "ndk", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -256,11 +268,11 @@ dependencies = [ [[package]] name = "ash" -version = "0.37.3+1.3.251" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.7.4", + "libloading", ] [[package]] @@ -273,6 +285,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -322,6 +346,17 @@ dependencies = [ "futures-lite 1.13.0", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", +] + [[package]] name = "async-global-executor" version = "2.4.1" @@ -407,12 +442,6 @@ dependencies = [ "url", ] -[[package]] -name = "async-once-cell" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" - [[package]] name = "async-process" version = "1.8.1" @@ -430,6 +459,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -502,6 +549,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -510,9 +580,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atspi" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6059f350ab6f593ea00727b334265c4dfc7fd442ee32d264794bd9bdc68e87ca" +checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf" dependencies = [ "atspi-common", "atspi-connection", @@ -521,39 +591,42 @@ dependencies = [ [[package]] name = "atspi-common" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92af95f966d2431f962bc632c2e68eda7777330158bf640c4af4249349b2cdf5" +checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7" dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus", - "zbus_names", - "zvariant", + "zbus 4.4.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 3.0.0", + "zvariant 4.2.0", ] [[package]] name = "atspi-connection" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c65e7d70f86d4c0e3b2d585d9bf3f979f0b19d635a336725a88d279f76b939" +checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333" dependencies = [ "atspi-common", "atspi-proxies", - "futures-lite 1.13.0", - "zbus", + "futures-lite 2.6.1", + "zbus 4.4.0", ] [[package]] name = "atspi-proxies" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6495661273703e7a229356dcbe8c8f38223d697aacfaf0e13590a9ac9977bb52" +checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496" dependencies = [ "atspi-common", "serde", - "zbus", + "zbus 4.4.0", + "zvariant 4.2.0", ] [[package]] @@ -606,19 +679,25 @@ dependencies = [ ] [[package]] -name = "bit-set" -version = "0.5.3" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" [[package]] name = "bit_field" @@ -637,6 +716,9 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -671,44 +753,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-sys" -version = "0.1.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa55741ee90902547802152aaf3f8e5248aab7e21468089560d4c8840561146" -dependencies = [ - "objc-sys 0.2.0-beta.2", -] - -[[package]] -name = "block-sys" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" -dependencies = [ - "objc-sys 0.3.5", -] - -[[package]] -name = "block2" -version = "0.2.0-alpha.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd9e63c1744f755c2f60332b88de39d341e5e86239014ad839bd71c106dec42" -dependencies = [ - "block-sys 0.1.0-beta.1", - "objc2-encode 2.0.0-pre.2", -] - -[[package]] -name = "block2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" -dependencies = [ - "block-sys 0.2.1", - "objc2 0.4.1", -] - [[package]] name = "block2" version = "0.5.1" @@ -782,10 +826,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "calloop" -version = "0.12.4" +name = "cairo-rs" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ "bitflags 2.13.0", "log", @@ -810,11 +879,11 @@ dependencies = [ [[package]] name = "calloop-wayland-source" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.12.4", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", @@ -854,10 +923,14 @@ dependencies = [ ] [[package]] -name = "cesu8" -version = "1.1.0" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] [[package]] name = "cfg-if" @@ -871,6 +944,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cgl" version = "0.3.2" @@ -1046,6 +1125,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1093,6 +1181,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "derivative" version = "2.2.0" @@ -1169,7 +1263,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading", ] [[package]] @@ -1188,21 +1282,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "ecolor" -version = "0.28.1" +name = "dpi" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e6b451ff1143f6de0f33fc7f1b68fecfd2c7de06e104de96c4514de3f5396f8" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" dependencies = [ "bytemuck", "emath", - "serde", ] [[package]] name = "eframe" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6490ef800b2e41ee129b1f32f9ac15f713233fe3bc18e241a1afe1e4fb6811e0" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" dependencies = [ "ahash", "bytemuck", @@ -1211,7 +1310,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", + "glow 0.14.2", "glutin", "glutin-winit", "image", @@ -1222,22 +1321,22 @@ dependencies = [ "objc2-foundation 0.2.2", "parking_lot", "percent-encoding", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", + "raw-window-handle", "static_assertions", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "web-time", "winapi", + "windows-sys 0.52.0", "winit", ] [[package]] name = "egui" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c97e70a2768de630f161bb5392cbd3874fcf72868f14df0e002e82e06cb798" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" dependencies = [ "accesskit", "ahash", @@ -1245,14 +1344,13 @@ dependencies = [ "epaint", "log", "nohash-hasher", - "serde", ] [[package]] name = "egui-wgpu" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c7a7c707877c3362a321ebb4f32be811c0b91f7aebf345fb162405c0218b4c" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" dependencies = [ "ahash", "bytemuck", @@ -1269,16 +1367,16 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4e066af341bf92559f60dbdf2020b2a03c963415349af5f3f8d79ff7a4926" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" dependencies = [ "accesskit_winit", "ahash", "arboard", "egui", "log", - "raw-window-handle 0.6.2", + "raw-window-handle", "smithay-clipboard", "web-time", "webbrowser", @@ -1287,28 +1385,29 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb783d9fa348f69ed5c340aa25af78b5472043090e8b809040e30960cc2a746" +checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" dependencies = [ "ahash", "egui", "enum-map", + "image", "log", "mime_guess2", - "serde", + "resvg", ] [[package]] name = "egui_glow" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e2bdc8b38cfa17cc712c4ae079e30c71c00cd4c2763c9e16dc7860a02769103" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" dependencies = [ "ahash", "bytemuck", "egui", - "glow", + "glow 0.14.2", "log", "memoffset 0.9.1", "wasm-bindgen", @@ -1324,14 +1423,19 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "emath" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6a21708405ea88f63d8309650b4d77431f4bc28fb9d8e6f77d3963b51249e6" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" dependencies = [ "bytemuck", - "serde", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-map" version = "2.7.3" @@ -1374,34 +1478,29 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "enumn" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "epaint" -version = "0.28.1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f0dcc0a0771e7500e94cd1cb797bd13c9f23b9409bdc3c824e2cbc562b7fa01" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" dependencies = [ "ab_glyph", "ahash", "bytemuck", "ecolor", "emath", + "epaint_default_fonts", "log", "nohash-hasher", "parking_lot", - "serde", ] +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + [[package]] name = "equator" version = "0.4.2" @@ -1539,6 +1638,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1555,6 +1664,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.1.5" @@ -1627,6 +1742,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1700,6 +1826,64 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1766,6 +1950,38 @@ dependencies = [ "weezl", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1777,6 +1993,53 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1802,56 +2065,69 @@ dependencies = [ ] [[package]] -name = "glutin" -version = "0.31.3" +name = "glow" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fcd4ae4e86d991ad1300b8f57166e5be0c95ef1f63f3f5b827f8a164548746" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ "bitflags 2.13.0", - "cfg_aliases", + "cfg_aliases 0.2.1", "cgl", - "core-foundation 0.9.4", - "dispatch", + "dispatch2", "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "icrate", - "libloading 0.8.9", - "objc2 0.4.1", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "once_cell", - "raw-window-handle 0.5.2", + "raw-window-handle", "wayland-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", "x11-dl", ] [[package]] name = "glutin-winit" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebcdfba24f73b8412c5181e56f092b5eff16671c514ce896b258a0a64bd7735" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "glutin", - "raw-window-handle 0.5.2", + "raw-window-handle", "winit", ] [[package]] name = "glutin_egl_sys" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77cc5623f5309ef433c3dd4ca1223195347fe62c413da8e2fdd0eb76db2d9bcd" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ "gl_generator", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "glutin_glx_sys" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a165fd686c10dcc2d45380b35796e577eacfd43d4660ee741ec8ebe2201b3b4f" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" dependencies = [ "gl_generator", "x11-dl", @@ -1859,18 +2135,29 @@ dependencies = [ [[package]] name = "glutin_wgl_sys" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" dependencies = [ "gl_generator", ] [[package]] -name = "gpu-alloc" -version = "0.6.0" +name = "gobject-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ "bitflags 2.13.0", "gpu-alloc-types", @@ -1878,18 +2165,18 @@ dependencies = [ [[package]] name = "gpu-alloc-types" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ "bitflags 2.13.0", ] [[package]] name = "gpu-allocator" -version = "0.25.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" dependencies = [ "log", "presser", @@ -1918,6 +2205,58 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "half" version = "2.7.1" @@ -1971,12 +2310,18 @@ dependencies = [ "bitflags 2.13.0", "com", "libc", - "libloading 0.7.4", + "libloading", "thiserror 1.0.69", "widestring", "winapi", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2049,17 +2394,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icrate" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" -dependencies = [ - "block2 0.3.0", - "dispatch", - "objc2 0.4.1", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -2183,7 +2517,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", "qoi", "ravif", "rayon", @@ -2203,12 +2537,27 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + [[package]] name = "imgref" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "immutable-chunkmap" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4" +dependencies = [ + "arrayvec", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2277,22 +2626,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -2372,6 +2705,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyring" version = "2.3.3" @@ -2393,7 +2737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.8.9", + "libloading", "pkg-config", ] @@ -2403,6 +2747,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" +dependencies = [ + "arrayvec", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2469,16 +2822,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.9" @@ -2526,6 +2869,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -2601,6 +2963,7 @@ dependencies = [ "futures-lite 2.6.1", "image", "keyring", + "muda", "rusqlite", "serde", "serde_json", @@ -2615,9 +2978,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" dependencies = [ "value-bag", ] @@ -2685,9 +3048,9 @@ dependencies = [ [[package]] name = "metal" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5637e166ea14be6063a3f8ba5ccb9a4159df7d8f6d61c02fc3d480b1f90dcfcb" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ "bitflags 2.13.0", "block", @@ -2748,19 +3111,40 @@ dependencies = [ ] [[package]] -name = "naga" -version = "0.20.0" +name = "muda" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e536ae46fcab0876853bd4a632ede5df4b1c2527a58f6c5a4150fe86be858231" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.0", + "cfg_aliases 0.1.1", "codespan-reporting", "hexf-parse", "indexmap", "log", - "num-traits", "rustc-hash 1.1.0", "spirv", "termcolor", @@ -2787,17 +3171,16 @@ dependencies = [ [[package]] name = "ndk" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.13.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", + "raw-window-handle", "thiserror 1.0.69", ] @@ -2816,6 +3199,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2834,6 +3226,19 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -2988,47 +3393,20 @@ dependencies = [ "malloc_buf", ] -[[package]] -name = "objc-sys" -version = "0.2.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b9834c1e95694a05a828b59f55fa2afec6288359cda67146126b3f90a55d7" - [[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" -[[package]] -name = "objc2" -version = "0.3.0-beta.3.patch-leaks.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e01640f9f2cb1220bbe80325e179e532cb3379ebcd1bf2279d703c19fe3a468" -dependencies = [ - "block2 0.2.0-alpha.6", - "objc-sys 0.2.0-beta.2", - "objc2-encode 2.0.0-pre.2", -] - -[[package]] -name = "objc2" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" -dependencies = [ - "objc-sys 0.3.5", - "objc2-encode 3.0.0", -] - [[package]] name = "objc2" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ - "objc-sys 0.3.5", - "objc2-encode 4.1.0", + "objc-sys", + "objc2-encode", ] [[package]] @@ -3037,7 +3415,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "objc2-encode 4.1.0", + "objc2-encode", ] [[package]] @@ -3047,7 +3425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.0", - "block2 0.5.1", + "block2", "libc", "objc2 0.5.2", "objc2-core-data", @@ -3064,10 +3442,35 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", "objc2 0.6.4", + "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3075,7 +3478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.0", - "block2 0.5.1", + "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3110,27 +3513,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2 0.5.1", + "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", ] [[package]] -name = "objc2-encode" -version = "2.0.0-pre.2" +name = "objc2-core-location" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abfcac41015b00a120608fdaa6938c44cb983fee294351cc4bac7638b4e50512" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "objc-sys 0.2.0-beta.2", + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-encode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" - [[package]] name = "objc2-encode" version = "4.1.0" @@ -3144,7 +3544,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.0", - "block2 0.5.1", + "block2", + "dispatch", "libc", "objc2 0.5.2", ] @@ -3171,6 +3572,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -3178,7 +3591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.0", - "block2 0.5.1", + "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3190,12 +3603,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.0", - "block2 0.5.1", + "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3280,6 +3748,31 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -3367,10 +3860,16 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.3", "unicase", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3426,6 +3925,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" @@ -3513,6 +4025,16 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3522,6 +4044,30 @@ dependencies = [ "toml_edit 0.25.12+spec-1.1.0", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3571,6 +4117,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -3710,12 +4266,6 @@ dependencies = [ "rgb", ] -[[package]] -name = "raw-window-handle" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3743,10 +4293,16 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.3.5" +name = "rctree" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ "bitflags 1.3.2", ] @@ -3815,11 +4371,34 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadccb3d99a9efb8e5e00c16fbb732cbe400db2ec7fc004697ee7d97d86cf1f4" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + [[package]] name = "rgb" version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" [[package]] name = "rusqlite" @@ -3934,14 +4513,14 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sctk-adwaita" -version = "0.8.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit 0.18.1", + "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -3961,7 +4540,7 @@ dependencies = [ "rand 0.8.6", "serde", "sha2", - "zbus", + "zbus 3.15.2", ] [[package]] @@ -4060,6 +4639,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4138,6 +4726,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.3" @@ -4167,13 +4770,13 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" -version = "0.18.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.0", - "calloop 0.12.4", - "calloop-wayland-source 0.2.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -4184,8 +4787,8 @@ dependencies = [ "wayland-client", "wayland-csd-frame", "wayland-cursor", - "wayland-protocols 0.31.2", - "wayland-protocols-wlr 0.2.0", + "wayland-protocols", + "wayland-protocols-wlr", "wayland-scanner", "xkeysym", ] @@ -4209,10 +4812,10 @@ dependencies = [ "wayland-client", "wayland-csd-frame", "wayland-cursor", - "wayland-protocols 0.32.12", + "wayland-protocols", "wayland-protocols-experimental", "wayland-protocols-misc", - "wayland-protocols-wlr 0.3.12", + "wayland-protocols-wlr", "wayland-scanner", "xkeysym", ] @@ -4295,6 +4898,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "subtle" @@ -4319,6 +4925,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "svgtypes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70" +dependencies = [ + "kurbo", + "siphasher 0.3.11", +] + [[package]] name = "syn" version = "1.0.109" @@ -4352,6 +4968,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -4448,6 +5083,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -4500,11 +5136,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "toml_datetime" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -4526,6 +5177,19 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -4679,6 +5343,50 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756" +dependencies = [ + "base64", + "log", + "pico-args", + "usvg-parser", + "usvg-tree", + "xmlwriter", +] + +[[package]] +name = "usvg-parser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc" +dependencies = [ + "data-url", + "flate2", + "imagesize", + "kurbo", + "log", + "roxmltree", + "simplecss", + "siphasher 0.3.11", + "svgtypes", + "usvg-tree", +] + +[[package]] +name = "usvg-tree" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee3d202ebdb97a6215604b8f5b4d6ef9024efd623cf2e373a6416ba976ec7d3" +dependencies = [ + "rctree", + "strict-num", + "svgtypes", + "tiny-skia-path", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4725,6 +5433,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -4908,18 +5622,6 @@ dependencies = [ "xcursor", ] -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.12" @@ -4941,7 +5643,7 @@ dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.12", + "wayland-protocols", "wayland-scanner", ] @@ -4954,33 +5656,20 @@ dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.12", + "wayland-protocols", "wayland-scanner", ] [[package]] name = "wayland-protocols-plasma" -version = "0.2.0" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] @@ -4993,7 +5682,7 @@ dependencies = [ "bitflags 2.13.0", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.12", + "wayland-protocols", "wayland-scanner", ] @@ -5004,7 +5693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] @@ -5032,9 +5721,9 @@ dependencies = [ [[package]] name = "web-time" -version = "0.2.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -5047,7 +5736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ "core-foundation 0.10.1", - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2 0.6.4", @@ -5064,19 +5753,18 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "0.20.1" +version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90e37c7b9921b75dfd26dd973fdcbce36f13dfa6e2dc82aece584e0ed48c355c" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" dependencies = [ "arrayvec", - "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "document-features", "js-sys", "log", "parking_lot", "profiling", - "raw-window-handle 0.6.2", + "raw-window-handle", "smallvec", "static_assertions", "wasm-bindgen", @@ -5089,15 +5777,14 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "0.21.1" +version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d50819ab545b867d8a454d1d756b90cd5f15da1f2943334ca314af10583c9d39" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" dependencies = [ "arrayvec", "bit-vec", "bitflags 2.13.0", - "cfg_aliases", - "codespan-reporting", + "cfg_aliases 0.1.1", "document-features", "indexmap", "log", @@ -5105,28 +5792,27 @@ dependencies = [ "once_cell", "parking_lot", "profiling", - "raw-window-handle 0.6.2", + "raw-window-handle", "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", - "web-sys", "wgpu-hal", "wgpu-types", ] [[package]] name = "wgpu-hal" -version = "0.21.1" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172e490a87295564f3fcc0f165798d87386f6231b04d4548bca458cbbfd63222" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" dependencies = [ "android_system_properties", "arrayvec", "ash", "bitflags 2.13.0", - "cfg_aliases", + "cfg_aliases 0.1.1", "core-graphics-types", - "glow", + "glow 0.13.1", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", @@ -5135,16 +5821,16 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading 0.7.4", + "libloading", "log", "metal", "naga", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", "parking_lot", "profiling", - "raw-window-handle 0.6.2", + "raw-window-handle", "renderdoc-sys", "rustc-hash 1.1.0", "smallvec", @@ -5157,9 +5843,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "0.20.0" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1353d9a46bff7f955a680577f34c69122628cc2076e1d6f3a9be6ef00ae793ef" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" dependencies = [ "bitflags 2.13.0", "js-sys", @@ -5203,17 +5889,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-implement 0.48.0", - "windows-interface 0.48.0", - "windows-targets 0.48.5", -] - [[package]] name = "windows" version = "0.52.0" @@ -5224,6 +5899,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5233,6 +5918,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5242,19 +5940,19 @@ dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-implement" -version = "0.48.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2ee588991b9e7e6c8338edf3333fbe4da35dc72092643958ebb43f0ab2c49c" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5270,13 +5968,13 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.48.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6fb8df20c9bcaa8ad6ab513f7b40104840c8867d5751126e4df3b08388d0cc7" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5296,6 +5994,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5305,6 +6012,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -5314,15 +6031,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -5368,21 +6076,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -5431,12 +6124,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5455,12 +6142,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5479,12 +6160,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5515,12 +6190,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5539,12 +6208,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5563,12 +6226,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5587,12 +6244,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5613,48 +6264,51 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" -version = "0.29.15" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", "atomic-waker", "bitflags 2.13.0", + "block2", "bytemuck", - "calloop 0.12.4", - "cfg_aliases", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", "core-foundation 0.9.4", "core-graphics", "cursor-icon", - "icrate", + "dpi", "js-sys", "libc", - "log", "memmap2", "ndk", - "ndk-sys", - "objc2 0.4.1", - "once_cell", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", "orbclient", "percent-encoding", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", - "redox_syscall 0.3.5", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit 0.18.1", + "smithay-client-toolkit 0.19.2", "smol_str", + "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-protocols-plasma", "web-sys", "web-time", - "windows-sys 0.48.0", + "windows-sys 0.52.0", "x11-dl", "x11rb", "xkbcommon-dl", @@ -5700,7 +6354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -5711,7 +6365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", @@ -5778,6 +6432,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -5798,7 +6462,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading 0.8.9", + "libloading", "once_cell", "rustix 1.1.4", "x11rb-protocol", @@ -5851,6 +6515,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "y4m" version = "0.8.0" @@ -5886,12 +6556,12 @@ version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" dependencies = [ - "async-broadcast", + "async-broadcast 0.5.1", "async-executor", - "async-fs", + "async-fs 1.6.0", "async-io 1.13.0", "async-lock 2.8.0", - "async-process", + "async-process 1.8.1", "async-recursion", "async-task", "async-trait", @@ -5904,7 +6574,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.4", "once_cell", "ordered-stream", "rand 0.8.6", @@ -5916,9 +6586,71 @@ dependencies = [ "uds_windows", "winapi", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast 0.7.2", + "async-executor", + "async-fs 2.2.0", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process 2.5.0", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d" +dependencies = [ + "zbus_xml", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant 4.2.0", ] [[package]] @@ -5932,7 +6664,20 @@ dependencies = [ "quote", "regex", "syn 1.0.109", - "zvariant_utils", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -5943,7 +6688,31 @@ checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_xml" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233" +dependencies = [ + "quick-xml 0.30.0", + "serde", + "static_assertions", + "zbus_names 3.0.0", + "zvariant 4.2.0", ] [[package]] @@ -6061,7 +6830,20 @@ dependencies = [ "libc", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 3.15.2", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", ] [[package]] @@ -6074,7 +6856,20 @@ dependencies = [ "proc-macro2", "quote", "syn 1.0.109", - "zvariant_utils", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -6087,3 +6882,14 @@ dependencies = [ "quote", "syn 1.0.109", ] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/LoFlum - Standalone.html b/LoFlum - Standalone.html new file mode 100644 index 0000000..ae16ea4 --- /dev/null +++ b/LoFlum - Standalone.html @@ -0,0 +1,179 @@ + + + + + Bundled Page + + + + +
+ + + + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file diff --git a/src/Cargo.toml b/src/Cargo.toml index 92cb36b..16a14be 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -14,9 +14,9 @@ name = "loflum" path = "main.rs" [dependencies] -eframe = { version = "0.28", features = ["default"] } -egui = "0.28" -egui_extras = "0.28" +eframe = { version = "0.29", features = ["default"] } +egui = "0.29" +egui_extras = { version = "0.29", features = ["svg", "image"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } @@ -34,4 +34,5 @@ tracing-subscriber = "0.3" chrono = "0.4" futures-lite = "2" image = "0.25" +muda = "0.19.3" diff --git a/src/fs/local.rs b/src/fs/local.rs index d7fabc3..219c37b 100644 --- a/src/fs/local.rs +++ b/src/fs/local.rs @@ -58,3 +58,46 @@ pub fn home_dir() -> String { .to_string_lossy() .to_string() } + +pub fn rename(from: &str, to: &str) -> Result<()> { + fs::rename(from, to)?; + Ok(()) +} + +pub fn delete(path: &str) -> Result<()> { + let meta = fs::symlink_metadata(path)?; + if meta.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + +pub fn mkdir(path: &str) -> Result<()> { + fs::create_dir(path)?; + Ok(()) +} + +/// Открывает файл/папку в системном приложении по умолчанию. +pub fn open(path: &str) -> Result<()> { + #[cfg(target_os = "macos")] + std::process::Command::new("open").arg(path).spawn()?; + #[cfg(target_os = "linux")] + std::process::Command::new("xdg-open").arg(path).spawn()?; + #[cfg(target_os = "windows")] + std::process::Command::new("cmd") + .args(["/C", "start", "", path]) + .spawn()?; + Ok(()) +} + +/// Перемещает файл/папку в целевую директорию (для drag & drop). +pub fn move_into(src_path: &str, dest_dir: &str) -> Result<()> { + let name = std::path::Path::new(src_path) + .file_name() + .ok_or_else(|| anyhow::anyhow!("invalid source path"))?; + let dest = std::path::Path::new(dest_dir).join(name); + fs::rename(src_path, dest)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 53f71e9..48b787c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,14 +39,37 @@ pub fn run() { let app = FileManagerApp::new(registry, queue, rt, db, sites); + let icon = load_app_icon(); + let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("LoFlum") .with_inner_size([1280.0, 800.0]) - .with_min_inner_size([900.0, 600.0]), + .with_min_inner_size([900.0, 600.0]) + .with_icon(icon), ..Default::default() }; - eframe::run_native("LoFlum", native_options, Box::new(|_cc| Ok(Box::new(app)))) - .expect("failed to start LoFlum"); + eframe::run_native( + "LoFlum", + native_options, + Box::new(|_cc| { + crate::ui::menu::setup_native_menu(); + Ok(Box::new(app)) + }), + ) + .expect("failed to start LoFlum"); +} + +fn load_app_icon() -> egui::IconData { + let bytes = include_bytes!("ui/icons/app_icon.png"); + let image = image::load_from_memory(bytes) + .expect("failed to decode app icon") + .into_rgba8(); + let (width, height) = image.dimensions(); + egui::IconData { + rgba: image.into_raw(), + width, + height, + } } diff --git a/src/protocols/sftp.rs b/src/protocols/sftp.rs index 24fa423..ba1868e 100644 --- a/src/protocols/sftp.rs +++ b/src/protocols/sftp.rs @@ -115,7 +115,11 @@ impl RemoteFs for SftpClient { let session = self.session.clone(); let path = path.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?; let result = entries @@ -159,7 +163,11 @@ impl RemoteFs for SftpClient { let local = local.to_string(); let remote = remote.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; let mut local_file = std::fs::File::open(&local).context("open local file failed")?; let mut remote_file = sftp .create(Path::new(&remote)) @@ -168,7 +176,9 @@ impl RemoteFs for SftpClient { let mut buf = vec![0u8; CHUNK_SIZE]; let mut total = 0u64; loop { - let n = local_file.read(&mut buf).context("read local file failed")?; + let n = local_file + .read(&mut buf) + .context("read local file failed")?; if n == 0 { break; } @@ -204,15 +214,23 @@ impl RemoteFs for SftpClient { let remote = remote.to_string(); let local = local.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; - let mut remote_file = sftp.open(Path::new(&remote)).context("open remote file failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + let mut remote_file = sftp + .open(Path::new(&remote)) + .context("open remote file failed")?; let mut local_file = std::fs::File::create(&local).context("create local file failed")?; let mut buf = vec![0u8; CHUNK_SIZE]; let mut total = 0u64; loop { - let n = remote_file.read(&mut buf).context("read remote file failed")?; + let n = remote_file + .read(&mut buf) + .context("read remote file failed")?; if n == 0 { break; } @@ -242,8 +260,13 @@ impl RemoteFs for SftpClient { let session = self.session.clone(); let path = path.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; - sftp.mkdir(Path::new(&path), 0o755).context("mkdir failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + sftp.mkdir(Path::new(&path), 0o755) + .context("mkdir failed")?; Ok(()) }) .await @@ -255,7 +278,11 @@ impl RemoteFs for SftpClient { let from = from.to_string(); let to = to.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; sftp.rename(Path::new(&from), Path::new(&to), None) .context("rename failed")?; Ok(()) @@ -268,7 +295,11 @@ impl RemoteFs for SftpClient { let session = self.session.clone(); let path = path.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; // пробуем как файл, потом как директорию if sftp.unlink(Path::new(&path)).is_err() { sftp.rmdir(Path::new(&path)).context("delete failed")?; @@ -283,14 +314,18 @@ impl RemoteFs for SftpClient { let session = self.session.clone(); let path = path.to_string(); tokio::task::spawn_blocking(move || { - let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?; + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; let stat = sftp.stat(Path::new(&path)).context("stat failed")?; Ok(FileEntry { name: Path::new(&path) .file_name() .unwrap_or_default() .to_string_lossy() - .to_string(), + .to_string(), path: path.to_string(), kind: if stat.is_dir() { EntryKind::Dir diff --git a/src/storage/db.rs b/src/storage/db.rs index bcf398b..9ece001 100644 --- a/src/storage/db.rs +++ b/src/storage/db.rs @@ -3,6 +3,22 @@ use crate::domain::site::Site; use anyhow::Result; use rusqlite::{Connection, params}; +fn protocol_to_str(protocol: &Protocol) -> &'static str { + match protocol { + Protocol::Sftp => "sftp", + Protocol::Ftp => "ftp", + Protocol::Ftps => "ftps", + } +} + +fn protocol_from_str(s: &str) -> Protocol { + match s { + "sftp" => Protocol::Sftp, + "ftps" => Protocol::Ftps, + _ => Protocol::Ftp, + } +} + pub fn init_tables(conn: &Connection) -> Result<()> { conn.execute_batch( " @@ -19,11 +35,159 @@ pub fn init_tables(conn: &Connection) -> Result<()> { folder TEXT, note TEXT ); + + CREATE TABLE IF NOT EXISTS connection_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + connected_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL + ); ", )?; + + // Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем, + // если их ещё нет (ошибку "duplicate column" просто игнорируем). + let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []); + let _ = conn.execute( + "ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'", + [], + ); + let _ = conn.execute( + "ALTER TABLE connection_history ADD COLUMN key_path TEXT", + [], + ); + // На случай старых записей без уникальности (host,port,username) — оставляем + // только самую свежую строку на каждую цель перед созданием индекса. + conn.execute( + "DELETE FROM connection_history WHERE id NOT IN ( + SELECT MAX(id) FROM connection_history GROUP BY host, port, username + )", + [], + )?; + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_target + ON connection_history(host, port, username)", + [], + )?; + Ok(()) } +pub fn get_bookmarks(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + +pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result { + conn.execute( + "INSERT INTO bookmarks (name, path) VALUES (?1, ?2)", + params![name, path], + )?; + Ok(conn.last_insert_rowid()) +} + +pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> { + conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; + Ok(()) +} + +/// Одна запись истории подключений: host, port, username, время, conn_id +/// (стабильный id — под ним же лежит пароль в keychain), протокол, key_path. +pub type HistoryRow = ( + String, + u16, + String, + String, + String, + Protocol, + Option, +); + +/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы +/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый. +pub fn find_history_conn_id( + conn: &Connection, + host: &str, + port: u16, + username: &str, +) -> Result> { + let id: Option = conn + .query_row( + "SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3", + params![host, port, username], + |row| row.get(0), + ) + .ok(); + Ok(id) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_history_entry( + conn: &Connection, + host: &str, + port: u16, + username: &str, + conn_id: &str, + protocol: &Protocol, + key_path: Option<&str>, +) -> Result<()> { + use chrono::Local; + let connected_at = Local::now().format("%H:%M %d.%m").to_string(); + let proto = protocol_to_str(protocol); + conn.execute( + "INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path) + VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(host, port, username) DO UPDATE SET + connected_at = excluded.connected_at, + protocol = excluded.protocol, + key_path = excluded.key_path, + conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)", + params![host, port, username, connected_at, conn_id, proto, key_path], + )?; + // Храним только последние 20 записей + conn.execute( + "DELETE FROM connection_history WHERE id NOT IN ( + SELECT id FROM connection_history ORDER BY id DESC LIMIT 20 + )", + [], + )?; + Ok(()) +} + +pub fn get_history(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT host, port, username, connected_at, conn_id, protocol, key_path + FROM connection_history ORDER BY id DESC LIMIT 20", + )?; + let rows = stmt + .query_map([], |row| { + let proto_str: String = row.get(5)?; + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + protocol_from_str(&proto_str), + row.get(6)?, + )) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + pub fn get_sites(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name", @@ -31,11 +195,7 @@ pub fn get_sites(conn: &Connection) -> Result> { let sites = stmt .query_map([], |row| { let proto_str: String = row.get(2)?; - let protocol = match proto_str.as_str() { - "sftp" => Protocol::Sftp, - "ftps" => Protocol::Ftps, - _ => Protocol::Ftp, - }; + let protocol = protocol_from_str(&proto_str); Ok(Site { id: row.get(0)?, name: row.get(1)?, @@ -54,11 +214,7 @@ pub fn get_sites(conn: &Connection) -> Result> { } pub fn save_site(conn: &Connection, site: &Site) -> Result<()> { - let proto = match site.protocol { - Protocol::Sftp => "sftp", - Protocol::Ftp => "ftp", - Protocol::Ftps => "ftps", - }; + let proto = protocol_to_str(&site.protocol); conn.execute( "INSERT OR REPLACE INTO sites (id, name, protocol, host, port, username, key_path, folder, note) diff --git a/src/transfer/queue.rs b/src/transfer/queue.rs index 146bed3..e3154d3 100644 --- a/src/transfer/queue.rs +++ b/src/transfer/queue.rs @@ -17,7 +17,12 @@ impl TransferQueue { } pub fn get(&self, id: &str) -> Option { - self.inner.lock().unwrap().iter().find(|t| t.id == id).cloned() + self.inner + .lock() + .unwrap() + .iter() + .find(|t| t.id == id) + .cloned() } pub fn all(&self) -> Vec { diff --git a/src/transfer/worker.rs b/src/transfer/worker.rs index 7cdee9f..b3d8d16 100644 --- a/src/transfer/worker.rs +++ b/src/transfer/worker.rs @@ -94,8 +94,8 @@ pub fn spawn_worker( let task_id_for_progress = task.id.clone(); let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default())); let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64))); - let on_progress: Option ProgressAction + Send>> = Some(Box::new( - move |transferred: u64| { + let on_progress: Option ProgressAction + Send>> = + Some(Box::new(move |transferred: u64| { // Check for user-initiated cancel/pause first. if let Some(t) = queue_for_progress.get(&task_id_for_progress) { match t.state { @@ -119,18 +119,23 @@ pub fn spawn_worker( speed }; if throttle.lock().unwrap().should_emit() { - queue_for_progress.update_progress(&task_id_for_progress, transferred, speed); + queue_for_progress.update_progress( + &task_id_for_progress, + transferred, + speed, + ); } ProgressAction::Continue - }, - )); + })); let result = match task.kind { TransferKind::Upload => { - fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress).await + fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress) + .await } TransferKind::Download => { - fs.download_with_progress(&task.remote_path, &task.local_path, on_progress).await + fs.download_with_progress(&task.remote_path, &task.local_path, on_progress) + .await } }; diff --git a/src/ui/app.rs b/src/ui/app.rs index ce128c4..887f06f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use egui::{CentralPanel, Color32, RichText, SidePanel, TopBottomPanel, Visuals}; +use egui::{CentralPanel, RichText, SidePanel, TopBottomPanel, Visuals}; use crate::domain::connection::ConnectionStatus; use crate::domain::file_entry::{EntryKind, FileEntry}; @@ -35,6 +35,15 @@ impl FileManagerApp { }; local_pane::refresh_local(&mut state); + state.reload_history(&db); + + if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) { + state.bookmarks = rows + .into_iter() + .map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path }) + .collect(); + } + Self { state, registry, @@ -81,8 +90,39 @@ impl FileManagerApp { self.state.status_message = format!("Connected to {}", params.host); self.state.connect_loading = false; self.state.show_connect_dialog = false; - self.state - .add_history(¶ms.host, params.port, ¶ms.username); + + // Один и тот же host/port/username всегда должен использовать + // один conn_id — под ним лежит пароль в keychain, иначе повторное + // подключение через "New Connection" развело бы историю и keychain. + if let Ok(conn) = self.db.lock() { + let canonical_id = crate::storage::db::find_history_conn_id( + &conn, + ¶ms.host, + params.port, + ¶ms.username, + ) + .ok() + .flatten() + .unwrap_or_else(|| params.id.clone()); + + if let Some(password) = ¶ms.password { + let _ = crate::storage::keychain::store_password( + &canonical_id, + password, + ); + } + + let _ = crate::storage::db::add_history_entry( + &conn, + ¶ms.host, + params.port, + ¶ms.username, + &canonical_id, + ¶ms.protocol, + params.key_path.as_deref(), + ); + } + self.state.reload_history(&self.db); } Err(e) => { self.state.status_message = format!("Connection failed: {}", e); @@ -149,6 +189,24 @@ impl FileManagerApp { } } + // --- История: переподключение по клику --- + if let Some(entry) = self.state.pending_history_reconnect.take() { + connection::reconnect_from_history( + &mut self.state, + &self.registry, + self.rt.handle(), + &entry, + ); + } + + // --- История: "Save" → постоянный Site --- + if let Some(entry) = self.state.pending_history_save.take() { + match connection::save_history_as_site(&self.db, &mut self.sites, &entry) { + Ok(()) => self.state.status_message = "Saved to sites".into(), + Err(e) => self.state.status_message = format!("Save failed: {}", e), + } + } + // --- Mkdir result --- let opt = self.state.pending_mkdir_result.take(); if let Some(res) = opt.as_ref() { @@ -346,6 +404,55 @@ impl FileManagerApp { self.state.pending_rename_result = Some(result); } + fn start_local_mkdir(&mut self, name: String) { + let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name); + match crate::fs::local::mkdir(&path) { + Ok(()) => { + self.state.status_message = "Folder created".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Create folder failed: {}", e), + } + } + + fn start_local_delete(&mut self, name: String) { + if name == ".." { + self.state.status_message = "Cannot delete parent entry".into(); + return; + } + let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + match crate::fs::local::delete(&entry.path) { + Ok(()) => { + self.state.status_message = "Deleted".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Delete failed: {}", e), + } + } + + fn start_local_rename(&mut self, old_name: String, new_name: String) { + let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + let from = entry.path.clone(); + let parent_dir = std::path::Path::new(&from) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| self.state.local_path.clone()); + let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name); + match crate::fs::local::rename(&from, &to) { + Ok(()) => { + self.state.status_message = "Renamed".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Rename failed: {}", e), + } + } + fn apply_visuals(&self, ctx: &egui::Context) { let mut vis = Visuals::dark(); vis.window_fill = BG_PANEL; @@ -354,7 +461,7 @@ impl FileManagerApp { vis.code_bg_color = BG_BASE; vis.override_text_color = Some(TEXT_PRIMARY); vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER); - vis.widgets.inactive.bg_fill = Color32::from_rgb(36, 36, 40); + vis.widgets.inactive.bg_fill = BG_CONTENT; vis.widgets.hovered.bg_fill = BG_ROW_HOVER; vis.widgets.active.bg_fill = ACCENT_DIM; vis.selection.bg_fill = BG_ROW_SEL; @@ -362,8 +469,7 @@ impl FileManagerApp { vis.window_rounding = egui::Rounding::same(8.0); ctx.set_visuals(vis); - let fonts = egui::FontDefinitions::default(); - ctx.set_fonts(fonts); + ctx.set_fonts(system_fonts()); let mut style = ctx.style().as_ref().clone(); style.spacing.item_spacing = egui::vec2(4.0, 2.0); @@ -376,11 +482,13 @@ impl eframe::App for FileManagerApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.first_frame { self.first_frame = false; + egui_extras::install_image_loaders(ctx); self.apply_visuals(ctx); ctx.request_repaint(); } self.poll_pending(); + crate::ui::menu::poll_menu_events(&mut self.state); self.state.queue_tasks = self.queue.all(); self.state.connected_count = self .state @@ -389,11 +497,168 @@ impl eframe::App for FileManagerApp { .filter(|t| t.status == ConnectionStatus::Connected) .count(); + if self.state.tabs.is_empty() { + self.render_welcome_screen(ctx); + } else { + self.render_main_ui(ctx); + } + + // ── Connection dialog ──────────────────────────────────────────────── + if self.state.show_connect_dialog { + connection::render(ctx, &mut self.state, &self.registry, self.rt.handle()); + } + + // ── Remote operation dialogs ───────────────────────────────────────── + self.render_remote_op_dialogs(ctx); + + // ── Settings ───────────────────────────────────────────────────────── + if self.state.show_settings_dialog { + self.render_settings_dialog(ctx); + } + + // 200ms repaint для обновления прогресса + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +impl FileManagerApp { + /// Стартовый экран — показывается, пока нет ни одного открытого соединения. + /// Только фон, логотип, кнопка нового подключения и список недавних серверов. + fn render_welcome_screen(&mut self, ctx: &egui::Context) { + CentralPanel::default() + .frame(egui::Frame::none().fill(BG_BASE)) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + let avail_h = ui.available_height(); + ui.add_space((avail_h * 0.16).max(24.0)); + + let icon_bytes: &[u8] = include_bytes!("icons/app_icon.png"); + let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes) + .rounding(RADIUS_LG) + .fit_to_exact_size(egui::vec2(72.0, 72.0)); + ui.add(icon_img); + + ui.add_space(14.0); + ui.label( + RichText::new("LoFlum") + .color(TEXT_PRIMARY) + .size(20.0) + .strong(), + ); + ui.add_space(4.0); + ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5)); + + ui.add_space(20.0); + let btn = egui::Button::image_and_text( + crate::ui::icons::image( + crate::ui::icons::Icon::AddCircleBold, + 15.0, + ON_ACCENT, + ), + RichText::new("New Connection") + .color(ON_ACCENT) + .size(12.5) + .strong(), + ) + .fill(ACCENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(170.0, 36.0)); + if ui.add(btn).clicked() { + self.state.show_connect_dialog = true; + } + + ui.add_space(28.0); + + let list_w = 320.0_f32.min(ui.available_width() - 40.0); + if !self.state.history.is_empty() { + ui.label( + RichText::new("RECENT CONNECTIONS") + .color(TEXT_HINT) + .size(10.5) + .strong(), + ); + ui.add_space(8.0); + + const ROW_H: f32 = 34.0; + egui::Frame::none().show(ui, |ui| { + ui.set_width(list_w); + let history: Vec<_> = + self.state.history.iter().take(8).cloned().collect(); + for entry in &history { + let row_rect = egui::Rect::from_min_size( + ui.cursor().min, + egui::vec2(list_w, ROW_H), + ); + let is_hovered = ui.rect_contains_pointer(row_rect); + let bg = if is_hovered { + BG_ROW_HOVER + } else { + egui::Color32::TRANSPARENT + }; + + let label = format!("{}@{}:{}", entry.user, entry.host, entry.port); + let row = egui::Frame::none() + .fill(bg) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin::symmetric(10.0, 7.0)) + .show(ui, |ui| { + ui.set_width(list_w - 20.0); + ui.horizontal(|ui| { + crate::ui::icons::icon( + ui, + crate::ui::icons::Icon::ServerSquare, + 13.0, + TEXT_DIM, + ); + ui.add_space(8.0); + ui.label( + RichText::new(&label) + .color(TEXT_PRIMARY) + .size(12.5), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(&entry.time) + .color(TEXT_HINT) + .size(10.5), + ); + }, + ); + }); + }); + + let resp = row.response.interact(egui::Sense::click()); + if is_hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if resp.clicked() { + self.state.pending_history_reconnect = Some(entry.clone()); + } + resp.context_menu(|ui| { + if ui.button("Edit").clicked() { + connection::edit_history_entry(&mut self.state, entry); + ui.close_menu(); + } + if ui.button("Save").clicked() { + self.state.pending_history_save = Some(entry.clone()); + ui.close_menu(); + } + }); + } + }); + } + }); + }); + } + + fn render_main_ui(&mut self, ctx: &egui::Context) { // ── Toolbar ────────────────────────────────────────────────────────── TopBottomPanel::top("toolbar") .frame(egui::Frame::none().fill(BG_TOOLBAR)) .show(ctx, |ui| { - toolbar::render(ui, &mut self.state); + toolbar::render(ui, &mut self.state, &self.queue); }); // ── Tab bar ────────────────────────────────────────────────────────── @@ -441,7 +706,7 @@ impl eframe::App for FileManagerApp { .default_width(SIDEBAR_W) .width_range(120.0..=260.0) .show(ctx, |ui| { - sidebar::render(ui, &mut self.state); + sidebar::render(ui, &mut self.state, &self.db); }); // ── Main content: Local | Remote ───────────────────────────────────── @@ -460,15 +725,13 @@ impl eframe::App for FileManagerApp { egui::vec2(half, total_h), egui::Layout::top_down(egui::Align::LEFT), |ui| { - // Хедер панели - pane_header(ui, "LOCAL", half); - local_pane::render( ui, &mut self.state, &self.queue, &self.registry, self.rt.handle(), + &self.db, ); }, ); @@ -485,10 +748,6 @@ impl eframe::App for FileManagerApp { if has_connection { let idx = self.state.active_tab.min(self.state.tabs.len() - 1); - // Хедер панели - let label = self.state.tabs[idx].params.host.clone(); - pane_header(ui, &format!("REMOTE · {}", label), half); - remote_pane::render( ui, &mut self.state, @@ -504,10 +763,35 @@ impl eframe::App for FileManagerApp { egui::Layout::centered_and_justified(egui::Direction::TopDown), |ui| { ui.vertical_centered(|ui| { - ui.add_space(ui.available_height() * 0.3); + ui.add_space(ui.available_height() * 0.28); + + let circle_d = 56.0; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(circle_d, circle_d), + egui::Sense::hover(), + ); + ui.painter().circle_filled( + rect.center(), + circle_d / 2.0, + BG_TAB_ACTIVE, + ); + let icon_img = crate::ui::icons::image( + crate::ui::icons::Icon::ServerSquare, + 26.0, + TEXT_HINT, + ); + icon_img.paint_at( + ui, + egui::Rect::from_center_size( + rect.center(), + egui::vec2(26.0, 26.0), + ), + ); + + ui.add_space(10.0); ui.label( RichText::new("Remote") - .color(TEXT_HINT) + .color(TEXT_DIM) .size(14.0) .strong(), ); @@ -520,13 +804,19 @@ impl eframe::App for FileManagerApp { .size(12.0), ); ui.add_space(16.0); - let btn = egui::Button::new( - RichText::new("+ New Connection") - .color(Color32::WHITE) - .size(12.0), + let btn = egui::Button::image_and_text( + crate::ui::icons::image( + crate::ui::icons::Icon::AddCircleBold, + 15.0, + ON_ACCENT, + ), + RichText::new("New Connection") + .color(ON_ACCENT) + .size(12.5) + .strong(), ) .fill(ACCENT) - .rounding(6.0) + .rounding(RADIUS_MD) .min_size(egui::vec2(150.0, 34.0)); if ui.add(btn).clicked() { self.state.show_connect_dialog = true; @@ -539,17 +829,6 @@ impl eframe::App for FileManagerApp { ); }); }); - - // ── Connection dialog ──────────────────────────────────────────────── - if self.state.show_connect_dialog { - connection::render(ctx, &mut self.state, &self.registry, self.rt.handle()); - } - - // ── Remote operation dialogs ───────────────────────────────────────── - self.render_remote_op_dialogs(ctx); - - // 200ms repaint для обновления прогресса - ctx.request_repaint_after(std::time::Duration::from_millis(200)); } } @@ -588,7 +867,7 @@ impl FileManagerApp { egui::Frame::none() .fill(BG_PANEL) .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(8.0) + .rounding(RADIUS_LG) .inner_margin(egui::Margin::same(16.0)), ) .open(&mut open) @@ -600,19 +879,20 @@ impl FileManagerApp { ui.add_space(16.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = egui::Button::new( - RichText::new("OK").color(Color32::WHITE).size(12.0), - ) - .fill(ACCENT) - .rounding(4.0); + let ok = + egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) + .fill(ACCENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(ok).clicked() { clicked_ok = true; } ui.add_space(8.0); let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.0), + RichText::new("Cancel").color(TEXT_DIM).size(12.5), ) - .fill(egui::Color32::TRANSPARENT); + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(cancel).clicked() { self.state.show_mkdir_dialog = false; self.state.mkdir_name.clear(); @@ -622,9 +902,14 @@ impl FileManagerApp { }); if clicked_ok && !self.state.mkdir_name.is_empty() { - if let Some(idx) = self.active_tab_idx() { - let name = self.state.mkdir_name.clone(); - self.start_mkdir(idx, name); + let name = self.state.mkdir_name.clone(); + match self.state.op_target { + crate::ui::state::Pane::Local => self.start_local_mkdir(name), + crate::ui::state::Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_mkdir(idx, name); + } + } } self.state.show_mkdir_dialog = false; } @@ -649,33 +934,50 @@ impl FileManagerApp { egui::Frame::none() .fill(BG_PANEL) .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(8.0) + .rounding(RADIUS_LG) .inner_margin(egui::Margin::same(16.0)), ) .open(&mut open) .show(ctx, |ui| { ui.set_width(260.0); - ui.label( - RichText::new(format!("Delete '{}' ?", name)) - .color(TEXT_PRIMARY) - .strong(), - ); + ui.horizontal(|ui| { + crate::ui::icons::icon( + ui, + crate::ui::icons::Icon::DangerTriangle, + 18.0, + RED, + ); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(format!("Delete \"{}\"?", name)) + .color(TEXT_PRIMARY) + .strong(), + ); + ui.label( + RichText::new("This action cannot be undone.") + .color(TEXT_DIM) + .size(11.0), + ); + }); + }); ui.add_space(16.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = egui::Button::new( - RichText::new("OK").color(Color32::WHITE).size(12.0), - ) - .fill(RED) - .rounding(4.0); + let ok = + egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) + .fill(RED) + .rounding(RADIUS_MD) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(ok).clicked() { clicked_ok = true; } ui.add_space(8.0); let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.0), + RichText::new("Cancel").color(TEXT_DIM).size(12.5), ) - .fill(egui::Color32::TRANSPARENT); + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(cancel).clicked() { self.state.show_delete_dialog = false; self.state.delete_name.clear(); @@ -685,8 +987,13 @@ impl FileManagerApp { }); if clicked_ok { - if let Some(idx) = self.active_tab_idx() { - self.start_delete(idx, name); + match self.state.op_target { + crate::ui::state::Pane::Local => self.start_local_delete(name), + crate::ui::state::Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_delete(idx, name); + } + } } self.state.show_delete_dialog = false; } @@ -710,7 +1017,7 @@ impl FileManagerApp { egui::Frame::none() .fill(BG_PANEL) .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(8.0) + .rounding(RADIUS_LG) .inner_margin(egui::Margin::same(16.0)), ) .open(&mut open) @@ -722,19 +1029,20 @@ impl FileManagerApp { ui.add_space(16.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = egui::Button::new( - RichText::new("OK").color(Color32::WHITE).size(12.0), - ) - .fill(ACCENT) - .rounding(4.0); + let ok = + egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) + .fill(ACCENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(ok).clicked() { clicked_ok = true; } ui.add_space(8.0); let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.0), + RichText::new("Cancel").color(TEXT_DIM).size(12.5), ) - .fill(egui::Color32::TRANSPARENT); + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(70.0, 30.0)); if ui.add(cancel).clicked() { self.state.show_rename_dialog = false; self.state.rename_new_name.clear(); @@ -745,10 +1053,15 @@ impl FileManagerApp { }); if clicked_ok && !self.state.rename_new_name.is_empty() { - if let Some(idx) = self.active_tab_idx() { - let old_name = self.state.rename_old_name.clone(); - let new_name = self.state.rename_new_name.clone(); - self.start_rename(idx, old_name, new_name); + let old_name = self.state.rename_old_name.clone(); + let new_name = self.state.rename_new_name.clone(); + match self.state.op_target { + crate::ui::state::Pane::Local => self.start_local_rename(old_name, new_name), + crate::ui::state::Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_rename(idx, old_name, new_name); + } + } } self.state.show_rename_dialog = false; } @@ -759,22 +1072,142 @@ impl FileManagerApp { } } } + + fn render_settings_dialog(&mut self, ctx: &egui::Context) { + let screen = ctx.screen_rect(); + let center = screen.center(); + + egui::Area::new(egui::Id::new("settings_overlay")) + .fixed_pos(egui::Pos2::ZERO) + .order(egui::Order::Background) + .show(ctx, |ui| { + let r = ui.allocate_rect(screen, egui::Sense::click()); + ui.painter() + .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); + if r.clicked() { + self.state.show_settings_dialog = false; + } + }); + + let mut open = true; + egui::Window::new("settings_dialog") + .collapsible(false) + .title_bar(false) + .resizable(false) + .default_pos(center) + .pivot(egui::Align2::CENTER_CENTER) + .frame( + egui::Frame::none() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(RADIUS_LG) + .inner_margin(egui::Margin::same(20.0)), + ) + .open(&mut open) + .show(ctx, |ui| { + ui.set_width(340.0); + ui.horizontal(|ui| { + crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT); + ui.add_space(8.0); + ui.label( + RichText::new("Settings") + .color(TEXT_PRIMARY) + .size(15.0) + .strong(), + ); + }); + ui.add_space(16.0); + + settings_row(ui, "Version", env!("CARGO_PKG_VERSION")); + let db_path = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("loflum") + .join("loflum.db"); + settings_row(ui, "Sites database", &db_path.to_string_lossy()); + + ui.add_space(16.0); + ui.label( + RichText::new("More settings are on the way.") + .color(TEXT_HINT) + .size(11.0), + ); + + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close = + egui::Button::new(RichText::new("Close").color(ON_ACCENT).size(12.5)) + .fill(ACCENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(80.0, 30.0)); + if ui.add(close).clicked() { + self.state.show_settings_dialog = false; + } + }); + }); + + if !open { + self.state.show_settings_dialog = false; + } + } +} + +fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(TEXT_DIM).size(11.5)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(value).color(TEXT_HINT).monospace().size(10.5)); + }); + }); + ui.add_space(6.0); +} + +/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта, +/// чтобы интерфейс выглядел нативно, как в исходном макете. На других ОС +/// файлы просто отсутствуют — тогда остаются встроенные шрифты egui. +fn system_fonts() -> egui::FontDefinitions { + let mut fonts = egui::FontDefinitions::default(); + + let candidates: &[(&str, &str, egui::FontFamily)] = &[ + ( + "system-sans", + "/System/Library/Fonts/SFNS.ttf", + egui::FontFamily::Proportional, + ), + ( + "system-mono", + "/System/Library/Fonts/SFNSMono.ttf", + egui::FontFamily::Monospace, + ), + ]; + + for (name, path, family) in candidates { + if let Ok(bytes) = std::fs::read(path) { + fonts + .font_data + .insert((*name).to_owned(), egui::FontData::from_owned(bytes)); + fonts + .families + .entry(family.clone()) + .or_default() + .insert(0, (*name).to_owned()); + } + } + + fonts } fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) { egui::Frame::none() .fill(BG_BASE) - .inner_margin(egui::Margin { - left: 10.0, - right: 6.0, - top: 4.0, - bottom: 4.0, - }) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)) .show(ui, |ui| { - ui.set_width(width); - ui.horizontal(|ui| { - ui.label(RichText::new(label).color(TEXT_DIM).size(10.0).strong()); - }); + ui.allocate_ui_with_layout( + egui::vec2(width - 24.0, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(RichText::new(label).color(TEXT_DIM).size(10.5).strong()); + }, + ); }); let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0)); diff --git a/src/ui/dialogs/connection.rs b/src/ui/dialogs/connection.rs index 75e973d..0da451a 100644 --- a/src/ui/dialogs/connection.rs +++ b/src/ui/dialogs/connection.rs @@ -20,7 +20,7 @@ pub fn render( // Центрируем окно let screen = ctx.screen_rect(); - let win_size = egui::vec2(420.0, 340.0); + let win_size = egui::vec2(380.0, 340.0); let win_pos = egui::pos2( (screen.width() - win_size.x) * 0.5, (screen.height() - win_size.y) * 0.4, @@ -43,9 +43,9 @@ pub fn render( .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::none() - .fill(Color32::from_rgb(30, 30, 34)) + .fill(BG_PANEL) .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(10.0) + .rounding(RADIUS_LG) .shadow(egui::Shadow { offset: egui::vec2(0.0, 8.0), blur: 24.0, @@ -80,15 +80,11 @@ pub fn render( ui.horizontal(|ui| { for (i, p) in protocols.iter().enumerate() { let active = state.connect_protocol == i; - let bg = if active { - ACCENT - } else { - Color32::from_rgb(40, 40, 44) - }; - let tc = if active { Color32::WHITE } else { TEXT_DIM }; - let btn = egui::Button::new(RichText::new(*p).color(tc).size(12.0)) + let bg = if active { ACCENT_DIM } else { BG_CONTENT }; + let tc = if active { TEXT_PRIMARY } else { TEXT_DIM }; + let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5)) .fill(bg) - .rounding(4.0) + .rounding(RADIUS_MD) .min_size(egui::vec2(64.0, 28.0)); if ui.add(btn).clicked() { state.connect_protocol = i; @@ -99,27 +95,20 @@ pub fn render( ui.add_space(14.0); - // Поля ввода + // Поля ввода — Host / Username+Port / Password, как в макете let field_w = ui.available_width(); - field( - ui, - "Label (optional)", - &mut state.connect_label, - field_w, - false, - ); + + field(ui, "Host", &mut state.connect_host, field_w, false); ui.add_space(8.0); ui.horizontal(|ui| { - let host_w = field_w - 80.0 - 8.0; - field(ui, "Host", &mut state.connect_host, host_w, false); + let user_w = field_w - 80.0 - 8.0; + field(ui, "Username", &mut state.connect_user, user_w, false); ui.add_space(8.0); field(ui, "Port", &mut state.connect_port, 80.0, false); }); ui.add_space(8.0); - field(ui, "Username", &mut state.connect_user, field_w, false); - ui.add_space(8.0); field(ui, "Password", &mut state.connect_pass, field_w, true); if state.connect_protocol == 0 { @@ -127,7 +116,7 @@ pub fn render( ui.horizontal(|ui| { field( ui, - "Key path", + "Key path (optional)", &mut state.connect_key_path, field_w - 80.0 - 8.0, false, @@ -137,22 +126,43 @@ pub fn render( let browse = egui::Button::new( RichText::new("Browse").color(TEXT_DIM).size(11.0), ) - .fill(Color32::from_rgb(40, 40, 44)) + .fill(BG_CONTENT) .min_size(egui::vec2(72.0, 32.0)) - .rounding(4.0); + .rounding(RADIUS_MD); ui.add_enabled(false, browse); }); } + ui.add_space(8.0); + field( + ui, + "Label (optional)", + &mut state.connect_label, + field_w, + false, + ); + // Ошибка if !state.connect_error.is_empty() { ui.add_space(10.0); egui::Frame::none() - .fill(Color32::from_rgb(60, 25, 25)) - .rounding(4.0) - .inner_margin(egui::Margin::symmetric(8.0, 6.0)) + .fill(Color32::from_rgb(42, 27, 22)) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(70, 40, 34))) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) .show(ui, |ui| { - ui.label(RichText::new(&state.connect_error).color(RED).size(11.0)); + ui.horizontal(|ui| { + crate::ui::icons::icon( + ui, + crate::ui::icons::Icon::DangerTriangle, + 14.0, + RED, + ); + ui.add_space(6.0); + ui.label( + RichText::new(&state.connect_error).color(RED).size(11.0), + ); + }); }); } @@ -161,10 +171,10 @@ pub fn render( // Кнопки ui.horizontal(|ui| { let cancel = - egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.0)) - .fill(Color32::from_rgb(40, 40, 44)) - .rounding(6.0) - .min_size(egui::vec2(90.0, 34.0)); + egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5)) + .fill(Color32::TRANSPARENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(90.0, 30.0)); if ui.add(cancel).clicked() && !state.connect_loading { state.show_connect_dialog = false; } @@ -178,13 +188,13 @@ pub fn render( }; let connect_btn = egui::Button::new( RichText::new(connect_label) - .color(Color32::WHITE) - .size(12.0) + .color(ON_ACCENT) + .size(12.5) .strong(), ) .fill(ACCENT) - .rounding(6.0) - .min_size(egui::vec2(110.0, 34.0)); + .rounding(RADIUS_MD) + .min_size(egui::vec2(110.0, 30.0)); if ui.add(connect_btn).clicked() { do_connect(state, registry, rt_handle); @@ -218,6 +228,19 @@ fn do_connect( ) { state.connect_error.clear(); + if state.connect_host.trim().is_empty() { + state.connect_error = "Host is required.".into(); + return; + } + if state.connect_user.trim().is_empty() { + state.connect_error = "Username is required.".into(); + return; + } + if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::().is_err() { + state.connect_error = "Port must be a number between 1 and 65535.".into(); + return; + } + let protocol = match state.connect_protocol { 0 => Protocol::Sftp, 1 => Protocol::Ftp, @@ -261,6 +284,17 @@ fn do_connect( }, }; + spawn_connect(state, registry, rt_handle, params); +} + +/// Общий код запуска подключения — используется и диалогом New Connection, +/// и переподключением из истории (см. `reconnect_from_history`). +pub fn spawn_connect( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + params: ConnectionParams, +) { let registry = registry.clone(); let params_clone = params.clone(); let result = Arc::new(std::sync::Mutex::new(None)); @@ -275,6 +309,80 @@ fn do_connect( state.pending_connect = Some(PendingConnect { result }); } +/// Переподключение по клику на запись истории — пароль (если есть) уже лежит +/// в keychain под `entry.conn_id`, поэтому диалог не нужен. +pub fn reconnect_from_history( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + entry: &crate::ui::state::HistoryEntry, +) { + state.connect_error.clear(); + let params = ConnectionParams { + id: entry.conn_id.clone(), + label: format!("{}@{}", entry.user, entry.host), + protocol: entry.protocol.clone(), + host: entry.host.clone(), + port: entry.port, + username: entry.user.clone(), + password: None, + key_path: entry.key_path.clone(), + }; + spawn_connect(state, registry, rt_handle, params); +} + +fn protocol_index(protocol: &Protocol) -> usize { + match protocol { + Protocol::Sftp => 0, + Protocol::Ftp => 1, + Protocol::Ftps => 2, + } +} + +/// "Изменить" в меню истории — открывает диалог New Connection, предзаполненный +/// этой записью (включая пароль из keychain, если он там есть). +pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) { + state.connect_error.clear(); + state.connect_host = entry.host.clone(); + state.connect_user = entry.user.clone(); + state.connect_port = entry.port.to_string(); + state.connect_protocol = protocol_index(&entry.protocol); + state.connect_key_path = entry.key_path.clone().unwrap_or_default(); + state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default(); + state.connect_label.clear(); + state.show_connect_dialog = true; +} + +/// "Сохранить" в меню истории — превращает разовое подключение в постоянный Site. +pub fn save_history_as_site( + db: &Arc>, + sites: &mut Vec, + entry: &crate::ui::state::HistoryEntry, +) -> Result<(), String> { + let site = crate::domain::site::Site { + id: entry.conn_id.clone(), + name: format!("{}@{}", entry.user, entry.host), + protocol: entry.protocol.clone(), + host: entry.host.clone(), + port: entry.port, + username: entry.user.clone(), + key_path: entry.key_path.clone(), + folder: None, + note: None, + }; + let conn = db + .lock() + .map_err(|_| "database lock poisoned".to_string())?; + crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?; + drop(conn); + if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) { + *existing = site; + } else { + sites.push(site); + } + Ok(()) +} + async fn connect_async( registry: &RemoteRegistry, params: &ConnectionParams, diff --git a/src/ui/icons.rs b/src/ui/icons.rs new file mode 100644 index 0000000..b7681a3 --- /dev/null +++ b/src/ui/icons.rs @@ -0,0 +1,98 @@ +//! Solar icon set (https://solar-icons.vercel.app) — встроенные SVG, красятся через tint() +use egui::{Color32, Ui}; + +macro_rules! icon_set { + ($($key:ident => $file:literal),* $(,)?) => { + pub enum Icon { $($key),* } + + fn bytes(icon: &Icon) -> &'static [u8] { + match icon { + $(Icon::$key => include_bytes!(concat!("icons/svg/", $file, ".svg"))),* + } + } + + fn uri(icon: &Icon) -> &'static str { + match icon { + $(Icon::$key => concat!("bytes://solar/", $file, ".svg")),* + } + } + }; +} + +icon_set! { + Folder => "folder-bold", + FolderWithFiles => "folder-with-files-linear", + AddCircleLinear => "add-circle-linear", + AddCircleBold => "add-circle-bold", + AddSquare => "add-square-linear", + Upload => "upload-linear", + Download => "download-linear", + DownloadSquare => "download-square-bold", + Clock => "clock-circle-linear", + Refresh => "refresh-linear", + Trash => "trash-bin-minimalistic-linear", + Pen => "pen-linear", + History => "history-linear", + ServerSquare => "server-square-linear", + Home => "home-2-bold", + Monitor => "monitor-bold", + Document => "document-bold", + DocumentText => "document-text-bold", + Ssd => "ssd-square-linear", + Database => "database-linear", + DangerTriangle => "danger-triangle-bold", + LockPassword => "lock-password-bold", + ArrowDown => "alt-arrow-down-linear", + ArrowUp => "alt-arrow-up-linear", + CheckCircle => "check-circle-bold", + PlayCircle => "play-circle-linear", + PauseCircle => "pause-circle-linear", + FileText => "file-text-bold", + Gallery => "gallery-bold", + Code => "code-bold", + Archive => "archive-down-minimlistic-bold", + ShieldKeyhole => "shield-keyhole-bold", + File => "file-bold", + Star => "star-bold", + Videocamera => "videocamera-record-bold", + MusicNote => "music-note-3-bold", + Link => "link-bold", + CloseCircle => "close-circle-linear", + Settings => "settings-linear", +} + +/// Рисует иконку заданного размера и цвета (tint) в текущей позиции ui. +pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> egui::Response { + let image = egui::Image::from_bytes(uri(&icon), bytes(&icon)) + .tint(color) + .fit_to_exact_size(egui::vec2(size, size)); + ui.add(image) +} + +/// Тот же рендер, но для использования внутри builder-паттернов (кнопки и т.д.) — возвращает Image. +pub fn image(icon: Icon, size: f32, color: Color32) -> egui::Image<'static> { + egui::Image::from_bytes(uri(&icon), bytes(&icon)) + .tint(color) + .fit_to_exact_size(egui::vec2(size, size)) +} + +/// Цвет + иконка по расширению файла — палитра Clay +pub fn file_icon_for(ext: &str) -> (Icon, Color32) { + use crate::ui::theme::*; + match ext { + "pdf" => (Icon::FileText, RED), + "xlsx" | "xls" | "doc" | "docx" => (Icon::Document, GREEN), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => (Icon::Gallery, ACCENT), + "mp4" | "mkv" | "avi" | "mov" => (Icon::Videocamera, ACCENT), + "mp3" | "ogg" | "flac" | "wav" => (Icon::MusicNote, GREEN), + "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => (Icon::Archive, TEXT_DIM), + "yaml" | "yml" | "toml" | "json" => (Icon::Code, YELLOW), + "php" | "java" | "class" | "jar" => (Icon::Code, ACCENT_DIM), + "rs" | "py" | "sh" | "bash" | "fish" | "zsh" => (Icon::Code, GREEN), + "html" | "css" | "js" | "ts" => (Icon::Code, ACCENT), + "log" => (Icon::DocumentText, TEXT_HINT), + "htaccess" => (Icon::ShieldKeyhole, TEXT_DIM), + "txt" | "md" => (Icon::DocumentText, TEXT_DIM), + _ => (Icon::File, TEXT_DIM), + } +} diff --git a/src/ui/icons/app_icon.png b/src/ui/icons/app_icon.png new file mode 100644 index 0000000..fafaf4d Binary files /dev/null and b/src/ui/icons/app_icon.png differ diff --git a/src/ui/icons/svg/add-circle-bold.svg b/src/ui/icons/svg/add-circle-bold.svg new file mode 100644 index 0000000..91d578e --- /dev/null +++ b/src/ui/icons/svg/add-circle-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/add-circle-linear.svg b/src/ui/icons/svg/add-circle-linear.svg new file mode 100644 index 0000000..34dad30 --- /dev/null +++ b/src/ui/icons/svg/add-circle-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/add-square-linear.svg b/src/ui/icons/svg/add-square-linear.svg new file mode 100644 index 0000000..3293451 --- /dev/null +++ b/src/ui/icons/svg/add-square-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/alt-arrow-down-linear.svg b/src/ui/icons/svg/alt-arrow-down-linear.svg new file mode 100644 index 0000000..77a426d --- /dev/null +++ b/src/ui/icons/svg/alt-arrow-down-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/alt-arrow-up-linear.svg b/src/ui/icons/svg/alt-arrow-up-linear.svg new file mode 100644 index 0000000..5644a65 --- /dev/null +++ b/src/ui/icons/svg/alt-arrow-up-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/app_icon_source.svg b/src/ui/icons/svg/app_icon_source.svg new file mode 100644 index 0000000..a17ab03 --- /dev/null +++ b/src/ui/icons/svg/app_icon_source.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/icons/svg/archive-down-minimlistic-bold.svg b/src/ui/icons/svg/archive-down-minimlistic-bold.svg new file mode 100644 index 0000000..576d4a6 --- /dev/null +++ b/src/ui/icons/svg/archive-down-minimlistic-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/check-circle-bold.svg b/src/ui/icons/svg/check-circle-bold.svg new file mode 100644 index 0000000..accfabb --- /dev/null +++ b/src/ui/icons/svg/check-circle-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/clock-circle-linear.svg b/src/ui/icons/svg/clock-circle-linear.svg new file mode 100644 index 0000000..550f088 --- /dev/null +++ b/src/ui/icons/svg/clock-circle-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/close-circle-linear.svg b/src/ui/icons/svg/close-circle-linear.svg new file mode 100644 index 0000000..a4c1e86 --- /dev/null +++ b/src/ui/icons/svg/close-circle-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/code-bold.svg b/src/ui/icons/svg/code-bold.svg new file mode 100644 index 0000000..e32d02f --- /dev/null +++ b/src/ui/icons/svg/code-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/danger-triangle-bold.svg b/src/ui/icons/svg/danger-triangle-bold.svg new file mode 100644 index 0000000..b4ce5b9 --- /dev/null +++ b/src/ui/icons/svg/danger-triangle-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/database-linear.svg b/src/ui/icons/svg/database-linear.svg new file mode 100644 index 0000000..a74a9bb --- /dev/null +++ b/src/ui/icons/svg/database-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/document-bold.svg b/src/ui/icons/svg/document-bold.svg new file mode 100644 index 0000000..bbd398d --- /dev/null +++ b/src/ui/icons/svg/document-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/document-text-bold.svg b/src/ui/icons/svg/document-text-bold.svg new file mode 100644 index 0000000..bf8b699 --- /dev/null +++ b/src/ui/icons/svg/document-text-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/download-linear.svg b/src/ui/icons/svg/download-linear.svg new file mode 100644 index 0000000..3f9cffe --- /dev/null +++ b/src/ui/icons/svg/download-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/download-square-bold.svg b/src/ui/icons/svg/download-square-bold.svg new file mode 100644 index 0000000..065f86f --- /dev/null +++ b/src/ui/icons/svg/download-square-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/file-bold.svg b/src/ui/icons/svg/file-bold.svg new file mode 100644 index 0000000..06521ec --- /dev/null +++ b/src/ui/icons/svg/file-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/file-text-bold.svg b/src/ui/icons/svg/file-text-bold.svg new file mode 100644 index 0000000..4a8f361 --- /dev/null +++ b/src/ui/icons/svg/file-text-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/folder-bold.svg b/src/ui/icons/svg/folder-bold.svg new file mode 100644 index 0000000..76063d5 --- /dev/null +++ b/src/ui/icons/svg/folder-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/folder-with-files-linear.svg b/src/ui/icons/svg/folder-with-files-linear.svg new file mode 100644 index 0000000..5c350a8 --- /dev/null +++ b/src/ui/icons/svg/folder-with-files-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/gallery-bold.svg b/src/ui/icons/svg/gallery-bold.svg new file mode 100644 index 0000000..304fda9 --- /dev/null +++ b/src/ui/icons/svg/gallery-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/history-linear.svg b/src/ui/icons/svg/history-linear.svg new file mode 100644 index 0000000..27c1774 --- /dev/null +++ b/src/ui/icons/svg/history-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/home-2-bold.svg b/src/ui/icons/svg/home-2-bold.svg new file mode 100644 index 0000000..ecde3f3 --- /dev/null +++ b/src/ui/icons/svg/home-2-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/link-bold.svg b/src/ui/icons/svg/link-bold.svg new file mode 100644 index 0000000..c24561c --- /dev/null +++ b/src/ui/icons/svg/link-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/lock-password-bold.svg b/src/ui/icons/svg/lock-password-bold.svg new file mode 100644 index 0000000..1282daa --- /dev/null +++ b/src/ui/icons/svg/lock-password-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/monitor-bold.svg b/src/ui/icons/svg/monitor-bold.svg new file mode 100644 index 0000000..4a4fdc7 --- /dev/null +++ b/src/ui/icons/svg/monitor-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/music-note-3-bold.svg b/src/ui/icons/svg/music-note-3-bold.svg new file mode 100644 index 0000000..e0439c3 --- /dev/null +++ b/src/ui/icons/svg/music-note-3-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/pause-circle-linear.svg b/src/ui/icons/svg/pause-circle-linear.svg new file mode 100644 index 0000000..d85a057 --- /dev/null +++ b/src/ui/icons/svg/pause-circle-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/pen-linear.svg b/src/ui/icons/svg/pen-linear.svg new file mode 100644 index 0000000..6133da3 --- /dev/null +++ b/src/ui/icons/svg/pen-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/play-circle-linear.svg b/src/ui/icons/svg/play-circle-linear.svg new file mode 100644 index 0000000..5566507 --- /dev/null +++ b/src/ui/icons/svg/play-circle-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/refresh-linear.svg b/src/ui/icons/svg/refresh-linear.svg new file mode 100644 index 0000000..37e7989 --- /dev/null +++ b/src/ui/icons/svg/refresh-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/server-square-linear.svg b/src/ui/icons/svg/server-square-linear.svg new file mode 100644 index 0000000..ec11364 --- /dev/null +++ b/src/ui/icons/svg/server-square-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/settings-linear.svg b/src/ui/icons/svg/settings-linear.svg new file mode 100644 index 0000000..36399b8 --- /dev/null +++ b/src/ui/icons/svg/settings-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/shield-keyhole-bold.svg b/src/ui/icons/svg/shield-keyhole-bold.svg new file mode 100644 index 0000000..e4578ac --- /dev/null +++ b/src/ui/icons/svg/shield-keyhole-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/ssd-square-linear.svg b/src/ui/icons/svg/ssd-square-linear.svg new file mode 100644 index 0000000..e84f97c --- /dev/null +++ b/src/ui/icons/svg/ssd-square-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/star-bold.svg b/src/ui/icons/svg/star-bold.svg new file mode 100644 index 0000000..c3b53b6 --- /dev/null +++ b/src/ui/icons/svg/star-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/trash-bin-minimalistic-linear.svg b/src/ui/icons/svg/trash-bin-minimalistic-linear.svg new file mode 100644 index 0000000..75d1a4e --- /dev/null +++ b/src/ui/icons/svg/trash-bin-minimalistic-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/upload-linear.svg b/src/ui/icons/svg/upload-linear.svg new file mode 100644 index 0000000..1acf54d --- /dev/null +++ b/src/ui/icons/svg/upload-linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/icons/svg/videocamera-record-bold.svg b/src/ui/icons/svg/videocamera-record-bold.svg new file mode 100644 index 0000000..fe07eaf --- /dev/null +++ b/src/ui/icons/svg/videocamera-record-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/menu.rs b/src/ui/menu.rs new file mode 100644 index 0000000..5594189 --- /dev/null +++ b/src/ui/menu.rs @@ -0,0 +1,97 @@ +//! Нативное меню в верхней панели экрана (только macOS). +//! На остальных платформах кнопка настроек живёт в тулбаре — см. panels/toolbar.rs. +use crate::ui::state::AppState; + +#[cfg(target_os = "macos")] +mod ids { + pub const SETTINGS: &str = "loflum-settings"; + pub const NEW_CONNECTION: &str = "loflum-new-connection"; +} + +/// Строит нативное меню macOS: App-меню (с "Settings…"), File, Edit, Window. +/// Вызывать один раз при старте, на главном потоке, уже после инициализации NSApplication. +#[cfg(target_os = "macos")] +pub fn setup_native_menu() { + use muda::{ + Menu, PredefinedMenuItem, Submenu, + accelerator::{Accelerator, CMD_OR_CTRL, Code}, + }; + + let menu = Menu::new(); + + let app_menu = Submenu::new("LoFlum", true); + let _ = app_menu.append_items(&[ + &PredefinedMenuItem::about(Some("About LoFlum"), None), + &PredefinedMenuItem::separator(), + &muda::MenuItem::with_id( + ids::SETTINGS, + "Settings…", + true, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)), + ), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(None), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::quit(None), + ]); + + let file_menu = Submenu::new("File", true); + let _ = file_menu.append_items(&[ + &muda::MenuItem::with_id( + ids::NEW_CONNECTION, + "New Connection…", + true, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)), + ), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::close_window(None), + ]); + + let edit_menu = Submenu::new("Edit", true); + let _ = edit_menu.append_items(&[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::select_all(None), + ]); + + let window_menu = Submenu::new("Window", true); + let _ = window_menu.append_items(&[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::bring_all_to_front(None), + ]); + + let _ = menu.append_items(&[&app_menu, &file_menu, &edit_menu, &window_menu]); + + menu.init_for_nsapp(); + // Держим меню живым на весь процесс — NSApp хранит на него ссылку, + // но Rust-сторона (Rc внутри muda) не должна быть уничтожена раньше. + Box::leak(Box::new(menu)); +} + +#[cfg(not(target_os = "macos"))] +pub fn setup_native_menu() {} + +/// Забирает события из нативного меню (клики по кастомным пунктам) и применяет к состоянию. +/// На платформах без нативного меню — no-op. +#[cfg(target_os = "macos")] +pub fn poll_menu_events(state: &mut AppState) { + while let Ok(event) = muda::MenuEvent::receiver().try_recv() { + let id = event.id().0.as_str(); + if id == ids::SETTINGS { + state.show_settings_dialog = true; + } else if id == ids::NEW_CONNECTION { + state.show_connect_dialog = true; + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn poll_menu_events(_state: &mut AppState) {} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0f1b6a6..8011042 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,8 @@ pub mod app; pub mod dialogs; pub mod drag; +pub mod icons; +pub mod menu; pub mod panels; pub mod state; pub mod theme; diff --git a/src/ui/panels/file_pane.rs b/src/ui/panels/file_pane.rs index 3853f54..42a46cc 100644 --- a/src/ui/panels/file_pane.rs +++ b/src/ui/panels/file_pane.rs @@ -1,6 +1,7 @@ //! Переиспользуемая таблица файлов с нормальными колонками use crate::domain::file_entry::{EntryKind, FileEntry}; use crate::ui::drag::DragPayload; +use crate::ui::icons::{self, Icon, file_icon_for}; use crate::ui::theme::*; use egui::{Color32, Id, RichText, Ui}; @@ -35,20 +36,56 @@ pub enum SortDir { Desc, } +/// Действие, запрошенное через контекстное меню (ПКМ) на строке. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RowAction { + Open, + Rename, + Delete, + Bookmark, + CopyPath, + /// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона. + Transfer, +} + pub struct FileTableResponse { pub double_clicked: Option, pub clicked: Option, + /// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили). + pub dropped_on_dir: Option<(FileEntry, std::sync::Arc)>, + /// Действие из контекстного меню (ПКМ) — (строка, действие). + pub context_action: Option<(FileEntry, RowAction)>, } -pub fn file_table( +pub fn file_table( ui: &mut Ui, id: &str, entries: &[FileEntry], selected: &mut Option, make_drag_payload: F, + context_menu: C, ) -> FileTableResponse where F: Fn(&FileEntry) -> Option, + C: FnMut(&mut Ui, &FileEntry) -> Option, +{ + ui.push_id(id, |ui| { + file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu) + }) + .inner +} + +fn file_table_inner( + ui: &mut Ui, + id: &str, + entries: &[FileEntry], + selected: &mut Option, + make_drag_payload: F, + mut context_menu: C, +) -> FileTableResponse +where + F: Fn(&FileEntry) -> Option, + C: FnMut(&mut Ui, &FileEntry) -> Option, { // Сортировка — читаем из egui memory let sort_id = Id::new((id, "sort")); @@ -92,48 +129,49 @@ where let mut double_clicked: Option = None; let mut clicked: Option = None; let mut new_sort: Option<(SortCol, SortDir)> = None; + let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc)> = None; + let mut context_action: Option<(FileEntry, RowAction)> = None; // Заголовки колонок let header_frame = egui::Frame::none() .fill(BG_HEADER) - .inner_margin(egui::Margin { - left: 6.0, - right: 6.0, - top: 0.0, - bottom: 0.0, - }); + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); header_frame.show(ui, |ui| { - ui.set_min_height(22.0); - ui.horizontal(|ui| { - let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir); - if name_r { - new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir)); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mod_r = sort_header( - ui, - "Modified", - 140.0, - sort_col == SortCol::Modified, - sort_dir, - ); - if mod_r { - new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir)); + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 24.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir); + if name_r { + new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir)); } - let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir); - if type_r { - new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir)); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mod_r = sort_header( + ui, + "Modified", + 140.0, + sort_col == SortCol::Modified, + sort_dir, + ); + if mod_r { + new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir)); + } - let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir); - if size_r { - new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir)); - } - }); - }); + let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir); + if type_r { + new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir)); + } + + let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir); + if size_r { + new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir)); + } + }); + }, + ); }); if let Some(s) = new_sort { @@ -148,101 +186,141 @@ where // Список файлов egui::ScrollArea::vertical() + .id_salt((id, "scroll")) .auto_shrink([false; 2]) .show(ui, |ui| { for entry in &sorted { let is_selected = selected.as_deref() == Some(&entry.name); let row_id = Id::new((id, "row", &entry.name)); + // Строка, которую сейчас тащат — её содержимое рисуется в + // плавающем слое поверх курсора (см. dnd_drag_source), поэтому + // сама она не может быть целью для дропа, а на исходном месте + // не должно оставаться подсветки выбора/ховера — там пусто. + let is_being_dragged = ui.ctx().is_being_dragged(row_id); + let row_rect = egui::Rect::from_min_size( ui.cursor().min, egui::vec2(ui.available_width(), ROW_H), ); - let is_hovered = ui.rect_contains_pointer(row_rect); + let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect); + let is_drop_target = !is_being_dragged + && entry.kind == EntryKind::Dir + && egui::DragAndDrop::has_payload_of_type::(ui.ctx()); + let is_drop_hover = is_drop_target && is_hovered; - let bg = if is_selected { + let bg = if is_being_dragged { + Color32::TRANSPARENT + } else if is_drop_hover { + ACCENT_DIM + } else if is_selected { BG_ROW_SEL } else if is_hovered { BG_ROW_HOVER } else { Color32::TRANSPARENT }; + let stroke = if is_drop_hover { + egui::Stroke::new(1.5, ACCENT) + } else { + egui::Stroke::NONE + }; + let row_width = ui.available_width(); let response = egui::Frame::none() .fill(bg) - .inner_margin(egui::Margin { - left: 6.0, - right: 6.0, - top: 0.0, - bottom: 0.0, - }) + .stroke(stroke) + .rounding(RADIUS_SM) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)) .show(ui, |ui| { - ui.set_min_height(ROW_H); - ui.horizontal_centered(|ui| { - // иконка + имя - let name_text = if entry.name == ".." { - "⬆ ..".to_string() - } else { - match entry.kind { - EntryKind::Dir => format!("📁 {}", entry.name), - EntryKind::Symlink => format!("🔗 {}", entry.name), - EntryKind::File => { - let icon = file_icon(&entry.name); - format!("{} {}", icon, entry.name) + ui.allocate_ui_with_layout( + egui::vec2(row_width - 24.0, ROW_H), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + // иконка (цвет по типу) + имя (единый цвет, как в спеке) + let (icon, icon_col) = if entry.name == ".." { + (Icon::ArrowUp, TEXT_HINT) + } else { + match entry.kind { + EntryKind::Dir => (Icon::Folder, YELLOW), + EntryKind::Symlink => (Icon::Link, YELLOW), + EntryKind::File => { + let ext = entry + .name + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + file_icon_for(&ext) + } } - } - }; + }; + let name_col = if entry.name == ".." { + TEXT_HINT + } else { + TEXT_PRIMARY + }; - let name_col = if entry.name == ".." { - TEXT_HINT - } else if entry.kind == EntryKind::Dir { - ACCENT - } else { - TEXT_PRIMARY - }; - - if let Some(payload) = make_drag_payload(entry) { - ui.dnd_drag_source(row_id, payload, |ui| { - ui.label(RichText::new(&name_text).color(name_col).size(12.0)); - }); - } else { - ui.label(RichText::new(&name_text).color(name_col).size(12.0)); - } - - // правые колонки - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.label( - RichText::new(format_time(entry.modified)) - .color(TEXT_DIM) - .size(11.0), - ); + let draw_row = |ui: &mut Ui| { + icons::icon(ui, icon, 14.0, icon_col); ui.add_space(6.0); + ui.label(RichText::new(&entry.name).color(name_col).size(12.5)); + }; - let (type_str, type_col) = match entry.kind { - EntryKind::Dir => ("Dir", TEXT_DIM), - EntryKind::File => ("File", TEXT_HINT), - EntryKind::Symlink => ("Link", YELLOW), - }; - let type_label = egui::Label::new( - RichText::new(type_str).color(type_col).size(11.0), - ) - .truncate(); - ui.add_sized([38.0, ROW_H], type_label); + if let Some(payload) = make_drag_payload(entry) { + ui.dnd_drag_source(row_id, payload, |ui| { + ui.horizontal(draw_row); + }); + } else { + draw_row(ui); + } - ui.label( - RichText::new(format_size(entry.size)) - .color(TEXT_DIM) - .size(11.0), - ); - ui.add_space(4.0); - }, - ); - }); + // правые колонки + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(format_time(entry.modified)) + .color(TEXT_HINT) + .monospace() + .size(10.5), + ); + ui.add_space(6.0); + + let (type_str, type_col) = match entry.kind { + EntryKind::Dir => ("Dir", TEXT_DIM), + EntryKind::File => ("File", TEXT_HINT), + EntryKind::Symlink => ("Link", YELLOW), + }; + let type_label = egui::Label::new( + RichText::new(type_str).color(type_col).size(11.0), + ) + .truncate(); + ui.add_sized([38.0, ROW_H], type_label); + + ui.label( + RichText::new(format_size(entry.size)) + .color(TEXT_DIM) + .monospace() + .size(11.0), + ); + ui.add_space(4.0); + }, + ); + }, + ); }) .response; + // Frame::show() only senses hover, not clicks — add a real click + // sense on the full row rect (separate id from the drag source, + // which only senses drag over the icon+name sub-area). Without + // this, every press was interpreted as a drag start and plain + // clicks/double-clicks never fired. + let click_response = + ui.interact(response.rect, row_id.with("click"), egui::Sense::click()); + let response = response | click_response; + if response.clicked() { *selected = Some(entry.name.clone()); clicked = Some(entry.name.clone()); @@ -250,15 +328,48 @@ where if response.double_clicked() { double_clicked = Some(entry.name.clone()); } + if is_drop_target + && let Some(payload) = response.dnd_release_payload::() + { + dropped_on_dir = Some((entry.clone(), payload)); + } + + if entry.name != ".." { + // Правый клик выбирает строку — иначе меню открывается + // над предыдущим выделением, что сбивает с толку. + if response.secondary_clicked() { + *selected = Some(entry.name.clone()); + } + response.context_menu(|ui| { + if let Some(action) = context_menu(ui, entry) { + context_action = Some((entry.clone(), action)); + ui.close_menu(); + } + }); + } } }); FileTableResponse { double_clicked, clicked, + dropped_on_dir, + context_action, } } +/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке). +pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool { + let color = if danger { RED } else { TEXT_PRIMARY }; + let btn = egui::Button::image_and_text( + icons::image(icon, 13.0, color), + RichText::new(label).size(12.0).color(color), + ) + .fill(Color32::TRANSPARENT) + .min_size(egui::vec2(170.0, 26.0)); + ui.add(btn).clicked() +} + fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool { let arrow = if active { if dir == SortDir::Asc { " ↑" } else { " ↓" } @@ -267,7 +378,7 @@ fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) }; let full = format!("{}{}", label, arrow); let col = if active { ACCENT } else { TEXT_DIM }; - let btn = egui::Button::new(RichText::new(full).color(col).size(11.0).strong()) + let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong()) .fill(Color32::TRANSPARENT) .min_size(egui::vec2(min_w, 20.0)); ui.add(btn).clicked() @@ -285,22 +396,3 @@ fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, So (col, SortDir::Asc) } } - -fn file_icon(name: &str) -> &'static str { - let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); - match ext.as_str() { - "rs" => "🦀", - "toml" | "yaml" | "yml" | "json" => "⚙", - "md" | "txt" | "log" => "📝", - "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "🖼", - "mp4" | "mkv" | "avi" | "mov" => "🎬", - "mp3" | "ogg" | "flac" | "wav" => "🎵", - "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => "📦", - "pdf" => "📕", - "sh" | "bash" | "fish" | "zsh" => "⚡", - "html" | "css" | "js" | "ts" => "🌐", - "py" => "🐍", - "java" | "class" | "jar" => "☕", - _ => "📄", - } -} diff --git a/src/ui/panels/local_pane.rs b/src/ui/panels/local_pane.rs index a24e613..6c4856a 100644 --- a/src/ui/panels/local_pane.rs +++ b/src/ui/panels/local_pane.rs @@ -5,11 +5,14 @@ use crate::fs::local; use crate::fs::remote::RemoteRegistry; use crate::transfer::queue::TransferQueue; use crate::ui::drag::{DragPayload, make_local_payload}; -use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size}; -use crate::ui::state::AppState; +use crate::ui::icons::{self, Icon}; +use crate::ui::panels::file_pane::{ + FileTableResponse, RowAction, context_menu_item, file_table, format_size, +}; +use crate::ui::state::{AppState, Pane}; use crate::ui::theme::*; use egui::{RichText, Ui}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; fn parent_path(path: &str) -> String { std::path::Path::new(path) @@ -35,43 +38,75 @@ pub fn render( queue: &TransferQueue, registry: &Arc, _rt_handle: &tokio::runtime::Handle, + db: &Arc>, ) { let frame = egui::Frame::none() .fill(BG_CONTENT) .inner_margin(egui::Margin::same(0.0)); let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { - render_inner(ui, state); + render_inner(ui, state, queue, registry, db); }); if let Some(payload_arc) = dropped { - match &*payload_arc { - DragPayload::RemoteFile(remote_path, file_name, conn_id) => { - if registry.get(conn_id).is_some() { - let local_path_str = - format!("{}/{}", state.local_path.trim_end_matches('/'), file_name); - let task = TransferTask::new( - TransferKind::Download, - conn_id.clone(), - local_path_str, - remote_path.clone(), - file_name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Download queued: {}", file_name); - } else { - state.status_message = "Connection no longer active".into(); - } + let dest_dir = state.local_path.clone(); + handle_drop(state, queue, registry, &payload_arc, &dest_dir); + } +} + +/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код +/// для дропа на весь пейн и дропа на конкретную папку в списке. +fn handle_drop( + state: &mut AppState, + queue: &TransferQueue, + registry: &Arc, + payload: &DragPayload, + dest_dir: &str, +) { + match payload { + DragPayload::RemoteFile(remote_path, file_name, conn_id) => { + if registry.get(conn_id).is_some() { + let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + let task = TransferTask::new( + TransferKind::Download, + conn_id.clone(), + local_path_str, + remote_path.clone(), + file_name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Download queued: {}", file_name); + } else { + state.status_message = "Connection no longer active".into(); } - DragPayload::LocalFile(_, _) => { - state.status_message = "Cannot drop local onto local".into(); + } + DragPayload::LocalFile(src_path, file_name) => { + if std::path::Path::new(dest_dir) + == std::path::Path::new(src_path) + .parent() + .unwrap_or(std::path::Path::new("")) + { + return; // бросили туда же, откуда взяли + } + match local::move_into(src_path, dest_dir) { + Ok(()) => { + state.status_message = format!("Moved {}", file_name); + refresh_local(state); + } + Err(e) => state.status_message = format!("Move failed: {}", e), } } } } -fn render_inner(ui: &mut Ui, state: &mut AppState) { +fn render_inner( + ui: &mut Ui, + state: &mut AppState, + queue: &TransferQueue, + registry: &Arc, + db: &Arc>, +) { // Хлебные крошки + путь render_path_bar(ui, state); @@ -88,93 +123,232 @@ fn render_inner(ui: &mut Ui, state: &mut AppState) { .map(|t| t.status == ConnectionStatus::Connected) .unwrap_or(false); - let FileTableResponse { double_clicked, .. } = - file_table(ui, "local_table", &entries, &mut selected, |entry| { + let FileTableResponse { + double_clicked, + clicked, + dropped_on_dir, + context_action, + } = file_table( + ui, + "local_table", + &entries, + &mut selected, + |entry| { if has_connection { make_local_payload(entry) } else { None } - }); + }, + |ui, entry| build_context_menu(ui, entry, has_connection), + ); state.local_selected = selected; + if clicked.is_some() { + state.active_pane = Pane::Local; + } + + if let Some((target_entry, payload)) = dropped_on_dir { + handle_drop(state, queue, registry, &payload, &target_entry.path); + } if let Some(name) = double_clicked { - if name == ".." { - state.local_path = parent_path(&state.local_path); - } else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) - && entry.kind == EntryKind::Dir - { - state.local_path = entry.path.clone(); - } - refresh_local(state); + open_entry(state, &name); + } + + if let Some((entry, action)) = context_action { + handle_row_action(ui.ctx(), state, queue, db, &entry, action); } // Нижняя полоска - render_footer(ui, state); + render_footer(ui, state, db); +} + +fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option { + if context_menu_item(ui, Icon::Folder, "Open", false) { + return Some(RowAction::Open); + } + if entry.name != ".." { + if context_menu_item(ui, Icon::Pen, "Rename", false) { + return Some(RowAction::Rename); + } + if context_menu_item(ui, Icon::Trash, "Delete", true) { + return Some(RowAction::Delete); + } + ui.separator(); + if entry.kind == EntryKind::Dir + && context_menu_item(ui, Icon::Star, "Add to Bookmarks", false) + { + return Some(RowAction::Bookmark); + } + if context_menu_item(ui, Icon::Document, "Copy Path", false) { + return Some(RowAction::CopyPath); + } + if entry.kind == EntryKind::File && has_connection { + ui.separator(); + if context_menu_item(ui, Icon::Upload, "Upload to Server", false) { + return Some(RowAction::Transfer); + } + } + } + None +} + +fn open_entry(state: &mut AppState, name: &str) { + if name == ".." { + state.local_path = parent_path(&state.local_path); + refresh_local(state); + } else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) { + if entry.kind == EntryKind::Dir { + state.local_path = entry.path.clone(); + refresh_local(state); + } else { + match local::open(&entry.path) { + Ok(()) => state.status_message = format!("Opening {}", entry.name), + Err(e) => state.status_message = format!("Open failed: {}", e), + } + } + } +} + +fn handle_row_action( + ctx: &egui::Context, + state: &mut AppState, + queue: &TransferQueue, + db: &Arc>, + entry: &FileEntry, + action: RowAction, +) { + match action { + RowAction::Open => open_entry(state, &entry.name), + RowAction::Rename => { + state.op_target = Pane::Local; + state.show_rename_dialog = true; + state.rename_old_name = entry.name.clone(); + state.rename_new_name = entry.name.clone(); + } + RowAction::Delete => { + state.op_target = Pane::Local; + state.show_delete_dialog = true; + state.delete_name = entry.name.clone(); + } + RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path), + RowAction::CopyPath => { + ctx.copy_text(entry.path.clone()); + state.status_message = "Path copied".into(); + } + RowAction::Transfer => { + if let Some(tab) = state.active_tab_ref() { + let connection_id = tab.id.clone(); + let remote_path = + format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + entry.path.clone(), + remote_path, + entry.name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Upload queued: {}", entry.name); + } + } + } +} + +fn add_bookmark( + state: &mut AppState, + db: &Arc>, + name: &str, + path: &str, +) { + if state.bookmarks.iter().any(|b| b.path == path) { + state.status_message = "Already bookmarked".into(); + return; + } + let Ok(conn) = db.lock() else { + return; + }; + match crate::storage::db::add_bookmark(&conn, name, path) { + Ok(id) => { + state.bookmarks.push(crate::ui::state::Bookmark { + id, + name: name.to_string(), + path: path.to_string(), + }); + state.status_message = "Bookmark added".into(); + } + Err(e) => state.status_message = format!("Bookmark failed: {}", e), + } } fn render_path_bar(ui: &mut Ui, state: &mut AppState) { let frame = egui::Frame::none() - .fill(BG_PANEL) - .inner_margin(egui::Margin { - left: 8.0, - right: 8.0, - top: 4.0, - bottom: 4.0, - }); + .fill(BG_BASE) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); frame.show(ui, |ui| { - ui.horizontal(|ui| { - // иконка home - let home = dirs::home_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let is_home = state.local_path == home; - let home_btn = egui::Button::new(RichText::new("🏠").size(13.0)) - .fill(if is_home { - BG_ROW_SEL - } else { - egui::Color32::TRANSPARENT - }) - .rounding(4.0) - .min_size(egui::vec2(22.0, 22.0)); - if ui.add(home_btn).clicked() { - state.local_path = home; - refresh_local(state); - } + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(RichText::new("LOCAL").color(TEXT_DIM).size(10.5).strong()); + ui.add_space(10.0); - ui.add_space(4.0); - - // Крошки - let path_clone = state.local_path.clone(); - let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); - let mut acc = String::new(); - - for (i, part) in parts.iter().enumerate() { - acc.push('/'); - acc.push_str(part); - let is_last = i == parts.len() - 1; - - if is_last { - ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); - } else { - let path_snap = acc.clone(); - let link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) - .sense(egui::Sense::click()); - if ui.add(link).clicked() { - state.local_path = path_snap; - refresh_local(state); - } - ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + // иконка home + let home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let is_home = state.local_path == home; + let home_col = if is_home { ACCENT } else { TEXT_DIM }; + let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col)) + .fill(if is_home { + BG_ROW_SEL + } else { + egui::Color32::TRANSPARENT + }) + .rounding(RADIUS_SM) + .min_size(egui::vec2(22.0, 22.0)); + if ui.add(home_btn).clicked() { + state.local_path = home; + refresh_local(state); } - } - }); + + ui.add_space(4.0); + + // Крошки + let path_clone = state.local_path.clone(); + let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + + for (i, part) in parts.iter().enumerate() { + acc.push('/'); + acc.push_str(part); + let is_last = i == parts.len() - 1; + + if is_last { + ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); + } else { + let path_snap = acc.clone(); + let link = + egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) + .sense(egui::Sense::click()); + if ui.add(link).clicked() { + state.local_path = path_snap; + refresh_local(state); + } + ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + } + } + }, + ); }); } -fn render_footer(ui: &mut Ui, state: &mut AppState) { +fn render_footer(ui: &mut Ui, state: &mut AppState, db: &Arc>) { let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); ui.painter().rect_filled(sep, 0.0, BORDER); @@ -207,7 +381,7 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // кнопка добавить закладку - let star = egui::Button::new(RichText::new("★").color(YELLOW).size(12.0)) + let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW)) .fill(egui::Color32::TRANSPARENT) .min_size(egui::vec2(20.0, 20.0)); if ui.add(star).on_hover_text("Bookmark this folder").clicked() { @@ -215,11 +389,8 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) { .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "Bookmark".into()); - state.bookmarks.push(crate::ui::state::Bookmark { - name, - path: state.local_path.clone(), - }); - state.status_message = "Bookmark added".into(); + let path = state.local_path.clone(); + add_bookmark(state, db, &name, &path); } }); }); diff --git a/src/ui/panels/queue.rs b/src/ui/panels/queue.rs index 1247a4b..0aba8a8 100644 --- a/src/ui/panels/queue.rs +++ b/src/ui/panels/queue.rs @@ -1,5 +1,6 @@ use crate::domain::transfer::TransferTask; use crate::domain::transfer::{TaskState, TransferKind}; +use crate::ui::icons::{self, Icon}; use crate::ui::panels::file_pane::format_size; use crate::ui::state::AppState; use crate::ui::theme::*; @@ -9,75 +10,84 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { // Заголовок очереди (всегда виден) let header_frame = egui::Frame::none() .fill(BG_QUEUE) - .inner_margin(egui::Margin::symmetric(8.0, 0.0)); + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); header_frame.show(ui, |ui| { - ui.set_min_height(QUEUE_COLLAPSED_H); - ui.horizontal_centered(|ui| { - let arrow = if state.show_queue { "▼" } else { "▲" }; - let n_active = tasks - .iter() - .filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)) - .count(); - - let title = if n_active > 0 { - format!("{} Transfer Queue ({})", arrow, n_active) - } else { - format!("{} Transfer Queue ({})", arrow, tasks.len()) - }; - - let btn = egui::Button::new( - RichText::new(&title) - .color(TEXT_PRIMARY) - .size(12.0) - .strong(), - ) - .fill(Color32::TRANSPARENT) - .min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H)); - - if ui.add(btn).clicked() { - state.show_queue = !state.show_queue; - } - - // Аггрегированная скорость справа - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let agg_speed: u64 = tasks + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, QUEUE_COLLAPSED_H), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let chevron = if state.show_queue { + Icon::ArrowDown + } else { + Icon::ArrowUp + }; + let n_active = tasks .iter() - .filter(|t| t.state == TaskState::Running) - .filter_map(|t| t.speed) - .sum(); + .filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)) + .count(); - if agg_speed > 0 { - ui.label( - RichText::new(format!("{}/s", format_size(Some(agg_speed)))) - .color(GREEN) - .size(11.0), - ); - } else if !tasks.is_empty() { - let done = tasks + let title = if n_active > 0 { + format!("Transfer Queue ({})", n_active) + } else { + format!("Transfer Queue ({})", tasks.len()) + }; + + let btn = egui::Button::image_and_text( + icons::image(chevron, 13.0, TEXT_DIM), + RichText::new(&title) + .color(TEXT_PRIMARY) + .size(11.5) + .strong(), + ) + .fill(Color32::TRANSPARENT) + .min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H)); + + if ui.add(btn).clicked() { + state.show_queue = !state.show_queue; + } + + // Аггрегированная скорость справа + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let agg_speed: u64 = tasks .iter() - .filter(|t| t.state == TaskState::Completed) - .count(); - let failed = tasks - .iter() - .filter(|t| matches!(t.state, TaskState::Failed(_))) - .count(); - ui.label( - RichText::new(format!("{}/{} done", done, tasks.len())) - .color(TEXT_DIM) - .size(11.0), - ); - if failed > 0 { - ui.add_space(8.0); + .filter(|t| t.state == TaskState::Running) + .filter_map(|t| t.speed) + .sum(); + + if agg_speed > 0 { ui.label( - RichText::new(format!("{} failed", failed)) - .color(RED) + RichText::new(format!("{}/s", format_size(Some(agg_speed)))) + .color(GREEN) .size(11.0), ); + } else if !tasks.is_empty() { + let done = tasks + .iter() + .filter(|t| t.state == TaskState::Completed) + .count(); + let failed = tasks + .iter() + .filter(|t| matches!(t.state, TaskState::Failed(_))) + .count(); + ui.label( + RichText::new(format!("{}/{} done", done, tasks.len())) + .color(TEXT_DIM) + .size(11.0), + ); + if failed > 0 { + ui.add_space(8.0); + ui.label( + RichText::new(format!("{} failed", failed)) + .color(RED) + .size(11.0), + ); + } } - } - }); - }); + }); + }, + ); }); if !state.show_queue || tasks.is_empty() { @@ -91,113 +101,81 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { .auto_shrink([false; 2]) .max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0) .show(ui, |ui| { - for task in tasks { - render_task(ui, task); + ui.add_space(2.0); + for (i, task) in tasks.iter().enumerate() { + if i > 0 { + ui.add_space(6.0); + } + egui::Frame::none() + .inner_margin(egui::Margin::symmetric(12.0, 0.0)) + .show(ui, |ui| render_task(ui, task)); } }); } fn render_task(ui: &mut Ui, task: &TransferTask) { - let bg = match &task.state { - TaskState::Completed => Color32::from_rgb(22, 38, 24), - TaskState::Failed(_) => Color32::from_rgb(38, 22, 22), - TaskState::Running => Color32::from_rgb(22, 28, 42), - TaskState::Queued => BG_QUEUE, - _ => BG_QUEUE, - }; + // Плоская однострочная карточка, как в макете — без цветной подложки + ui.horizontal(|ui| { + ui.set_min_height(34.0); - egui::Frame::none() - .fill(bg) - .inner_margin(egui::Margin { - left: 10.0, - right: 10.0, - top: 4.0, - bottom: 4.0, - }) - .show(ui, |ui| { - ui.horizontal(|ui| { - // стрелка вида - let (arrow, arrow_col) = match task.kind { - TransferKind::Upload => ("↑", ACCENT), - TransferKind::Download => ("↓", GREEN), - }; - ui.label(RichText::new(arrow).color(arrow_col).size(13.0).strong()); - ui.add_space(6.0); + let (arrow, arrow_col) = match task.kind { + TransferKind::Upload => (Icon::Upload, ACCENT), + TransferKind::Download => (Icon::Download, GREEN), + }; + icons::icon(ui, arrow, 15.0, arrow_col); + ui.add_space(10.0); - // имя файла - ui.label( - RichText::new(&task.file_name) - .color(TEXT_PRIMARY) - .size(12.0), - ); + // имя файла — фиксированная ширина, как в спеке + let name_label = egui::Label::new( + RichText::new(&task.file_name) + .color(TEXT_PRIMARY) + .size(12.0), + ) + .truncate(); + let name_resp = ui.add_sized([150.0, 18.0], name_label); + if let TaskState::Failed(err) = &task.state { + name_resp.on_hover_text(err); + } + ui.add_space(10.0); - // статус справа - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let (state_str, state_col) = match &task.state { - TaskState::Queued => ("Queued", TEXT_HINT), - TaskState::Running => ("Uploading", ACCENT), - TaskState::Completed => ("✓ Done", GREEN), - TaskState::Failed(_) => ("× Failed", RED), - TaskState::Paused => ("Paused", YELLOW), - TaskState::Cancelled => ("Cancelled", TEXT_HINT), - TaskState::Retrying(_) => ("Retrying", YELLOW), - }; - ui.label(RichText::new(state_str).color(state_col).size(11.0)); - }); - }); + let pct = task.progress_pct() as f32 / 100.0; + let bar_col = match &task.state { + TaskState::Completed => GREEN, + TaskState::Failed(_) => RED, + TaskState::Paused => TEXT_HINT, + _ => ACCENT, + }; + let bar = egui::ProgressBar::new(pct) + .desired_width(ui.available_width() - 130.0) + .fill(bar_col); + ui.add(bar); + ui.add_space(10.0); - // прогресс - ui.horizontal(|ui| { - let pct = task.progress_pct() as f32 / 100.0; - let bar_col = match &task.state { - TaskState::Completed => GREEN, - TaskState::Failed(_) => RED, - _ => ACCENT, - }; + ui.label( + RichText::new(format!("{:.0}%", task.progress_pct())) + .color(TEXT_DIM) + .monospace() + .size(11.0), + ); - let bar = egui::ProgressBar::new(pct) - .text(format!("{:.0}%", task.progress_pct())) - .desired_width(ui.available_width() * 0.55) - .fill(bar_col); - ui.add(bar); - - ui.label( - RichText::new(format!( - " {}/{}", - format_size(Some(task.transferred_bytes)), - format_size(Some(task.total_bytes)) - )) - .color(TEXT_DIM) - .size(11.0), - ); - - if let Some(speed) = task.speed - && speed > 0 - { - ui.label( - RichText::new(format!(" {}/s", format_size(Some(speed)))) - .color(GREEN) - .size(11.0), - ); - } - if let Some(eta) = task.eta_secs - && eta > 0 - { - ui.label( - RichText::new(format!(" ETA {}s", eta)) - .color(TEXT_HINT) - .size(10.0), - ); - } - }); - - // ошибка если есть - if let TaskState::Failed(err) = &task.state { - ui.label(RichText::new(err).color(RED).size(10.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (state_str, state_col, state_icon) = match &task.state { + TaskState::Queued => ("Queued".to_string(), TEXT_HINT, None), + TaskState::Running => match task.speed { + Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None), + _ => ("Uploading".to_string(), ACCENT, None), + }, + TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)), + TaskState::Failed(_) => ("Failed".to_string(), RED, None), + TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)), + TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None), + TaskState::Retrying(_) => ("Retrying".to_string(), YELLOW, None), + }; + if let Some(ic) = state_icon { + icons::icon(ui, ic, 13.0, state_col); + ui.add_space(4.0); } + ui.label(RichText::new(state_str).color(state_col).size(10.5)); }); - - // разделитель - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, SEPARATOR); + }); } diff --git a/src/ui/panels/remote_pane.rs b/src/ui/panels/remote_pane.rs index f946e4f..016520f 100644 --- a/src/ui/panels/remote_pane.rs +++ b/src/ui/panels/remote_pane.rs @@ -7,8 +7,11 @@ use crate::domain::transfer::{TransferKind, TransferTask}; use crate::fs::remote::RemoteRegistry; use crate::transfer::queue::TransferQueue; use crate::ui::drag::{DragPayload, make_remote_payload}; -use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size}; -use crate::ui::state::{AppState, PendingRemoteList}; +use crate::ui::icons::{self, Icon}; +use crate::ui::panels::file_pane::{ + FileTableResponse, RowAction, context_menu_item, file_table, format_size, +}; +use crate::ui::state::{AppState, Pane, PendingRemoteList}; use crate::ui::theme::*; pub fn remote_parent(path: &str) -> String { @@ -41,35 +44,83 @@ pub fn render( .inner_margin(egui::Margin::same(0.0)); let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { - render_content(ui, state, tab_idx, registry, rt_handle); + render_content(ui, state, tab_idx, registry, rt_handle, queue); }); - let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; - let connection_id = state.tabs[tab_idx].id.clone(); let remote_path = state.tabs[tab_idx].remote_path.clone(); if let Some(payload_arc) = dropped { - match &*payload_arc { - DragPayload::LocalFile(local_path, file_name) => { - if is_connected { - let dest = format!("{}/{}", remote_path.trim_end_matches('/'), file_name); - let task = TransferTask::new( - TransferKind::Upload, - connection_id, - local_path.clone(), - dest, - file_name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Upload queued: {}", file_name); - } else { - state.status_message = "No active connection".into(); - } + handle_drop( + state, + tab_idx, + queue, + registry, + rt_handle, + &payload_arc, + &remote_path, + ); + } +} + +/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне — +/// общий код для дропа на весь пейн и дропа на конкретную папку в списке. +fn handle_drop( + state: &mut AppState, + tab_idx: usize, + queue: &TransferQueue, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + payload: &DragPayload, + dest_dir: &str, +) { + let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; + let connection_id = state.tabs[tab_idx].id.clone(); + + match payload { + DragPayload::LocalFile(local_path, file_name) => { + if is_connected { + let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + local_path.clone(), + dest, + file_name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Upload queued: {}", file_name); + } else { + state.status_message = "No active connection".into(); } - DragPayload::RemoteFile(_, _, _) => { - state.status_message = "Cannot drop remote onto remote".into(); + } + DragPayload::RemoteFile(remote_path, file_name, conn_id) => { + if *conn_id != connection_id { + state.status_message = "Cannot move between different connections".into(); + return; } + let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + if dest == *remote_path { + return; + } + let registry = registry.clone(); + let from = remote_path.clone(); + let conn_id_clone = conn_id.clone(); + let result = Arc::new(std::sync::Mutex::new(None::>)); + let result_clone = result.clone(); + rt_handle.spawn(async move { + let fs = match registry.get(&conn_id_clone) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + state.pending_rename_result = Some(result); + state.status_message = format!("Moving {}…", file_name); } } } @@ -80,6 +131,7 @@ fn render_content( tab_idx: usize, registry: &Arc, rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, ) { // Path bar render_path_bar(ui, state, tab_idx, registry, rt_handle); @@ -109,44 +161,173 @@ fn render_content( let conn_id = state.tabs[tab_idx].id.clone(); let table_id = format!("remote_table_{}", tab_idx); - let FileTableResponse { double_clicked, .. } = - file_table(ui, &table_id, &entries, &mut selected, move |entry| { + let conn_id_for_drag = conn_id.clone(); + let FileTableResponse { + double_clicked, + clicked, + dropped_on_dir, + context_action, + } = file_table( + ui, + &table_id, + &entries, + &mut selected, + move |entry| { if is_connected { - make_remote_payload(entry, &conn_id) + make_remote_payload(entry, &conn_id_for_drag) } else { None } - }); + }, + build_context_menu, + ); state.tabs[tab_idx].remote_selected = selected; + if clicked.is_some() { + state.active_pane = Pane::Remote; + } + + if let Some((target_entry, payload)) = dropped_on_dir { + handle_drop( + state, + tab_idx, + queue, + registry, + rt_handle, + &payload, + &target_entry.path, + ); + } if let Some(name) = double_clicked { - let (nav_path, is_dir) = if name == ".." { - let parent = remote_parent(&state.tabs[tab_idx].remote_path); - (parent, true) - } else { - let entry = state.tabs[tab_idx] - .remote_entries - .iter() - .find(|e| e.name == name); - match entry { - Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true), - _ => (state.tabs[tab_idx].remote_path.clone(), false), - } - }; + open_entry(state, tab_idx, registry, rt_handle, queue, &name); + } - if is_dir { - state.tabs[tab_idx].remote_path = nav_path; - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } + if let Some((entry, action)) = context_action { + handle_row_action( + ui.ctx(), + state, + tab_idx, + registry, + rt_handle, + queue, + &entry, + action, + ); } // Footer render_footer(ui, state, tab_idx, registry, rt_handle); } +fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option { + if context_menu_item(ui, Icon::Folder, "Open", false) { + return Some(RowAction::Open); + } + if entry.name != ".." { + if context_menu_item(ui, Icon::Pen, "Rename", false) { + return Some(RowAction::Rename); + } + if context_menu_item(ui, Icon::Trash, "Delete", true) { + return Some(RowAction::Delete); + } + ui.separator(); + if context_menu_item(ui, Icon::Document, "Copy Path", false) { + return Some(RowAction::CopyPath); + } + if entry.kind == EntryKind::File + && context_menu_item(ui, Icon::Download, "Download to Local", false) + { + return Some(RowAction::Transfer); + } + } + None +} + +fn open_entry( + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, + name: &str, +) { + let (nav_path, is_dir) = if name == ".." { + let parent = remote_parent(&state.tabs[tab_idx].remote_path); + (parent, true) + } else { + let entry = state.tabs[tab_idx] + .remote_entries + .iter() + .find(|e| e.name == name); + match entry { + Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true), + _ => (state.tabs[tab_idx].remote_path.clone(), false), + } + }; + + if is_dir { + state.tabs[tab_idx].remote_path = nav_path; + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } else if let Some(entry) = state.tabs[tab_idx] + .remote_entries + .iter() + .find(|e| e.name == name) + .cloned() + { + queue_download(state, tab_idx, queue, &entry); + } +} + +fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) { + let connection_id = state.tabs[tab_idx].id.clone(); + let task = TransferTask::new( + TransferKind::Download, + connection_id, + format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name), + entry.path.clone(), + entry.name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Download queued: {}", entry.name); +} + +#[allow(clippy::too_many_arguments)] +fn handle_row_action( + ctx: &egui::Context, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, + entry: &FileEntry, + action: RowAction, +) { + match action { + RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name), + RowAction::Rename => { + state.op_target = Pane::Remote; + state.show_rename_dialog = true; + state.rename_old_name = entry.name.clone(); + state.rename_new_name = entry.name.clone(); + } + RowAction::Delete => { + state.op_target = Pane::Remote; + state.show_delete_dialog = true; + state.delete_name = entry.name.clone(); + } + RowAction::CopyPath => { + ctx.copy_text(entry.path.clone()); + state.status_message = "Path copied".into(); + } + RowAction::Transfer => queue_download(state, tab_idx, queue, entry), + RowAction::Bookmark => {} + } +} + fn render_path_bar( ui: &mut Ui, state: &mut AppState, @@ -155,63 +336,73 @@ fn render_path_bar( rt_handle: &tokio::runtime::Handle, ) { let frame = egui::Frame::none() - .fill(BG_PANEL) - .inner_margin(egui::Margin { - left: 8.0, - right: 8.0, - top: 4.0, - bottom: 4.0, - }); + .fill(BG_BASE) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); frame.show(ui, |ui| { - ui.horizontal(|ui| { - // статус точка - let (dot, dot_col) = match state.tabs[tab_idx].status { - ConnectionStatus::Connected => ("●", GREEN), - ConnectionStatus::Disconnected => ("○", TEXT_HINT), - ConnectionStatus::Connecting => ("◐", YELLOW), - ConnectionStatus::Error(_) => ("×", RED), - }; - ui.label(RichText::new(dot).color(dot_col).size(10.0)); - ui.add_space(4.0); + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let host = state.tabs[tab_idx].params.host.clone(); + ui.label( + RichText::new(format!("REMOTE · {}", host)) + .color(TEXT_DIM) + .size(10.5) + .strong(), + ); + ui.add_space(10.0); - // Хлебные крошки для remote path - let path_clone = state.tabs[tab_idx].remote_path.clone(); - let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); - let mut acc = String::new(); + // статус точка + let (dot, dot_col) = match state.tabs[tab_idx].status { + ConnectionStatus::Connected => ("●", GREEN), + ConnectionStatus::Disconnected => ("○", TEXT_HINT), + ConnectionStatus::Connecting => ("◐", YELLOW), + ConnectionStatus::Error(_) => ("×", RED), + }; + ui.label(RichText::new(dot).color(dot_col).size(10.0)); + ui.add_space(4.0); - // Root - let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0)) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(12.0, 22.0)); - if ui.add(root_btn).clicked() { - state.tabs[tab_idx].remote_path = "/".into(); - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } + // Хлебные крошки для remote path + let path_clone = state.tabs[tab_idx].remote_path.clone(); + let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); - for (i, part) in parts.iter().enumerate() { - acc.push('/'); - acc.push_str(part); - let is_last = i == parts.len() - 1; - let path_snap = acc.clone(); - - if is_last { - ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); - } else { - let link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) - .sense(egui::Sense::click()); - if ui.add(link).clicked() { - state.tabs[tab_idx].remote_path = path_snap; - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } + // Root + let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0)) + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(12.0, 22.0)); + if ui.add(root_btn).clicked() { + state.tabs[tab_idx].remote_path = "/".into(); + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); } - ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); } - } - }); + + for (i, part) in parts.iter().enumerate() { + acc.push('/'); + acc.push_str(part); + let is_last = i == parts.len() - 1; + let path_snap = acc.clone(); + + if is_last { + ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); + } else { + let link = + egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) + .sense(egui::Sense::click()); + if ui.add(link).clicked() { + state.tabs[tab_idx].remote_path = path_snap; + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } + ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + } + } + }, + ); }); } @@ -253,7 +444,7 @@ fn render_footer( // Refresh кнопка ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let refresh = egui::Button::new(RichText::new("⟳").color(ACCENT).size(14.0)) + let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT)) .fill(egui::Color32::TRANSPARENT) .min_size(egui::vec2(20.0, 20.0)); if ui.add(refresh).on_hover_text("Refresh").clicked() diff --git a/src/ui/panels/sidebar.rs b/src/ui/panels/sidebar.rs index 9ef49db..dca8140 100644 --- a/src/ui/panels/sidebar.rs +++ b/src/ui/panels/sidebar.rs @@ -1,10 +1,12 @@ //! Левая боковая панель — bookmarks + места +use crate::ui::icons::{self, Icon}; use crate::ui::panels::local_pane::refresh_local; use crate::ui::state::AppState; use crate::ui::theme::*; use egui::{RichText, Ui}; +use std::sync::{Arc, Mutex}; -pub fn render(ui: &mut Ui, state: &mut AppState) { +pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc>) { let frame = egui::Frame::none() .fill(BG_PANEL) .inner_margin(egui::Margin::symmetric(0.0, 0.0)); @@ -20,11 +22,11 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { section_header(ui, "LOCAL"); ui.add_space(4.0); - for bm in &state.bookmarks.clone() { - let is_active = state.local_path == bm.path; - let icon = bookmark_icon(&bm.name); - sidebar_item(ui, icon, &bm.name, is_active, || { - state.local_path = bm.path.clone(); + for qa in &state.quick_access.clone() { + let is_active = state.local_path == qa.path; + let icon = quick_access_icon(&qa.name); + sidebar_item(ui, icon, &qa.name, is_active, || { + state.local_path = qa.path.clone(); refresh_local(state); }); } @@ -38,7 +40,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { // Root + data drives let root_active = state.local_path == "/"; - sidebar_item(ui, "🖥", "/ (root)", root_active, || { + sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || { state.local_path = "/".into(); refresh_local(state); }); @@ -50,7 +52,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| mount_path.clone()); let is_active = state.local_path == mount_path; - sidebar_item(ui, "💾", &label, is_active, || { + sidebar_item(ui, Icon::Database, &label, is_active, || { state.local_path = mount_path.clone(); refresh_local(state); }); @@ -63,9 +65,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { section_header(ui, "BOOKMARKS"); ui.add_space(4.0); - // Пользовательские закладки (после 3 дефолтных) - let custom: Vec<_> = state.bookmarks.iter().skip(3).cloned().collect(); - if custom.is_empty() { + if state.bookmarks.is_empty() { ui.label( RichText::new(" No bookmarks yet") .color(TEXT_HINT) @@ -77,13 +77,26 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { .size(10.0), ); } else { - for bm in &custom { + let mut to_remove: Option = None; + for bm in &state.bookmarks.clone() { let is_active = state.local_path == bm.path; - sidebar_item(ui, "📌", &bm.name, is_active, || { + sidebar_item(ui, Icon::Star, &bm.name, is_active, || { state.local_path = bm.path.clone(); refresh_local(state); + }) + .context_menu(|ui| { + if ui.button("Remove bookmark").clicked() { + to_remove = Some(bm.id); + ui.close_menu(); + } }); } + if let Some(id) = to_remove { + state.bookmarks.retain(|b| b.id != id); + if let Ok(conn) = db.lock() { + let _ = crate::storage::db::remove_bookmark(&conn, id); + } + } } ui.add_space(8.0); @@ -93,8 +106,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { fn section_header(ui: &mut Ui, text: &str) { ui.horizontal(|ui| { - ui.add_space(10.0); - ui.label(RichText::new(text).color(TEXT_HINT).size(10.0).strong()); + ui.add_space(14.0); + ui.label(RichText::new(text).color(TEXT_HINT).size(10.5).strong()); }); } @@ -103,51 +116,54 @@ fn section_divider(ui: &mut Ui) { ui.painter().rect_filled(rect, 0.0, SEPARATOR); } -fn sidebar_item(ui: &mut Ui, icon: &str, label: &str, active: bool, mut on_click: impl FnMut()) { +fn sidebar_item( + ui: &mut Ui, + icon: Icon, + label: &str, + active: bool, + mut on_click: impl FnMut(), +) -> egui::Response { let bg = if active { BG_ROW_SEL } else { egui::Color32::TRANSPARENT }; - let text_col = if active { - egui::Color32::WHITE - } else { - TEXT_PRIMARY - }; + let text_col = TEXT_PRIMARY; + let icon_col = if active { ACCENT } else { TEXT_DIM }; let resp = egui::Frame::none() .fill(bg) - .rounding(4.0) - .inner_margin(egui::Margin { - left: 10.0, - right: 6.0, - top: 2.0, - bottom: 2.0, - }) + .rounding(RADIUS_SM) + .inner_margin(egui::Margin::symmetric(8.0, 0.0)) .show(ui, |ui| { - ui.set_min_width(SIDEBAR_W - 8.0); - ui.horizontal(|ui| { - ui.label(RichText::new(icon).size(12.0)); - ui.add_space(6.0); - ui.label(RichText::new(label).color(text_col).size(12.0)); - }); + ui.allocate_ui_with_layout( + egui::vec2(SIDEBAR_W - 32.0, 28.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + icons::icon(ui, icon, 14.0, icon_col); + ui.add_space(8.0); + ui.label(RichText::new(label).color(text_col).size(12.5)); + }, + ); }); - if resp.response.interact(egui::Sense::click()).clicked() { + let response = resp.response.interact(egui::Sense::click()); + if response.clicked() { on_click(); } + response } -fn bookmark_icon(name: &str) -> &'static str { +fn quick_access_icon(name: &str) -> Icon { match name { - "Home" => "🏠", - "Desktop" => "🖥", - "Documents" => "📄", - "Downloads" => "⬇", - "Pictures" => "🖼", - "Music" => "🎵", - "Videos" => "🎬", - _ => "📁", + "Home" => Icon::Home, + "Desktop" => Icon::Monitor, + "Documents" => Icon::Document, + "Downloads" => Icon::DownloadSquare, + "Pictures" => Icon::Gallery, + "Music" => Icon::MusicNote, + "Videos" => Icon::Videocamera, + _ => Icon::Folder, } } diff --git a/src/ui/panels/status_bar.rs b/src/ui/panels/status_bar.rs index 2f1f6a6..0fd10af 100644 --- a/src/ui/panels/status_bar.rs +++ b/src/ui/panels/status_bar.rs @@ -1,4 +1,5 @@ use crate::domain::connection::ConnectionStatus; +use crate::ui::icons::{self, Icon}; use crate::ui::panels::file_pane::format_size; use crate::ui::state::AppState; use crate::ui::theme::*; @@ -10,56 +11,61 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) { .inner_margin(egui::Margin::symmetric(10.0, 2.0)); frame.show(ui, |ui| { - ui.set_min_height(STATUS_H); - ui.horizontal_centered(|ui| { - // статус сообщение - ui.label( - RichText::new(&state.status_message) - .color(TEXT_DIM) - .size(11.0), - ); + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, STATUS_H), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + // статус сообщение + ui.label( + RichText::new(&state.status_message) + .color(TEXT_DIM) + .size(11.0), + ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // аггрегированная скорость - let agg: u64 = state - .queue_tasks - .iter() - .filter(|t| t.state == crate::domain::transfer::TaskState::Running) - .filter_map(|t| t.speed) - .sum(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // аггрегированная скорость + let agg: u64 = state + .queue_tasks + .iter() + .filter(|t| t.state == crate::domain::transfer::TaskState::Running) + .filter_map(|t| t.speed) + .sum(); - if agg > 0 { - ui.label( - RichText::new(format!("⬆⬇ {}/s", format_size(Some(agg)))) - .color(GREEN) - .size(11.0), - ); - ui.add_space(10.0); - } + if agg > 0 { + ui.label( + RichText::new(format!("{}/s", format_size(Some(agg)))) + .color(GREEN) + .monospace() + .size(11.0), + ); + ui.add_space(10.0); + } - // соединения - let connected = state - .tabs - .iter() - .filter(|t| t.status == ConnectionStatus::Connected) - .count(); - let total = state.tabs.len(); + // соединения + let connected = state + .tabs + .iter() + .filter(|t| t.status == ConnectionStatus::Connected) + .count(); + let total = state.tabs.len(); - if total > 0 { - let dot_col = if connected > 0 { GREEN } else { TEXT_HINT }; - ui.label( - RichText::new(format!("● {}/{}", connected, total)) - .color(dot_col) - .size(11.0), - ); - } else { - ui.label( - RichText::new("○ Not connected") - .color(TEXT_HINT) - .size(11.0), - ); - } - }); - }); + if total > 0 { + let dot_col = if connected > 0 { GREEN } else { TEXT_HINT }; + if connected > 0 { + icons::icon(ui, Icon::LockPassword, 11.0, GREEN); + ui.add_space(4.0); + } + ui.label( + RichText::new(format!("{}/{}", connected, total)) + .color(dot_col) + .size(11.0), + ); + } else { + ui.label(RichText::new("Not connected").color(TEXT_HINT).size(11.0)); + } + }); + }, + ); }); } diff --git a/src/ui/panels/tabs.rs b/src/ui/panels/tabs.rs index ac849d7..4c93689 100644 --- a/src/ui/panels/tabs.rs +++ b/src/ui/panels/tabs.rs @@ -14,92 +14,96 @@ pub fn render(ui: &mut Ui, state: &mut AppState) { }); frame.show(ui, |ui| { - ui.set_min_height(TABBAR_H); - ui.horizontal(|ui| { - let mut to_close: Option = None; + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, TABBAR_H), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let mut to_close: Option = None; - if state.tabs.is_empty() { - ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0)); - } + if state.tabs.is_empty() { + ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0)); + } - for (i, tab) in state.tabs.iter().enumerate() { - let is_active = i == state.active_tab; + for (i, tab) in state.tabs.iter().enumerate() { + let is_active = i == state.active_tab; - let (dot_col, dot) = match &tab.status { - ConnectionStatus::Connected => (GREEN, "●"), - ConnectionStatus::Disconnected => (TEXT_HINT, "○"), - ConnectionStatus::Connecting => (YELLOW, "◐"), - ConnectionStatus::Error(_) => (RED, "×"), - }; + let (dot_col, dot) = match &tab.status { + ConnectionStatus::Connected => (GREEN, "●"), + ConnectionStatus::Disconnected => (TEXT_HINT, "○"), + ConnectionStatus::Connecting => (YELLOW, "◐"), + ConnectionStatus::Error(_) => (RED, "×"), + }; - let bg = if is_active { - BG_TAB_ACTIVE - } else { - BG_TAB_IDLE - }; - let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM }; + let bg = if is_active { + BG_TAB_ACTIVE + } else { + BG_TAB_IDLE + }; + let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM }; - let tab_response = egui::Frame::none() - .fill(bg) - .rounding(egui::Rounding { - nw: 4.0, - ne: 4.0, - sw: 0.0, - se: 0.0, - }) - .inner_margin(egui::Margin { - left: 10.0, - right: 4.0, - top: 0.0, - bottom: 0.0, - }) - .show(ui, |ui| { - ui.set_min_height(TABBAR_H); - ui.horizontal_centered(|ui| { - // цветная точка статуса - ui.label(RichText::new(dot).color(dot_col).size(9.0)); - ui.add_space(4.0); - // название вкладки - let lbl = - egui::Label::new(RichText::new(&tab.label).color(tcol).size(12.0)) + let tab_response = egui::Frame::none() + .fill(bg) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin { + left: 10.0, + right: 4.0, + top: 0.0, + bottom: 0.0, + }) + .show(ui, |ui| { + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width().min(220.0), 24.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + // цветная точка статуса + ui.label(RichText::new(dot).color(dot_col).size(9.0)); + ui.add_space(4.0); + // название вкладки + let lbl = egui::Label::new( + RichText::new(&tab.label).color(tcol).size(12.0), + ) .sense(egui::Sense::click()); - if ui.add(lbl).clicked() { - // обрабатывается ниже - } - ui.add_space(6.0); - // кнопка закрытия - let close = - egui::Label::new(RichText::new("×").color(TEXT_HINT).size(14.0)) + if ui.add(lbl).clicked() { + // обрабатывается ниже + } + ui.add_space(6.0); + // кнопка закрытия + let close = egui::Label::new( + RichText::new("×").color(TEXT_HINT).size(14.0), + ) .sense(egui::Sense::click()); - if ui.add(close).clicked() { - to_close = Some(i); - } - ui.add_space(4.0); + if ui.add(close).clicked() { + to_close = Some(i); + } + ui.add_space(4.0); + }, + ); }); - }); - if tab_response.response.clicked() { - state.active_tab = i; + if tab_response.response.clicked() { + state.active_tab = i; + } } - } - // кнопка "+" - ui.add_space(4.0); - let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(16.0)) - .fill(Color32::TRANSPARENT) - .rounding(4.0) - .min_size(egui::vec2(28.0, TABBAR_H)); - if ui.add(plus).clicked() { - state.show_connect_dialog = true; - } - - if let Some(idx) = to_close { - state.tabs.remove(idx); - if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() { - state.active_tab = state.tabs.len() - 1; + // кнопка "+" + ui.add_space(4.0); + let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0)) + .fill(Color32::TRANSPARENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(24.0, 24.0)); + if ui.add(plus).clicked() { + state.show_connect_dialog = true; } - } - }); + + if let Some(idx) = to_close { + state.tabs.remove(idx); + if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() { + state.active_tab = state.tabs.len() - 1; + } + } + }, + ); }); // нижняя граница таббара diff --git a/src/ui/panels/toolbar.rs b/src/ui/panels/toolbar.rs index 87265ef..2583533 100644 --- a/src/ui/panels/toolbar.rs +++ b/src/ui/panels/toolbar.rs @@ -1,105 +1,166 @@ -use crate::ui::state::AppState; +use crate::domain::transfer::{TransferKind, TransferTask}; +use crate::transfer::queue::TransferQueue; +use crate::ui::icons::{self, Icon}; +use crate::ui::state::{AppState, Pane}; use crate::ui::theme::*; use egui::{Align, Layout, RichText, Ui}; -pub fn render(ui: &mut Ui, state: &mut AppState) { +pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) { let frame = egui::Frame::none() .fill(BG_TOOLBAR) .inner_margin(egui::Margin::symmetric(8.0, 0.0)); frame.show(ui, |ui| { - ui.set_min_height(TOOLBAR_H); - ui.horizontal_centered(|ui| { - // Логотип - ui.label( - RichText::new("LoFlum") - .color(TEXT_PRIMARY) - .size(14.0) - .strong(), - ); + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, TOOLBAR_H), + Layout::left_to_right(Align::Center), + |ui| { + // Логотип + icons::icon(ui, Icon::Folder, 17.0, ACCENT); + ui.add_space(4.0); + ui.label( + RichText::new("LoFlum") + .color(TEXT_PRIMARY) + .size(14.0) + .strong(), + ); - ui.add_space(8.0); - separator_v(ui); - ui.add_space(8.0); + ui.add_space(8.0); + separator_v(ui); + ui.add_space(8.0); - // Новое подключение - toolbar_btn(ui, "+ New Connection", false, || { - state.show_connect_dialog = true; - }); - - separator_v(ui); - - // Кнопки работы с файлами - let has_conn = state - .active_tab_ref() - .map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected) - .unwrap_or(false); - - let has_local_sel = state.local_selected.is_some(); - let has_remote_sel = state - .active_tab_ref() - .and_then(|t| t.remote_selected.as_ref()) - .is_some(); - - toolbar_btn_enabled(ui, "⬆ Upload", has_conn && has_local_sel, || { - state.status_message = "Upload: select file and use drag & drop".into(); - }); - toolbar_btn_enabled(ui, "⬇ Download", has_conn && has_remote_sel, || { - state.status_message = "Download: select file and use drag & drop".into(); - }); - - separator_v(ui); - - // Очередь — с счётчиком - let q_count = state.queue_tasks.len(); - let q_label = if q_count > 0 { - format!("⏳ Queue {}", q_count) - } else { - "⏳ Queue".to_string() - }; - let q_active = state.show_queue; - toolbar_btn(ui, &q_label, q_active, || { - state.show_queue = !state.show_queue; - }); - - separator_v(ui); - - toolbar_btn_enabled(ui, "⟳ Refresh", has_conn, || { - // refresh remote — вызывается снаружи через state флаг - state.pending_refresh = true; - }); - toolbar_btn_enabled(ui, "📁 New Folder", has_conn, || { - state.show_mkdir_dialog = true; - state.mkdir_name.clear(); - }); - toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || { - if let Some(name) = state - .active_tab_ref() - .and_then(|t| t.remote_selected.clone()) - { - state.show_delete_dialog = true; - state.delete_name = name; + // Новое подключение — единственная акцентная кнопка в тулбаре + let connect_btn = egui::Button::image_and_text( + icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY), + RichText::new("New Connection") + .color(TEXT_PRIMARY) + .size(12.5), + ) + .fill(ACCENT_DIM) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 30.0)); + if ui.add(connect_btn).clicked() { + state.show_connect_dialog = true; } - }); - toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || { - if let Some(name) = state - .active_tab_ref() - .and_then(|t| t.remote_selected.clone()) - { - state.show_rename_dialog = true; - state.rename_old_name = name.clone(); - state.rename_new_name = name; - } - }); - // Правая часть — History / Bookmarks - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - toolbar_btn(ui, "⏱ History", state.show_history, || { - state.show_history = !state.show_history; - state.show_bookmarks = false; + separator_v(ui); + + // Кнопки работы с файлами + let has_conn = state + .active_tab_ref() + .map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected) + .unwrap_or(false); + + let has_local_sel = state.local_selected.is_some(); + let has_remote_sel = state + .active_tab_ref() + .and_then(|t| t.remote_selected.as_ref()) + .is_some(); + + toolbar_btn_enabled( + ui, + Icon::Upload, + "Upload", + has_conn && has_local_sel, + || { + queue_selected_upload(state, queue); + }, + ); + toolbar_btn_enabled( + ui, + Icon::Download, + "Download", + has_conn && has_remote_sel, + || { + queue_selected_download(state, queue); + }, + ); + + separator_v(ui); + + // Очередь — с счётчиком + let q_count = state.queue_tasks.len(); + let q_label = if q_count > 0 { + format!("Queue {}", q_count) + } else { + "Queue".to_string() + }; + let q_active = state.show_queue; + toolbar_btn(ui, Icon::Clock, &q_label, q_active, || { + state.show_queue = !state.show_queue; }); - }); - }); + + separator_v(ui); + + let on_remote = state.active_pane == Pane::Remote; + let can_new_folder = if on_remote { has_conn } else { true }; + let can_delete_rename = if on_remote { + has_conn && has_remote_sel + } else { + has_local_sel + }; + + toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || { + // refresh remote — вызывается снаружи через state флаг + state.pending_refresh = true; + }); + toolbar_icon_btn( + ui, + Icon::FolderWithFiles, + can_new_folder, + "New Folder", + || { + state.op_target = state.active_pane; + state.show_mkdir_dialog = true; + state.mkdir_name.clear(); + }, + ); + toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || { + let name = if on_remote { + state + .active_tab_ref() + .and_then(|t| t.remote_selected.clone()) + } else { + state.local_selected.clone() + }; + if let Some(name) = name { + state.op_target = state.active_pane; + state.show_delete_dialog = true; + state.delete_name = name; + } + }); + toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || { + let name = if on_remote { + state + .active_tab_ref() + .and_then(|t| t.remote_selected.clone()) + } else { + state.local_selected.clone() + }; + if let Some(name) = name { + state.op_target = state.active_pane; + state.show_rename_dialog = true; + state.rename_old_name = name.clone(); + state.rename_new_name = name; + } + }); + + // Правая часть — History / Bookmarks / Settings + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // На macOS кнопка настроек живёт в нативном верхнем меню + // (LoFlum → Settings…), здесь она не нужна. + #[cfg(not(target_os = "macos"))] + toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || { + state.show_settings_dialog = true; + }); + toolbar_btn(ui, Icon::History, "History", state.show_history, || { + state.show_history = !state.show_history; + state.show_bookmarks = false; + }); + }); + }, + ); }); // История поверх @@ -120,17 +181,22 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) { .order(egui::Order::Foreground) .show(ui.ctx(), |ui| { egui::Frame::none() - .fill(egui::Color32::from_rgb(32, 32, 36)) + .fill(BG_PANEL) .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(6.0) - .inner_margin(egui::Margin::same(8.0)) + .rounding(RADIUS_LG) + .inner_margin(egui::Margin::same(10.0)) .show(ui, |ui| { ui.set_width(260.0); - ui.label( - RichText::new("Recent Connections") - .color(TEXT_DIM) - .size(11.0), - ); + ui.horizontal(|ui| { + icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM); + ui.add_space(6.0); + ui.label( + RichText::new("Recent connections") + .color(TEXT_DIM) + .size(11.0) + .strong(), + ); + }); ui.add_space(4.0); if state.history.is_empty() { ui.label(RichText::new("No history yet").color(TEXT_HINT)); @@ -143,24 +209,30 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) { format!("{}@{}:{}", entry.user, entry.host, entry.port); let time = RichText::new(&entry.time).color(TEXT_HINT).size(10.0); - if ui - .add( - egui::Button::new( - RichText::new(&label) - .color(TEXT_PRIMARY) - .size(12.0), - ) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(240.0, 24.0)), + let btn = ui.add( + egui::Button::new( + RichText::new(&label).color(TEXT_PRIMARY).size(12.0), ) - .clicked() - { - state.connect_host = entry.host.clone(); - state.connect_user = entry.user.clone(); - state.connect_port = entry.port.to_string(); - state.show_connect_dialog = true; + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(240.0, 24.0)), + ); + if btn.clicked() { + state.pending_history_reconnect = Some(entry.clone()); state.show_history = false; } + btn.context_menu(|ui| { + if ui.button("Edit").clicked() { + crate::ui::dialogs::connection::edit_history_entry( + state, entry, + ); + state.show_history = false; + ui.close_menu(); + } + if ui.button("Save").clicked() { + state.pending_history_save = Some(entry.clone()); + ui.close_menu(); + } + }); ui.label(time); } }); @@ -179,25 +251,53 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) { }); } -fn toolbar_btn(ui: &mut Ui, label: &str, active: bool, mut on_click: impl FnMut()) { +fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) { let fill = if active { BG_TAB_ACTIVE } else { egui::Color32::TRANSPARENT }; let text_col = if active { ACCENT } else { TEXT_PRIMARY }; - let btn = egui::Button::new(RichText::new(label).color(text_col).size(12.0)) - .fill(fill) - .rounding(4.0) - .min_size(egui::vec2(0.0, 26.0)); + let btn = egui::Button::image_and_text( + icons::image(icon, 15.0, text_col), + RichText::new(label).color(text_col).size(12.5), + ) + .fill(fill) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 30.0)); if ui.add(btn).clicked() { on_click(); } } -fn toolbar_btn_enabled(ui: &mut Ui, label: &str, enabled: bool, on_click: impl FnMut()) { +fn toolbar_btn_enabled( + ui: &mut Ui, + icon: Icon, + label: &str, + enabled: bool, + on_click: impl FnMut(), +) { ui.add_enabled_ui(enabled, |ui| { - toolbar_btn(ui, label, false, on_click); + toolbar_btn(ui, icon, label, false, on_click); + }); +} + +fn toolbar_icon_btn( + ui: &mut Ui, + icon: Icon, + enabled: bool, + hover: &str, + mut on_click: impl FnMut(), +) { + ui.add_enabled_ui(enabled, |ui| { + let col = if enabled { TEXT_DIM } else { TEXT_HINT }; + let btn = egui::Button::image(icons::image(icon, 16.0, col)) + .fill(egui::Color32::TRANSPARENT) + .rounding(RADIUS_MD) + .min_size(egui::vec2(30.0, 30.0)); + if ui.add(btn).on_hover_text(hover).clicked() { + on_click(); + } }); } @@ -205,3 +305,51 @@ fn separator_v(ui: &mut Ui) { let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0)); ui.painter().rect_filled(rect, 0.0, BORDER); } + +fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) { + let Some(name) = state.local_selected.clone() else { + return; + }; + let Some(tab) = state.active_tab_ref() else { + return; + }; + let connection_id = tab.id.clone(); + let remote_dir = tab.remote_path.clone(); + let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); + let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + local_path, + remote_path, + name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Upload queued: {}", name); +} + +fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) { + let Some(tab) = state.active_tab_ref() else { + return; + }; + let Some(name) = tab.remote_selected.clone() else { + return; + }; + let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else { + return; + }; + let connection_id = tab.id.clone(); + let remote_path = entry.path.clone(); + let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); + let task = TransferTask::new( + TransferKind::Download, + connection_id, + local_path, + remote_path, + name.clone(), + 0, + ); + queue.push(task); + state.status_message = format!("Download queued: {}", name); +} diff --git a/src/ui/state.rs b/src/ui/state.rs index 869ab89..8c73db0 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::domain::connection::{ConnectionParams, ConnectionStatus}; +use crate::domain::connection::{ConnectionParams, ConnectionStatus, Protocol}; use crate::domain::file_entry::FileEntry; use crate::domain::transfer::TransferTask; @@ -16,6 +16,14 @@ pub struct ConnectionTab { pub loading: bool, } +/// Какая панель последней принимала клик — используется тулбаром, +/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Pane { + Local, + Remote, +} + type PendingResult = Arc>>>; pub struct PendingConnect { @@ -29,6 +37,14 @@ pub struct PendingRemoteList { #[derive(Clone)] pub struct Bookmark { + pub id: i64, + pub name: String, + pub path: String, +} + +/// Фиксированный пункт быстрого доступа (Home/Desktop/...) — не удаляется, не хранится в БД. +#[derive(Clone)] +pub struct QuickAccess { pub name: String, pub path: String, } @@ -39,6 +55,10 @@ pub struct HistoryEntry { pub port: u16, pub user: String, pub time: String, + /// Стабильный id этой цели — под ним же лежит пароль в keychain. + pub conn_id: String, + pub protocol: Protocol, + pub key_path: Option, } pub struct AppState { @@ -49,10 +69,20 @@ pub struct AppState { pub local_entries: Vec, pub local_selected: Option, + /// Панель, в которой был последний клик — New Folder/Rename/Delete в тулбаре + /// применяются к ней. + pub active_pane: Pane, + /// К какой стороне относится открытый сейчас диалог mkdir/rename/delete. + pub op_target: Pane, + pub show_connect_dialog: bool, pub show_bookmarks: bool, pub show_history: bool, + pub show_settings_dialog: bool, + /// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД. + pub quick_access: Vec, + /// Закладки, добавленные пользователем — хранятся в БД, можно удалять. pub bookmarks: Vec, pub history: Vec, @@ -74,6 +104,10 @@ pub struct AppState { // action flags — выставляются тулбаром, обрабатываются в app.rs pub pending_refresh: bool, + /// Клик по записи истории — переподключиться (новая вкладка, без диалога). + pub pending_history_reconnect: Option, + /// ПКМ → "Save" на записи истории — сохранить как постоянный Site. + pub pending_history_save: Option, // диалоги операций над удалённой ФС pub show_mkdir_dialog: bool, @@ -105,41 +139,45 @@ impl Default for AppState { local_path: home.clone(), local_entries: Vec::new(), local_selected: None, + active_pane: Pane::Local, + op_target: Pane::Local, show_connect_dialog: false, show_bookmarks: false, show_history: false, - bookmarks: vec![ - Bookmark { + show_settings_dialog: false, + quick_access: vec![ + QuickAccess { name: "Home".into(), path: home.clone(), }, - Bookmark { + QuickAccess { name: "Desktop".into(), path: format!("{}/Desktop", home), }, - Bookmark { + QuickAccess { name: "Documents".into(), path: format!("{}/Documents", home), }, - Bookmark { + QuickAccess { name: "Downloads".into(), path: dirs::download_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| format!("{}/Downloads", home)), }, - Bookmark { + QuickAccess { name: "Pictures".into(), path: format!("{}/Pictures", home), }, - Bookmark { + QuickAccess { name: "Music".into(), path: format!("{}/Music", home), }, - Bookmark { + QuickAccess { name: "Videos".into(), path: format!("{}/Videos", home), }, ], + bookmarks: Vec::new(), history: Vec::new(), connect_label: String::new(), connect_host: String::new(), @@ -155,6 +193,8 @@ impl Default for AppState { status_message: "Ready".into(), connected_count: 0, pending_refresh: false, + pending_history_reconnect: None, + pending_history_save: None, show_mkdir_dialog: false, show_delete_dialog: false, show_rename_dialog: false, @@ -188,20 +228,25 @@ impl AppState { Some(&self.tabs[idx]) } - pub fn add_history(&mut self, host: &str, port: u16, user: &str) { - use chrono::Local; - let now = Local::now().format("%H:%M %d.%m").to_string(); - self.history.insert( - 0, - HistoryEntry { - host: host.into(), - port, - user: user.into(), - time: now, - }, - ); - if self.history.len() > 20 { - self.history.truncate(20); + /// Перечитывает историю подключений из БД (после записи новой попытки). + pub fn reload_history(&mut self, db: &Arc>) { + if let Ok(conn) = db.lock() + && let Ok(rows) = crate::storage::db::get_history(&conn) + { + self.history = rows + .into_iter() + .map( + |(host, port, user, time, conn_id, protocol, key_path)| HistoryEntry { + host, + port, + user, + time, + conn_id, + protocol, + key_path, + }, + ) + .collect(); } } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 965c6f2..fa15479 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,40 +1,46 @@ -//! Цвета и константы темы LoFlum +//! Цвета и константы темы LoFlum — "Clay" use egui::Color32; -// Backgrounds -pub const BG_BASE: Color32 = Color32::from_rgb(18, 18, 18); // самый тёмный фон -pub const BG_PANEL: Color32 = Color32::from_rgb(24, 24, 24); // боковая панель -pub const BG_CONTENT: Color32 = Color32::from_rgb(28, 28, 28); // основная область -pub const BG_TOOLBAR: Color32 = Color32::from_rgb(22, 22, 22); // тулбар -pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(38, 38, 42); // активная вкладка -pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(28, 28, 30); // неактивная -pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(35, 35, 38); -pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 64, 95); // выделение — синеватое -pub const BG_QUEUE: Color32 = Color32::from_rgb(20, 20, 22); -pub const BG_HEADER: Color32 = Color32::from_rgb(22, 22, 24); +// Backgrounds — тёплый почти-чёрный, а не нейтрально-синий +pub const BG_BASE: Color32 = Color32::from_rgb(23, 20, 15); // #17140F — самый тёмный фон +pub const BG_PANEL: Color32 = Color32::from_rgb(25, 22, 17); // #19160F — тулбар/боковая панель +pub const BG_CONTENT: Color32 = Color32::from_rgb(32, 29, 25); // #201D19 — основная область +pub const BG_TOOLBAR: Color32 = Color32::from_rgb(25, 22, 17); // #19160F +pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(36, 29, 20); // #241D14 — активная вкладка (clay-tint) +pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(23, 20, 15); +pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(38, 32, 25); // #262019 +pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 32, 24); // #2A2018 — тёплое выделение +pub const BG_QUEUE: Color32 = Color32::from_rgb(22, 19, 16); // #161310 +pub const BG_HEADER: Color32 = Color32::from_rgb(23, 20, 15); -// Accents -pub const ACCENT: Color32 = Color32::from_rgb(64, 130, 220); // синий акцент -pub const ACCENT_DIM: Color32 = Color32::from_rgb(45, 95, 165); -pub const GREEN: Color32 = Color32::from_rgb(60, 180, 100); -pub const RED: Color32 = Color32::from_rgb(210, 70, 70); -pub const YELLOW: Color32 = Color32::from_rgb(210, 160, 50); -pub const ORANGE: Color32 = Color32::from_rgb(210, 120, 50); +// Accents — clay/терракота вместо синего, один акцент на экран +pub const ACCENT: Color32 = Color32::from_rgb(193, 103, 60); // #C1673C — clay +pub const ACCENT_DIM: Color32 = Color32::from_rgb(139, 74, 44); // #8B4A2C — приглушённый clay +pub const ON_ACCENT: Color32 = Color32::from_rgb(26, 22, 17); // #1A1611 — текст на clay-заливке +pub const GREEN: Color32 = Color32::from_rgb(122, 148, 113); // #7A9471 — sage (успех/подключено) +pub const RED: Color32 = Color32::from_rgb(179, 82, 62); // #B3523E — brick (ошибка) +pub const YELLOW: Color32 = Color32::from_rgb(217, 164, 65); // #D9A441 — amber (папки/предупреждение) +pub const ORANGE: Color32 = Color32::from_rgb(193, 103, 60); // Text -pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(220, 220, 220); -pub const TEXT_DIM: Color32 = Color32::from_rgb(130, 130, 135); -pub const TEXT_HINT: Color32 = Color32::from_rgb(80, 80, 85); +pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(237, 232, 224); // #EDE8E0 +pub const TEXT_DIM: Color32 = Color32::from_rgb(166, 156, 142); // #A69C8E +pub const TEXT_HINT: Color32 = Color32::from_rgb(110, 101, 90); // #6E655A // Borders / separators -pub const BORDER: Color32 = Color32::from_rgb(40, 40, 44); -pub const SEPARATOR: Color32 = Color32::from_rgb(35, 35, 38); +pub const BORDER: Color32 = Color32::from_rgb(46, 42, 36); // #2E2A24 +pub const SEPARATOR: Color32 = Color32::from_rgb(40, 36, 30); // #28241E // Size constants -pub const SIDEBAR_W: f32 = 180.0; -pub const TOOLBAR_H: f32 = 40.0; -pub const TABBAR_H: f32 = 32.0; -pub const QUEUE_COLLAPSED_H: f32 = 32.0; -pub const QUEUE_EXPANDED_H: f32 = 160.0; -pub const STATUS_H: f32 = 24.0; -pub const ROW_H: f32 = 22.0; +pub const SIDEBAR_W: f32 = 200.0; +pub const TOOLBAR_H: f32 = 48.0; +pub const TABBAR_H: f32 = 36.0; +pub const QUEUE_COLLAPSED_H: f32 = 34.0; +pub const QUEUE_EXPANDED_H: f32 = 190.0; +pub const STATUS_H: f32 = 26.0; +pub const ROW_H: f32 = 28.0; + +// Radius +pub const RADIUS_SM: f32 = 5.0; +pub const RADIUS_MD: f32 = 6.0; +pub const RADIUS_LG: f32 = 10.0;