diff --git a/.gitignore b/.gitignore index 0e76d10..b7a788c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .cargo/ -Cargo.lock *.pdb **/*.rs.bk +tmp/ debug/ target/ vendor/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c9a81f3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7880 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.16.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" + +[[package]] +name = "accesskit_atspi_common" +version = "0.9.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant 3.15.2", +] + +[[package]] +name = "accesskit_consumer" +version = "0.24.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "immutable-chunkmap", +] + +[[package]] +name = "accesskit_macos" +version = "0.17.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "accesskit_consumer", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", +] + +[[package]] +name = "accesskit_unix" +version = "0.12.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "atspi", + "futures-lite 1.13.0", + "serde", + "tokio", + "tokio-stream", + "zbus 3.15.2", +] + +[[package]] +name = "accesskit_windows" +version = "0.22.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "accesskit_consumer", + "paste", + "static_assertions", + "windows 0.54.0", +] + +[[package]] +name = "accesskit_winit" +version = "0.22.0" +source = "git+https://github.com/wash2/accesskit?tag=iced-xdg-surface-0.13-rc#c46afc041b1968a5af0186fa6aba3ea9cf24c8c3" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "almost" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa2999eb46af81abb65c2d30d446778d7e613b60bbf4e174a027e80f90a3c14" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "apply" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47b57fc4521e3cae26a4d45b5227f8fadee4c345be0fefd8d5d1711afb8aeb9" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.13.0", +] + +[[package]] +name = "ashpd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.13.0", +] + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "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 = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.3.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "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", + "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.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomicwrites" +version = "0.4.2" +source = "git+https://github.com/jackpot51/rust-atomicwrites#043ab4859d53ffd3d55334685303d8df39c9f768" +dependencies = [ + "rustix 0.38.44", + "tempfile", + "windows-sys 0.48.0", +] + +[[package]] +name = "atspi" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6059f350ab6f593ea00727b334265c4dfc7fd442ee32d264794bd9bdc68e87ca" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92af95f966d2431f962bc632c2e68eda7777330158bf640c4af4249349b2cdf5" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + +[[package]] +name = "atspi-connection" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c65e7d70f86d4c0e3b2d585d9bf3f979f0b19d635a336725a88d279f76b939" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite 1.13.0", + "zbus 3.15.2", +] + +[[package]] +name = "atspi-proxies" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6495661273703e7a229356dcbe8c8f38223d697aacfaf0e13590a9ac9977bb52" +dependencies = [ + "atspi-common", + "serde", + "zbus 3.15.2", +] + +[[package]] +name = "auto_enums" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c170965892137a3a9aeb000b4524aa3cc022a310e709d848b6e1cdce4ab4781" +dependencies = [ + "derive_utils", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec 0.7.6", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.17", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec 0.7.6", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec 0.5.2", + "constant_time_eq", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.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.10.0", + "log", + "polling 3.11.0", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling 3.11.0", + "rustix 1.1.3", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.3", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +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 = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.0" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "objc", + "objc-foundation", + "objc_id", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "dnd", + "mime", + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.2" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "thiserror 1.0.69", + "x11rb", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-client-toolkit" +version = "0.1.0" +source = "git+https://github.com/pop-os/cosmic-protocols?rev=d0e95be#d0e95be25e423cfe523b11111a3666ed7aaf0dc4" +dependencies = [ + "bitflags 2.10.0", + "cosmic-protocols", + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-client", + "wayland-protocols", +] + +[[package]] +name = "cosmic-config" +version = "0.1.0" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "atomicwrites", + "cosmic-config-derive", + "cosmic-settings-daemon", + "dirs 6.0.0", + "futures-util", + "iced_futures", + "known-folders", + "notify", + "ron", + "serde", + "tokio", + "tracing", + "xdg", + "zbus 5.13.0", +] + +[[package]] +name = "cosmic-config-derive" +version = "0.1.0" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cosmic-freedesktop-icons" +version = "0.4.0" +source = "git+https://github.com/pop-os/freedesktop-icons#7a61a704f6d1ec41f71cbe766e3cc484858523fa" +dependencies = [ + "bstr", + "btoi", + "memchr", + "memmap2 0.9.9", + "thiserror 2.0.17", + "tracing", + "xdg", +] + +[[package]] +name = "cosmic-protocols" +version = "0.1.0" +source = "git+https://github.com/pop-os/cosmic-protocols?rev=d0e95be#d0e95be25e423cfe523b11111a3666ed7aaf0dc4" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "cosmic-settings-daemon" +version = "0.1.0" +source = "git+https://github.com/pop-os/dbus-settings-bindings#87c3c35666b926a24a1e8045fd70be2db1145e34" +dependencies = [ + "zbus 5.13.0", +] + +[[package]] +name = "cosmic-text" +version = "0.16.0" +source = "git+https://github.com/pop-os/cosmic-text.git#0d9af4f7de087878100b296c81d1baca2e05433d" +dependencies = [ + "bitflags 2.10.0", + "fontdb 0.23.0", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.39.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cosmic-theme" +version = "0.1.0" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "almost", + "cosmic-config", + "csscolorparser", + "dirs 6.0.0", + "palette", + "ron", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "css-color" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42aaeae719fd78ce501d77c6cdf01f7e96f26bcd5617a4903a1c2b97e388543a" + +[[package]] +name = "csscolorparser" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16" +dependencies = [ + "phf 0.11.3", + "serde", +] + +[[package]] +name = "ctor-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.10.0", + "libloading", + "winapi", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_setters" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_utils" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "libc", + "objc2 0.6.3", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "dnd" +version = "0.1.0" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "bitflags 2.10.0", + "mime", + "raw-window-handle", + "smithay-client-toolkit 0.19.2", + "smithay-clipboard", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.1" +source = "git+https://github.com/pop-os/winit.git?tag=iced-xdg-surface-0.13-rc#12a5f17d1811cdebbcbd310a3d92965e9142fa12" + +[[package]] +name = "drm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f8a69e60d75ae7dab4ef26a59ca99f2a89d4c142089b537775ae0c198bdcde" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41334f8405792483e32ad05fbb9c5680ff4e84491883d2947a4757dc54cb2ac6" +dependencies = [ + "drm-sys", + "rustix 0.38.44", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d09ff881f92f118b11105ba5e34ff8f4adf27b30dae8f12e28c193af1c83176" +dependencies = [ + "libc", + "linux-raw-sys 0.6.5", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enquote" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c36cb11dbde389f4096111698d8b567c0720e3452fd5ac3e6b4e47e1939932" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 2.1.1", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.17", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32eac81c1135c1df01d4e6d4233c47ba11f6a6d07f33e0bba09d18797077770" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.9", + "slotmap", + "tinyvec", + "ttf-parser 0.21.1", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.9", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.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.114", +] + +[[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 = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "grid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e2d4c0a8296178d8802098410ca05d86b17a10bb5ab559b3fb404c1f948220" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts 0.36.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.10.0", + "com", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot 0.12.5", + "rust-embed", + "sys-locale", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e598ed73b67db92f61e04672e599eef2991a262a40e1666735b8a86d2e7e9f30" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "iced" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "dnd", + "iced_accessibility", + "iced_core", + "iced_futures", + "iced_renderer", + "iced_widget", + "iced_winit", + "image", + "mime", + "thiserror 1.0.69", + "window_clipboard", +] + +[[package]] +name = "iced_accessibility" +version = "0.1.0" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "accesskit", + "accesskit_winit", +] + +[[package]] +name = "iced_core" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "cosmic-client-toolkit", + "dnd", + "glam", + "iced_accessibility", + "log", + "mime", + "num-traits", + "once_cell", + "palette", + "raw-window-handle", + "rustc-hash 2.1.1", + "serde", + "smol_str", + "thiserror 1.0.69", + "web-time", + "window_clipboard", +] + +[[package]] +name = "iced_futures" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.1", + "tokio", + "wasm-bindgen-futures", + "wasm-timer", +] + +[[package]] +name = "iced_glyphon" +version = "0.6.0" +source = "git+https://github.com/pop-os/glyphon.git?tag=iced-0.14-dev#6ef9d12a20cfd0f7bdf38136a26ded9f7459ec8b" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.1", + "wgpu", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image", + "kamadak-exif", + "log", + "lyon_path", + "once_cell", + "raw-window-handle", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "bytes", + "cosmic-client-toolkit", + "dnd", + "iced_accessibility", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 1.0.69", + "window_clipboard", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_graphics", + "kurbo 0.10.4", + "log", + "resvg 0.42.0", + "rustc-hash 2.1.1", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "as-raw-xcb-connection", + "bitflags 2.10.0", + "bytemuck", + "cosmic-client-toolkit", + "futures", + "glam", + "guillotiere", + "iced_glyphon", + "iced_graphics", + "log", + "lyon", + "once_cell", + "raw-window-handle", + "resvg 0.42.0", + "rustc-hash 2.1.1", + "rustix 0.38.44", + "thiserror 1.0.69", + "tiny-xlib", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-sys", + "wgpu", + "x11rb", +] + +[[package]] +name = "iced_widget" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "cosmic-client-toolkit", + "dnd", + "iced_accessibility", + "iced_renderer", + "iced_runtime", + "log", + "num-traits", + "once_cell", + "ouroboros", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", + "window_clipboard", +] + +[[package]] +name = "iced_winit" +version = "0.14.0-dev" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "cosmic-client-toolkit", + "dnd", + "iced_accessibility", + "iced_futures", + "iced_graphics", + "iced_runtime", + "log", + "raw-window-handle", + "rustc-hash 2.1.1", + "rustix 0.38.44", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "winapi", + "window_clipboard", + "winit", + "xkbcommon 0.7.0", + "xkbcommon-dl", + "xkeysym", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif 0.14.1", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.0", + "zune-jpeg 0.5.8", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "immutable-chunkmap" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[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", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "known-folders" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d463f34ca3c400fde3a054da0e0b8c6ffa21e4590922f3e18281bb5eeef4cbdc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec 0.7.6", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec 0.7.6", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libcosmic" +version = "0.1.0" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" +dependencies = [ + "apply", + "ashpd 0.12.0", + "auto_enums", + "chrono", + "cosmic-client-toolkit", + "cosmic-config", + "cosmic-freedesktop-icons", + "cosmic-settings-daemon", + "cosmic-theme", + "css-color", + "derive_setters", + "futures", + "i18n-embed", + "i18n-embed-fl", + "iced", + "iced_accessibility", + "iced_core", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_tiny_skia", + "iced_wgpu", + "iced_widget", + "iced_winit", + "image", + "log", + "palette", + "phf 0.13.1", + "raw-window-handle", + "rfd", + "ron", + "rust-embed", + "serde", + "slotmap", + "taffy", + "thiserror 2.0.17", + "tokio", + "tracing", + "unicode-segmentation", + "url", + "zbus 5.13.0", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "lyon" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343" +dependencies = [ + "arrayvec 0.7.6", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.1.0" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec 0.7.6", + "bit-set", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "noctua" +version = "0.1.0" +dependencies = [ + "anyhow", + "cairo-rs", + "clap", + "dirs 5.0.1", + "env_logger", + "futures-util", + "i18n-embed", + "i18n-embed-fl", + "image", + "kamadak-exif", + "libcosmic", + "log", + "open", + "poppler", + "resvg 0.45.1", + "rust-embed", + "sha2", + "simple_logger", + "tokio", + "wallpaper", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", + "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.10.0", + "block2 0.5.1", + "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 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "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 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "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.10.0", + "block2 0.5.1", + "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 0.5.1", + "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.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf 0.11.3", + "serde", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand 2.3.0", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poppler" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6580b718aba679b295299567119284d534d7bfc7510259d0c72273879831da8d" +dependencies = [ + "cairo-rs", + "glib", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.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-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "version_check", + "yansi", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec 0.7.6", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.17", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "read-fonts" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall 0.1.57", + "rust-argon2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944d052815156ac8fa77eaac055220e95ba0b01fa8887108ca710c03805d9051" +dependencies = [ + "gif 0.13.3", + "jpeg-decoder", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg 0.42.0", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd 0.11.0", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ron" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +dependencies = [ + "base64 0.22.1", + "bitflags 2.10.0", + "serde", + "serde_derive", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rust-embed" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rust-ini" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac66e816614e124a692b6ac1b8437237a518c9155a3aacab83a373982630c715" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring 0.4.0", + "unicode-ccc 0.4.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2 0.9.9", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simple_logger" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7e46c8c90251d47d08b28b8a419ffb4aede0f87c2eea95e17d1d5bacbf3ef1" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.48.0", +] + +[[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 = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" +dependencies = [ + "bytemuck", + "read-fonts 0.36.0", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2 0.9.9", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2 0.9.9", + "pkg-config", + "rustix 1.1.3", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkbcommon 0.8.0", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.8.0" +source = "git+https://github.com/pop-os/smithay-clipboard?tag=pop-dnd-5#5a3007def49eb678d1144850c9ee04b80707c56a" +dependencies = [ + "libc", + "raw-window-handle", + "smithay-client-toolkit 0.19.2", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.1" +source = "git+https://github.com/pop-os/softbuffer?tag=cosmic-4.0#a3f77e251e7422803f693df6e3fc313c010c4dcb" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "cfg_aliases 0.2.1", + "cocoa", + "core-graphics", + "drm", + "fastrand 2.3.0", + "foreign-types", + "js-sys", + "log", + "memmap2 0.9.9", + "objc", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 0.38.44", + "tiny-xlib", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.52.0", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa 0.37.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[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 0.8.2", + "version-compare", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec 0.7.6", + "grid", + "serde", + "slotmap", +] + +[[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.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand 2.3.0", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[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" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "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.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb 0.18.0", + "imagesize 0.12.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz 0.14.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz 0.20.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wallpaper" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0547c84bf49b1096b20ce49736b86cd27f8225fc426665d3fba19e71e44c4d46" +dependencies = [ + "dirs 1.0.5", + "enquote", + "rust-ini", + "winapi", + "winreg", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.3", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +dependencies = [ + "rustix 1.1.3", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-server" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9297ab90f8d1f597711d36455c5b1b2290eca59b8134485e377a296b80b118c9" +dependencies = [ + "bitflags 2.10.0", + "downcast-rs", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec 0.7.6", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec 0.7.6", + "bit-vec", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec 0.7.6", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.4.1" +source = "git+https://github.com/pop-os/window_clipboard.git?tag=pop-0.13-2#6b9faab87bea9cebec6ae036906fd67fed254f5f" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "dnd", + "mime", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-implement 0.53.0", + "windows-interface 0.53.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.5" +source = "git+https://github.com/pop-os/winit.git?tag=iced-xdg-surface-0.13-rc#12a5f17d1811cdebbcbd310a3d92965e9142fa12" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2 0.9.9", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cdb3898397cf7f624c294948669beafaeebc5577d5ec53d0afb76633593597" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.3", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2 0.9.9", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "xml-rs" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast 0.5.1", + "async-process 1.8.1", + "async-recursion", + "async-trait", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "once_cell", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515214ab069b46f614dee52c1256015cdc1a0b441ed612118e2871014956741" +dependencies = [ + "async-broadcast 0.7.2", + "async-executor", + "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-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.3", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros 5.13.0", + "zbus_names 4.3.1", + "zvariant 5.9.0", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zbus_macros" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f54d8a5b4e9c46cf4a9732da4899b12851b5df952fc8deda23aca1d6f3e26c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zbus_names 4.3.1", + "zvariant 5.9.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant 3.15.2", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant 5.9.0", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +dependencies = [ + "zune-core 0.5.0", +] + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive 3.15.2", +] + +[[package]] +name = "zvariant" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "788ca131e3757991e4b9fe9f7b78ae302749ed96093ff60858a1f4732b04b164" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.14", + "zvariant_derive 5.9.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils 1.0.1", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e69a2f6b221a6fec9bd6bcc77c19360cca106f92a5fd948b8aa17d2339c7505" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "winnow 0.7.14", +] + +[[patch.unused]] +name = "cosmic-config" +version = "1.0.0" + +[[patch.unused]] +name = "cosmic-theme" +version = "1.0.0" + +[[patch.unused]] +name = "libcosmic-yoda" +version = "0.1.0-yoda.2" diff --git a/Cargo.toml b/Cargo.toml index 986a96f..c81d358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,38 +1,44 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Cargo.toml # -# Noctua COSMIC desktop application +# Project metadata and dependencies for the Noctua document viewer. [package] name = "noctua" version = "0.1.0" -edition = "2021" -description = "Noctua – a document viewer for the COSMIC™ desktop" +edition = "2024" +description = "A wise document and image viewer for the COSMIC™ desktop" repository = "https://codeberg.org/wfx/noctua" authors = ["Wolfgang Morawetz "] license = "GPL-3.0-or-later" +keywords = ["document", "image", "viewer", "pdf", "cosmic"] +categories = ["gui", "multimedia::graphics", "multimedia::images"] [features] default = ["image", "vector", "portable"] image = ["dep:image", "dep:kamadak-exif"] vector = ["dep:resvg"] -portable = ["dep:pdfium-render"] +portable = ["dep:poppler", "dep:cairo-rs"] full = ["image", "vector", "portable"] [dependencies] -# Serialization -serde = { version = "1", features = ["derive"] } -ron = "0.8" +# Error handling +anyhow = "1" -# Feature-gated document backends -image = { version = "0.25", optional = true } -kamadak-exif = { version = "0.5", optional = true } -pdfium-render = { version = "0.8", optional = true } +# Feature-gated dependencies +kamadak-exif = { version = "0.5.5", optional = true } +image = { version = "0.25.9", optional = true } +poppler = { version = "0.4", features = ["render"], optional = true } +cairo-rs = { version = "0.18", features = ["png"], optional = true } resvg = { version = "0.45", optional = true } +# Async / concurrency +futures-util = "0.3.31" +tokio = { version = "1.48.0", features = ["full"] } + # Logging -log = "0.4" -env_logger = "0.11" +log = "0.4.20" +simple_logger = "4.2.0" # i18n / localization i18n-embed = { version = "0.16", features = [ @@ -41,35 +47,41 @@ i18n-embed = { version = "0.16", features = [ ] } i18n-embed-fl = "0.10" -# Resource embedding -rust-embed = "8" - -# Open files/URLs with the system default application -open = "5" - -# Thumbnail cache (freedesktop.org spec) +# Misc utilities +open = "5.3.2" +rust-embed = "8.8.0" dirs = "5.0" -md5 = "0.7" -png = "0.17" +sha2 = "0.10" +clap = { version = "4.5.54", features = ["derive"] } +env_logger = "0.11.8" +wallpaper = "3.2" -# CLI argument parsing -clap = { version = "4", features = ["derive"] } - -# Async runtime (required by libcosmic) -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } - -[dependencies.cosmic] -package = "libcosmic-yoda" -path = "../libcosmic" -default-features = false +[dependencies.libcosmic] +git = "https://github.com/pop-os/libcosmic.git" +# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features. features = [ + # Accessibility support "a11y", + # About widget for the app "about", + # Uses cosmic-settings-daemon to watch for config file changes "dbus-config", + # Support creating additional application windows. "multi-window", + # On app startup, focuses an existing instance if the app is already open "single-instance", + # Uses tokio as the executor for the runtime "tokio", + # Windowing support for X11, Windows, Mac, & Redox "winit", + # Add Wayland support to winit "wayland", + # GPU-accelerated rendering "wgpu", ] + +# Uncomment to test a locally-cloned libcosmic +[patch.'https://github.com/pop-os/libcosmic'] +libcosmic-yoda = { path = "../libcosmic" } +cosmic-config = { path = "../libcosmic/cosmic-config" } +cosmic-theme = { path = "../libcosmic/cosmic-theme" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5f4c3e --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# noctua + diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..5cd123e --- /dev/null +++ b/docs/features.md @@ -0,0 +1,202 @@ +# Noctua Features + +This document describes the implemented and planned features of Noctua, a modern image viewer for the COSMIC desktop environment. + +## Current Features + +### Document Support + +#### Raster Images (Implemented) +- **Formats**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs` +- **Capabilities**: + - Full pixel-perfect rendering at 100% zoom + - Lossless transformations (rotate, flip) + - Real-time transformation preview + - EXIF metadata extraction + +#### Vector Graphics (Implemented) +- **Formats**: SVG +- **Rendering**: High-quality rendering via `resvg` library +- **Capabilities**: + - Scalable display at any zoom level without quality loss + - Transformations (rotate, flip) + +#### Portable Documents (Implemented) +- **Formats**: PDF +- **Rendering**: Full PDF rendering via poppler library +- **Multi-page navigation**: Browse through all pages of a document +- **Page thumbnails**: Left sidebar shows page previews (generated on demand) +- **Transformations**: Rotate and flip on rendered pages + +### Navigation + +#### Folder Navigation (Implemented) +- **Automatic folder scanning**: When opening an image, all supported images in the same folder are indexed +- **Quick navigation**: + - Arrow keys (Left/Right) to navigate between images + - Footer displays current position (e.g., "3 / 42") + - Seamless transitions between images + +#### File Opening (Implemented) +- **Command-line arguments**: Open images directly from terminal +- **Default directory**: Configurable starting location (defaults to XDG Pictures) +- File dialog not yet implemented + +### View Controls + +#### Zoom (Implemented) +- **Mouse wheel**: Zoom in/out centered on cursor position +- **Keyboard shortcuts**: + - `+` or `=` - Zoom in + - `-` - Zoom out + - `1` - Reset to 100% (Actual Size) + - `f` - Fit to window +- **View modes**: + - **Fit**: Automatically scales image to fit window while preserving aspect ratio + - **Actual Size**: Displays image at 100% (1:1 pixel mapping) + - **Custom**: Any zoom level from 10% to 2000% +- **Footer display**: Real-time zoom percentage or "Fit" indicator + +#### Pan (Implemented) +- **Mouse drag**: Click and drag to pan around zoomed images +- **Keyboard shortcuts**: `Ctrl + Arrow Keys` for precise panning +- **Smart boundaries**: Pan is automatically limited to image boundaries +- **Auto-center**: Images smaller than viewport are automatically centered + +#### Bidirectional State Sync (Implemented) +- Mouse interactions update keyboard/button controls +- Keyboard/button controls update mouse interaction state +- No conflicts between input methods + +### Transformations + +#### Image Manipulation (Implemented) +- **Rotate**: + - `r` - Rotate 90° clockwise + - `Shift + r` - Rotate 90° counter-clockwise + - Toolbar buttons available +- **Flip**: + - `h` - Flip horizontally (mirror) + - `v` - Flip vertically + - Toolbar buttons available +- **Lossless operations**: All transformations preserve original image quality +- **Real-time preview**: Changes are immediately visible + +### User Interface + +#### COSMIC Integration (Implemented) +- **Native COSMIC design**: Follows COSMIC desktop design language +- **Theme support**: Automatically adapts to system light/dark theme +- **Header toolbar**: + - Left: Navigation controls (Previous/Next) and panel toggle + - Center: Transformation buttons (Rotate, Flip) - horizontally centered + - Right: Information panel toggle +- **Footer bar**: + - Zoom controls with buttons + - Current zoom level display + - Image dimensions + - Navigation position counter + +#### Panels (Implemented) +- **Properties panel**: + - Image metadata display + - File information + - Action buttons: + - Set as Wallpaper (works with COSMIC, GNOME, KDE, XFCE, and tiling WMs) + - Open With… (planned) + - Show in Folder (planned) + - Toggle with `i` key or toolbar button +- **Navigation panel** (Left sidebar): + - Toggle with `n` key or toolbar button + - For multi-page documents (PDF): Shows page thumbnails + - Click to navigate to specific page + +#### Keyboard Shortcuts (Implemented) +Full keyboard-driven workflow: +- Navigation: `←` `→` +- Zoom: `+` `-` `1` `f` +- Pan: `Ctrl + ←` `Ctrl + →` `Ctrl + ↑` `Ctrl + ↓` +- Transform: `r` `Shift+r` `h` `v` +- Panels: `i` `n` +- Actions: `w` (Set as Wallpaper) + +### Desktop Integration + +#### Wallpaper Support (Implemented) +- **Set as Wallpaper**: One-click wallpaper setting with cross-desktop compatibility +- **Supported desktop environments**: + - COSMIC Desktop (direct config file integration) + - GNOME (via gsettings) + - KDE Plasma (via wallpaper crate) + - XFCE (via wallpaper crate) + - Tiling window managers (via feh) +- **Multiple access methods**: + - Keyboard shortcut: `w` + - Icon button in Properties panel + - Tooltip support for discoverability +- **Automatic fallback**: Tries multiple methods until one succeeds + +### Configuration + +#### Persistent Settings (Implemented) +- **Panel states**: Remembers which panels were open +- **Default directory**: Customizable starting location +- **Settings location**: `~/.config/noctua/config.toml` + +### Technical Features + +#### Architecture (Implemented) +- **Clean separation**: View layer agnostic to document format +- **Polymorphic documents**: Single `DocumentContent` interface for all formats +- **Efficient rendering**: Leverages COSMIC's iced renderer +- **Type-safe transformations**: Compile-time guarantees for image operations + +#### Performance (Implemented) +- **Lazy loading**: Images loaded on-demand +- **Efficient folder scanning**: Fast directory traversal +- **Minimal memory footprint**: Only active document kept in memory +- **Smooth zooming**: Hardware-accelerated rendering + +## Planned Features + +### High Priority + +#### File Operations +- File dialog integration (OpenPath message prepared) +- Save transformed images +- Copy/Move/Delete operations +- Drag-and-drop support + +### Medium Priority + +#### Multi-format TIFF Support +- Multi-page TIFF navigation (infrastructure ready) +- Page thumbnails for TIFF (same as PDF) + +#### Metadata Editing +- EXIF data modification +- Comment annotations +- Tag management + +### Low Priority + +#### Advanced Editing +- Crop tool (message prepared) +- Scale/Resize tool (message prepared) +- Basic color adjustments (brightness, contrast) + +#### Enhanced Navigation +- Grid view for folder contents +- Quick jump to file + +#### Slideshow Mode +- Auto-advance timer +- Configurable intervals +- Fullscreen support + +## Feature Status Legend + +- **Implemented**: Fully functional and tested +- **Planned**: Design complete, implementation pending +- **Partial**: Basic functionality exists, enhancements needed +- **In Progress**: Currently being developed diff --git a/docs/images/screenshot.png b/docs/images/screenshot.png new file mode 100644 index 0000000..3a374f4 Binary files /dev/null and b/docs/images/screenshot.png differ diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..72d032b --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,193 @@ +# Usage Guide + +Noctua is a modern image viewer for the COSMIC desktop environment with full keyboard support. + +## Opening Images + +### Command Line +Open an image directly from the terminal: +```bash +noctua /path/to/image.png +``` + +When you open an image, Noctua automatically scans the folder and indexes all supported images for quick navigation. + +### Supported Formats +- **Raster Images**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs` +- **Vector Graphics**: SVG (with scalable rendering) +- **Portable Documents**: PDF (with multi-page navigation and thumbnails) + +## Keyboard Shortcuts + +All keyboard shortcuts are case-insensitive unless otherwise noted. + +### Navigation + +| Key | Action | Description | +|:------|:------------------|:-----------------------------------------------| +| `←` | Previous image | Navigate to the previous image in the folder | +| `→` | Next image | Navigate to the next image in the folder | + +The footer shows your current position (e.g., "3 / 42"). + +For multi-page documents (PDF): +- Click thumbnails in the left sidebar to jump to a specific page +- Use `←` `→` to navigate between pages + +### Zoom and View + +| Key | Action | Description | +|:----------|:---------------------------|:------------------------------------------------------| +| `+` / `=` | Zoom in | Increase zoom by 10% | +| `-` | Zoom out | Decrease zoom by ~9% | +| `1` | Actual size (100%) | Display image at pixel-perfect 1:1 scale | +| `f` | Fit to window | Scale image to fit the window while preserving ratio | + +You can also zoom with the **mouse wheel** - the zoom centers on your cursor position. + +The current zoom level is displayed in the footer (e.g., "150%" or "Fit"). + +### Pan + +Pan controls allow you to move around zoomed images: + +| Key | Action | Description | +|:-----------|:-------------------|:-----------------------------------------| +| `Ctrl + ←` | Pan left | Move view to the left | +| `Ctrl + →` | Pan right | Move view to the right | +| `Ctrl + ↑` | Pan up | Move view upward | +| `Ctrl + ↓` | Pan down | Move view downward | +| `0` | Reset pan | Center the image | + +You can also **click and drag** with the mouse to pan around zoomed images. + +### Transformations + +| Key | Action | Description | +|:------------|:-------------------------------|:------------------------------------------| +| `h` | Flip horizontal | Mirror the image horizontally | +| `v` | Flip vertical | Flip the image upside down | +| `r` | Rotate clockwise | Rotate 90° clockwise | +| `Shift + r` | Rotate counter-clockwise | Rotate 90° counter-clockwise | + +All transformations are lossless and show in real-time. + +### Panels and UI + +| Key | Action | Description | +|:----|:-----------------------|:-----------------------------------------| +| `i` | Toggle properties | Show/hide the properties panel (metadata)| +| `n` | Toggle navigation | Show/hide the navigation sidebar | + +For multi-page documents (PDF), the navigation sidebar displays page thumbnails. +Click on a thumbnail to jump to that page. + +### Actions + +| Key | Action | Description | +|:----|:-----------------------|:-----------------------------------------| +| `w` | Set as wallpaper | Set the current image as desktop wallpaper| + +## Mouse Controls + +### Zoom +- **Mouse wheel up/down**: Zoom in/out centered on cursor +- **Footer buttons**: Click zoom in/out buttons for step-by-step control + +### Pan +- **Click and drag**: Pan around zoomed images +- Hold and drag anywhere on the image to move the view + +### Navigation +- **Footer navigation**: Use Previous/Next buttons to browse images + +## Toolbar + +The header toolbar provides quick access to common operations: + +### Left Side +- **Navigation toggle**: Show/hide the sidebar +- **Previous/Next buttons**: Navigate between images in the folder + +### Center (Horizontally Centered) +- **Rotate buttons**: Rotate clockwise or counter-clockwise +- **Flip buttons**: Flip horizontally or vertically + +### Right Side +- **Properties toggle**: Show/hide the metadata panel + +## Properties Panel + +The properties panel (toggle with `i`) displays image metadata and provides quick actions: + +### Action Buttons +Located at the top-right of the properties panel: + +- **Set as Wallpaper** (`w` key): Set the current image as your desktop wallpaper + - Works with COSMIC, GNOME, KDE, XFCE, and tiling window managers + - Automatically detects your desktop environment + - Falls back to alternative methods if the primary method fails + +- **Open With** (planned): Open the image with another application + +- **Show in Folder** (planned): Open the containing folder in your file manager + +### Metadata Display +- **File Information**: Name, format, dimensions, file size, color type +- **Camera Information** (if available): Camera model, date taken, exposure settings, GPS location + +## Footer Information + +The footer displays useful information: +- **Zoom controls**: Zoom out, current zoom level, zoom in, fit buttons +- **Image dimensions**: Width × Height in pixels +- **Navigation position**: Current image / Total images in folder + +For PDFs, it also shows the current page number. + +## Tips and Tricks + +### Keyboard-Driven Workflow +Noctua is designed for efficient keyboard use: +1. Open an image from terminal +2. Use `←` `→` to browse through the folder +3. Press `r` to rotate, `h` or `v` to flip +4. Use `+` `-` to zoom, `Ctrl + arrows` to pan +5. Press `i` to check metadata + +### Zoom and Pan Together +- Zoom with mouse wheel while hovering over the area you want to examine +- The zoom centers on your cursor, making it easy to focus on details +- Once zoomed, drag with mouse or use `Ctrl + arrows` to navigate + +### Bidirectional Controls +Mouse and keyboard work together seamlessly: +- Zoom with keyboard, pan with mouse +- Zoom with mouse wheel, the footer updates automatically +- Pan with keyboard, continue with mouse drag + +## Configuration + +Settings are stored in `~/.config/cosmic/org.codeberg.wfx.Noctua/v1/`. + +### Configurable Options +- **Default directory**: Set your preferred starting location +- **Panel states**: Your panel preferences are remembered between sessions + +## Planned Features + +The following features are prepared in code but not yet implemented: + +### File Operations +- File open dialog +- Save transformed images +- (Copy/Move/)Delete operations + +### Document Support +- SVG rendering (implemented) + +### Advanced Editing +- Crop mode (`c` key prepared) +- Scale/Resize mode (`s` key prepared) + +See [features.md](features.md) for a complete list of planned features. diff --git a/i18n/cs/noctua.ftl b/i18n/cs/noctua.ftl new file mode 100644 index 0000000..7ab4e97 --- /dev/null +++ b/i18n/cs/noctua.ftl @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# i18n/en/noctua.ftl +# +# Localization strings for Noctua (English). +# Usage: fl!("message-id", arg1, arg2, ...) +# +# Positional arguments ($1, $2, ...) are used for variable content. + + +## Application +noctua-app-name = Noctua +noctua-app-description = Prohlížeč dokumentů a obrázků pro prostředí COSMIC + + +## Main window +window-title = { $filename -> + [none] Noctua + *[some] { $filename } — Noctua +} + + +## Menu entries +menu-file-open = Otevřít… +menu-file-quit = Ukončit +menu-view-zoom-in = Přiblížit +menu-view-zoom-out = Oddálit +menu-view-zoom-reset = Obnovit přiblížení +menu-view-zoom-fit = Přizpůsobit oknu +menu-view-flip-horizontal = Překlopit vodorovně +menu-view-flip-vertical = Překlopit svisle +menu-view-rotate-cw = Otočit po směru hodinových ručiček +menu-view-rotate-ccw = Otočit proti směru hodinových ručiček + + +## Tooltips (for buttons and icons) +tooltip-nav-previous = Předchozí dokument +tooltip-nav-next = Další dokument +tooltip-nav-toggle = Přepnout navigační panel +tooltip-zoom-in = Přiblížit +tooltip-zoom-out = Oddálit +tooltip-zoom-fit = Přizpůsobit oknu +tooltip-rotate-ccw = Otočit proti směru hodinových ručiček +tooltip-rotate-cw = Otočit po směru hodinových ručiček +tooltip-flip-horizontal = Překlopit vodorovně +tooltip-flip-vertical = Překlopit svisle +tooltip-info-panel = Přepnout informační panel + + +## Footer / Status bar +status-zoom-fit = Přizpůsobit +status-zoom-percent = { $percent }% +status-doc-dimensions = { $width } × { $height } +status-nav-position = { $current } / { $total } +status-separator = | + + +## Placeholders / Empty states +no-document = Není načten žádný dokument + + +## Labels +label-zoom = Přiblížení +label-tools = Nástroje +label-crop = Oříznout +label-scale = Měřítko +label-page = Stránka +label-pages = Stránky + + +## Loading states +loading-metadata = Načítání metadat… +loading-thumbnails = Načítání { $current } / { $total }… + + +## Error messages +error-failed-to-open = Nepodařilo se otevřít „{ $path }“ +error-unsupported-format = Nepodporovaný formát souboru +error-no-image-loaded = Není načten žádný obrázek + + +## Properties panel +panel-properties = Vlastnosti +panel-actions = Akce + +meta-section-file = Informace o souboru +meta-section-exif = Informace o kameře +meta-section-image = Informace o obrázku + +## File metadata +meta-filename = Název +meta-format = Formát +meta-dimensions = Rozměry +meta-filesize = Velikost +meta-colortype = Typ barev +meta-path = Cesta +meta-pages = Stránky +meta-current-page = Aktuální stránka + +## Image metadata +meta-width = Šířka +meta-height = Výška +meta-depth = Bitová hloubka + +## EXIF metadata +meta-camera = Kamera +meta-datetime = Datum pořízení +meta-exposure = Expozice +meta-aperture = Clona +meta-iso = ISO { $iso } +meta-focal = Ohnisková vzdálenost +meta-gps = Poloha GPS + +## Action buttons +action-set-wallpaper = Nastavit jako tapetu +action-open-with = Otevřít pomocí… +action-show-in-folder = Zobrazit ve složce + + +## Navigation panel (thumbnails) +nav-panel-title = Stránky +nav-panel-loading = Načítání { $current } / { $total }… diff --git a/i18n/en/noctua.ftl b/i18n/en/noctua.ftl index fb33b6a..3b51c8b 100644 --- a/i18n/en/noctua.ftl +++ b/i18n/en/noctua.ftl @@ -33,6 +33,20 @@ menu-view-rotate-cw = Rotate Clockwise menu-view-rotate-ccw = Rotate Counter-Clockwise +## Tooltips (for buttons and icons) +tooltip-nav-previous = Previous document +tooltip-nav-next = Next document +tooltip-nav-toggle = Toggle navigation panel +tooltip-zoom-in = Zoom in +tooltip-zoom-out = Zoom out +tooltip-zoom-fit = Fit to window +tooltip-rotate-ccw = Rotate counter-clockwise +tooltip-rotate-cw = Rotate clockwise +tooltip-flip-horizontal = Flip horizontally +tooltip-flip-vertical = Flip vertically +tooltip-info-panel = Toggle info panel + + ## Footer / Status bar status-zoom-fit = Fit status-zoom-percent = { $percent }% @@ -43,11 +57,6 @@ status-separator = | ## Placeholders / Empty states no-document = No document loaded -no-document-hint = Open a document to view - -## Status / Info -status-file-position = File { $current } / { $total } -status-page-position = Page { $current } / { $total } ## Labels diff --git a/i18n/sv/noctua.ftl b/i18n/sv/noctua.ftl new file mode 100644 index 0000000..07fe962 --- /dev/null +++ b/i18n/sv/noctua.ftl @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# i18n/sv/noctua.ftl +# +# Localization strings for Noctua (Swedish). +# Usage: fl!("message-id", arg1, arg2, ...) +# +# Positional arguments ($1, $2, ...) are used for variable content. + + +## Program +noctua-app-name = Noctua +noctua-app-description = En dokument och bildvisare för COSMIC skrivbordet + +## Main window +window-title = { $filename -> + [none] Noctua + *[some] { $filename } — Noctua +} + + +## Menyposter +menu-main = Meny +menu-file-open = Öppna… +menu-file-quit = Avsluta +menu-view-zoom-in = Zooma in +menu-view-zoom-out = Zooma ut +menu-view-zoom-reset = Återställ zoom +menu-view-zoom-fit = Anpassa till fönster +menu-view-flip-horizontal = Vänd horisontellt +menu-view-flip-vertical = Vänd vertikalt +menu-view-rotate-cw = Rotera medurs +menu-view-rotate-ccw = Rotera moturs + + +## Verktygstips (för knappar och ikoner) +tooltip-nav-previous = Föregående dokument +tooltip-nav-next = Nästa dokument +tooltip-nav-toggle = Växla navigeringspanel +tooltip-zoom-in = Zooma in +tooltip-zoom-out = Zooma ut +tooltip-zoom-fit = Anpassa till fönster +tooltip-rotate-ccw = Rotera moturs +tooltip-rotate-cw = Rotera medurs +tooltip-flip-horizontal = Vänd horisontellt +tooltip-flip-vertical = Vänd vertikalt +tooltip-info-panel = Växla informationspanel + + +## Sidfot / Statusfält +status-zoom-fit = Passa +status-zoom-percent = { $percent }% +status-doc-dimensions = { $width } × { $height } +status-nav-position = { $current } / { $total } +status-separator = | + + +## Platshållare / Tomma tillstånd +no-document = Inget dokument laddat + + +## Etiketter +label-zoom = Zoom +label-tools = Verktyg +label-crop = Beskära +label-scale = Skala +label-page = Sida +label-pages = Sidor + + +## Laddningstillstånd +loading-metadata = Laddar metadata… +loading-thumbnails = Laddar { $current } / { $total }… + + +## Felmeddelanden +error-failed-to-open = Misslyckades att öppna "{ $path }" +error-unsupported-format = Filformat som inte stöds +error-no-image-loaded = Ingen bild laddad + + +## Egenskapspanel +panel-properties = Egenskaper +panel-actions = Åtgärder + +meta-section-file = Fil information +meta-section-exif = Kamera information +meta-section-image = Bild information + +## Fil metadata +meta-filename = Namn +meta-format = Format +meta-dimensions = Dimensioner +meta-filesize = Storlek +meta-colortype = Färg-typ +meta-path = Sökväg +meta-pages = Sidor +meta-current-page = Nuvarande sida + +## Bildmetadata +meta-width = Bredd +meta-height = Höjd +meta-depth = Bitdjup + +## EXIF metadata +meta-camera = Kamera +meta-datetime = Datum taget +meta-exposure = Exponering +meta-aperture = Bländare +meta-iso = ISO { $iso } +meta-focal = Brännvidd +meta-gps = GPS plats + +## Åtgärdsknappar +action-set-wallpaper = Använd som bakgrundsbild +action-open-with = Öppna med… +action-show-in-folder = Visa i mapp + + +## Navigeringspanel (tumnaglar) +nav-panel-title = Sidor +nav-panel-loading = Laddar { $current } / { $total }… + + +## Formatpanel +format-section-title = Pappersformat +format-section-subtitle = Välj pappersstorlek för export +orientation-section-title = Orientering diff --git a/justfile b/justfile index 65263c9..c45e1e1 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -# Name of the application binary (produced by the ui crate). +# Name of the application's binary. name := 'noctua' # The unique ID of the application. appid := 'org.codeberg.wfx.Noctua' @@ -25,7 +25,7 @@ desktop-dst := base-dir / 'share' / 'applications' / desktop icons-dst := base-dir / 'share' / 'icons' / 'hicolor' icon-svg-dst := icons-dst / 'scalable' / 'apps' / icon-svg -# Default recipe +# Default recipe which runs `just build-release` default: build-release # Runs `cargo clean` @@ -39,9 +39,9 @@ clean-vendor: # `cargo clean` and removes vendored dependencies clean-dist: clean clean-vendor -# Compiles the entire workspace with debug profile +# Compiles with debug profile build-debug *args: - cargo build --workspace {{args}} + cargo build {{args}} # Compiles with release profile build-release *args: (build-debug '--release' args) @@ -49,18 +49,14 @@ build-release *args: (build-debug '--release' args) # Compiles release profile with vendored dependencies build-vendored *args: vendor-extract (build-release '--frozen --offline' args) -# Runs clippy on the entire workspace +# Runs a clippy check check *args: - cargo clippy --workspace --all-features {{args}} -- -W clippy::pedantic + cargo clippy --all-features {{args}} -- -W clippy::pedantic -# Runs clippy with JSON message format +# Runs a clippy check with JSON message format check-json: (check '--message-format=json') -# Runs tests on the entire workspace -test *args: - cargo test --workspace {{args}} - -# Runs the application (no args = COSMIC UI, with args = CLI mode) +# Run the application for testing purposes run *args: env RUST_BACKTRACE=full cargo run --release {{args}} @@ -95,10 +91,10 @@ vendor-extract: rm -rf vendor tar pxf vendor.tar -# Bump version in all crates, commit and tag +# Bump cargo version, create git commit, and create tag tag version: find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \; - cargo check --workspace + cargo check cargo clean git add Cargo.lock git commit -m 'release: {{version}}' diff --git a/resources/org.codeberg.wfx.Noctua.desktop b/resources/org.codeberg.wfx.Noctua.desktop index 9bb2d0a..35d5bd9 100644 --- a/resources/org.codeberg.wfx.Noctua.desktop +++ b/resources/org.codeberg.wfx.Noctua.desktop @@ -1,12 +1,14 @@ [Desktop Entry] Name=Noctua Comment=A wise document and image viewer for the COSMIC™ desktop +Comment[cs]=Chytrý prohlížeč dokumentů a obrázku pro prostředí COSMIC™ Type=Application Icon=org.codeberg.wfx.Noctua -Exec=/usr/bin/noctua %F +Exec=noctua %F Terminal=false StartupNotify=true Categories=Graphics;Viewer;Utility; StartupWMClass=org.codeberg.wfx.Noctua Keywords=image;document;pdf;viewer;cosmic; -MimeType=image/png;image/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf; +Keywords[cs]=obrázek;dokument;pdf;prohlížeč;fotka; +MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf; diff --git a/resources/org.codeberg.wfx.Noctua.metainfo.xml b/resources/org.codeberg.wfx.Noctua.metainfo.xml index ae24682..95b1479 100644 --- a/resources/org.codeberg.wfx.Noctua.metainfo.xml +++ b/resources/org.codeberg.wfx.Noctua.metainfo.xml @@ -8,12 +8,17 @@ GPL-3.0-or-later Noctua A wise document and image viewer for the COSMIC™ desktop + Chytrý prohlížeč dokumentů a obrázků pro prostředí COSMIC™

Noctua is a lightweight, keyboard-driven viewer for images, PDFs, and vector graphics. Built with Rust and libcosmic, it integrates seamlessly with COSMIC™ and GTK-based desktops.

+

+ Noctua je nenáročný prohlížeč obrázků, PDF a vektorové grafiky, snadno ovladatelný pomocí klávesnice. + Je napsaný v jazyce Rust a postavený na libcosmic, díky čemuž se plynule integruje s prostředím COSMIC™ a prostředími založenými na GTK. +

org.codeberg.wfx.Noctua.desktop @@ -25,14 +30,10 @@ noctua image/png - image/x-png image/jpeg - image/jpg - image/pjpeg image/gif image/webp image/bmp - image/x-bmp image/tiff image/svg+xml application/pdf diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..51a7648 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +# rustfmt.toml +reorder_imports = false +reorder_modules = false +imports_granularity = "Preserve" +group_imports = "Preserve" diff --git a/src/active_state.rs b/src/active_state.rs deleted file mode 100644 index 6525747..0000000 --- a/src/active_state.rs +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/active_state.rs -// -// Pure navigation state: tracks which session and item are currently active. - -use anyhow::{Context, Result}; -use uuid::Uuid; - -use crate::workspace::model::{Item, Session, Workspace}; - -/// Tracks the active session and item selection. -/// -/// `ActiveState` does not own or store a `Workspace`. All methods that need -/// workspace data receive it as a parameter. This keeps navigation state -/// cleanly separated from domain data. -#[derive(Debug, Clone)] -pub struct ActiveState { - active_session: Option, - active_item: Option, -} - -impl Default for ActiveState { - fn default() -> Self { - Self::new() - } -} - -impl ActiveState { - #[must_use] - pub fn new() -> Self { - Self { - active_session: None, - active_item: None, - } - } - - /// Clear all navigation state. - pub fn reset(&mut self) { - self.active_session = None; - self.active_item = None; - } - - pub fn select_session(&mut self, workspace: &Workspace, name: &str) -> Result<()> { - let session = workspace.session_by_name(name)?; - if self.active_session == Some(session.id) { - return Ok(()); - } - self.active_session = Some(session.id); - self.active_item = if session.items.is_empty() { - None - } else { - Some(0) - }; - Ok(()) - } - - pub fn select_session_by_id(&mut self, workspace: &Workspace, id: Uuid) -> Result<()> { - if self.active_session == Some(id) { - return Ok(()); - } - let session = workspace.session(id)?; - self.active_session = Some(session.id); - self.active_item = if session.items.is_empty() { - None - } else { - Some(0) - }; - Ok(()) - } - - pub fn clear_session(&mut self) { - self.active_session = None; - self.active_item = None; - } - - pub fn select_item(&mut self, workspace: &Workspace, index: usize) -> Result<()> { - let session = self.require_active_session(workspace)?; - if index >= session.items.len() { - anyhow::bail!( - "Item index {index} out of range (session {:?} has {} items)", - session.name, - session.items.len() - ); - } - self.active_item = Some(index); - Ok(()) - } - - pub fn next_item(&mut self, workspace: &Workspace) { - let count = self.item_count(workspace); - if let Some(idx) = self.active_item { - if idx + 1 < count { - self.active_item = Some(idx + 1); - } - } - } - - pub fn previous_item(&mut self) { - if let Some(idx) = self.active_item { - if idx > 0 { - self.active_item = Some(idx - 1); - } - } - } - - /// Adjust the item cursor after an item was removed from the active session. - pub fn adjust_after_remove(&mut self, workspace: &Workspace) { - let count = self.item_count(workspace); - self.active_item = if count == 0 { - None - } else { - Some(self.active_item.unwrap_or(0).min(count - 1)) - }; - } - - /// Point the item cursor to the last item in the active session. - pub fn select_last_item(&mut self, workspace: &Workspace) { - let count = self.item_count(workspace); - self.active_item = if count == 0 { None } else { Some(count - 1) }; - } - - // -- Read-only accessors -- - - #[must_use] - pub fn active_session_id(&self) -> Option { - self.active_session - } - - #[must_use] - pub fn active_item_index(&self) -> Option { - self.active_item - } - - /// Returns the active session from the workspace. - #[must_use] - pub fn active_session<'a>(&self, workspace: &'a Workspace) -> Option<&'a Session> { - let id = self.active_session?; - workspace.session(id).ok() - } - - /// Returns the active item from the active session. - #[must_use] - pub fn active_item<'a>(&self, workspace: &'a Workspace) -> Option<&'a Item> { - let session = self.active_session(workspace)?; - let idx = self.active_item?; - session.items.get(idx) - } - - /// Returns the UUID of the active item. - #[must_use] - pub fn active_item_id(&self, workspace: &Workspace) -> Option { - self.active_item(workspace).map(|i| i.id) - } - - // Private helpers - - fn require_active_session<'a>(&self, workspace: &'a Workspace) -> Result<&'a Session> { - let id = self.active_session.context("No active session")?; - workspace.session(id) - } - - fn item_count(&self, workspace: &Workspace) -> usize { - self.active_session(workspace).map_or(0, |s| s.items.len()) - } -} diff --git a/src/application/commands/crop_document.rs b/src/application/commands/crop_document.rs new file mode 100644 index 0000000..2d2f408 --- /dev/null +++ b/src/application/commands/crop_document.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/crop_document.rs +// +// Crop document command: crop the current document to a specified region. + +use cosmic::iced::{ContentFit, Size, Vector}; + +use crate::application::DocumentManager; +use crate::domain::document::core::content::DocumentKind; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::operations::CropRegion; + +/// Crop document command. +/// +/// Crops the current document to the specified rectangular region. +/// The coordinates are in image pixels (not canvas/screen coordinates). +pub struct CropDocumentCommand { + /// X coordinate of the crop region (top-left corner). + pub x: u32, + /// Y coordinate of the crop region (top-left corner). + pub y: u32, + /// Width of the crop region in pixels. + pub width: u32, + /// Height of the crop region in pixels. + pub height: u32, +} + +impl CropDocumentCommand { + /// Create a new crop document command. + #[must_use] + pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self { + Self { + x, + y, + width, + height, + } + } + + /// Create a crop command from canvas coordinates. + /// + /// Converts canvas-space coordinates to image-space pixels based on + /// the current view state (scale, pan, content fit). + /// + /// # Errors + /// + /// Returns an error if the crop region is invalid or outside image bounds. + pub fn from_canvas_selection( + crop_region: &CropRegion, + canvas_size: Size, + image_size: Size, + scale: f32, + pan_offset: Vector, + ) -> Result { + let (x, y, w, h) = crop_region.as_tuple(); + let canvas_rect = (x as f32, y as f32, w as f32, h as f32); + + // Convert canvas coordinates to image pixel coordinates + let image_rect = Self::canvas_rect_to_image_rect( + canvas_rect, + canvas_size, + image_size, + scale, + pan_offset, + ContentFit::Contain, + ) + .ok_or_else(|| "Invalid crop region".to_string())?; + + Ok(Self { + x: image_rect.0, + y: image_rect.1, + width: image_rect.2, + height: image_rect.3, + }) + } + + /// Convert canvas rectangle to image pixel rectangle. + /// + /// This is the core coordinate transformation logic that maps from + /// canvas/screen coordinates to actual image pixel coordinates. + fn canvas_rect_to_image_rect( + canvas_rect: (f32, f32, f32, f32), + canvas_size: Size, + image_size: Size, + scale: f32, + offset: Vector, + content_fit: ContentFit, + ) -> Option<(u32, u32, u32, u32)> { + let (cx, cy, cw, ch) = canvas_rect; + + if cw <= 1.0 || ch <= 1.0 { + return None; + } + + // Transform top-left and bottom-right corners + let (x1, y1) = Self::canvas_to_image_coords( + cx, + cy, + canvas_size, + image_size, + scale, + offset, + content_fit, + ); + let (x2, y2) = Self::canvas_to_image_coords( + cx + cw, + cy + ch, + canvas_size, + image_size, + scale, + offset, + content_fit, + ); + + // Clamp to image boundaries + let img_x = x1.max(0.0).min(image_size.width); + let img_y = y1.max(0.0).min(image_size.height); + let img_w = (x2 - x1).max(1.0).min(image_size.width - img_x); + let img_h = (y2 - y1).max(1.0).min(image_size.height - img_y); + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Some(( + img_x.round() as u32, + img_y.round() as u32, + img_w.round() as u32, + img_h.round() as u32, + )) + } + + /// Convert a single point from canvas coordinates to image coordinates. + fn canvas_to_image_coords( + cx: f32, + cy: f32, + canvas_size: Size, + image_size: Size, + scale: f32, + offset: Vector, + content_fit: ContentFit, + ) -> (f32, f32) { + // Calculate displayed image dimensions based on ContentFit + let (display_w, display_h) = match content_fit { + ContentFit::Contain => { + let aspect = image_size.width / image_size.height; + let canvas_aspect = canvas_size.width / canvas_size.height; + + if aspect > canvas_aspect { + // Limited by width + (canvas_size.width, canvas_size.width / aspect) + } else { + // Limited by height + (canvas_size.height * aspect, canvas_size.height) + } + } + _ => (image_size.width, image_size.height), + }; + + // Apply scale + let scaled_w = display_w * scale; + let scaled_h = display_h * scale; + + // Center in canvas + let center_x = (canvas_size.width - scaled_w) / 2.0; + let center_y = (canvas_size.height - scaled_h) / 2.0; + + // Convert canvas coords to scaled image coords + let img_x = (cx - center_x - offset.x) / scale; + let img_y = (cy - center_y - offset.y) / scale; + + // Scale from display space to actual image pixel space + let pixel_x = (img_x / display_w) * image_size.width; + let pixel_y = (img_y / display_h) * image_size.height; + + (pixel_x, pixel_y) + } + + /// Execute the crop command on the document manager. + /// + /// # Errors + /// + /// Returns an error if: + /// - No document is currently open + /// - The document type doesn't support cropping + /// - The crop region is invalid + /// - The crop operation fails + pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> { + let doc = manager + .current_document_mut() + .ok_or_else(|| anyhow::anyhow!("No document open"))?; + + // Only raster images support cropping + if doc.kind() != DocumentKind::Raster { + return Err(anyhow::anyhow!( + "Crop operation is only supported for raster images" + )); + } + + // Get the raster document and apply crop + if let crate::domain::document::core::content::DocumentContent::Raster(raster) = doc { + raster + .crop(self.x, self.y, self.width, self.height) + .map_err(|e| anyhow::anyhow!("Crop failed: {}", e))?; + } + + Ok(()) + } + + /// Check if the command can be executed. + #[must_use] + pub fn can_execute(&self, manager: &DocumentManager) -> bool { + manager + .current_document() + .map_or(false, |doc| doc.kind() == DocumentKind::Raster) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_creation() { + let cmd = CropDocumentCommand::new(10, 20, 100, 150); + assert_eq!(cmd.x, 10); + assert_eq!(cmd.y, 20); + assert_eq!(cmd.width, 100); + assert_eq!(cmd.height, 150); + } +} diff --git a/src/application/commands/mod.rs b/src/application/commands/mod.rs new file mode 100644 index 0000000..c6ab7bd --- /dev/null +++ b/src/application/commands/mod.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/mod.rs +// +// Application commands: document operations and navigation. + +pub mod crop_document; +pub mod navigate; +pub mod open_document; +pub mod save_document; +pub mod transform_document; diff --git a/src/application/commands/navigate.rs b/src/application/commands/navigate.rs new file mode 100644 index 0000000..c46cac3 --- /dev/null +++ b/src/application/commands/navigate.rs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/navigate.rs +// +// Navigation command: next/previous document. +// Reserved for future CQRS pattern - currently using direct DocumentManager methods. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use crate::application::document_manager::DocumentManager; +use crate::domain::document::core::document::DocResult; + +/// Navigation direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavigationDirection { + /// Navigate to next document. + Next, + /// Navigate to previous document. + Previous, +} + +/// Navigate command. +pub struct NavigateCommand { + direction: NavigationDirection, +} + +impl NavigateCommand { + /// Create a new navigate command. + #[must_use] + pub fn new(direction: NavigationDirection) -> Self { + Self { direction } + } + + /// Execute the navigate command. + pub fn execute(&self, manager: &mut DocumentManager) -> DocResult> { + let path = match self.direction { + NavigationDirection::Next => manager.next_document(), + NavigationDirection::Previous => manager.previous_document(), + }; + + Ok(path) + } + + /// Check if navigation is possible. + #[must_use] + pub fn can_execute(&self, manager: &DocumentManager) -> bool { + match self.direction { + NavigationDirection::Next => manager.has_next(), + NavigationDirection::Previous => manager.has_previous(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_navigate_command_creation() { + let cmd = NavigateCommand::new(NavigationDirection::Next); + assert_eq!(cmd.direction, NavigationDirection::Next); + + let cmd = NavigateCommand::new(NavigationDirection::Previous); + assert_eq!(cmd.direction, NavigationDirection::Previous); + } +} diff --git a/src/application/commands/open_document.rs b/src/application/commands/open_document.rs new file mode 100644 index 0000000..35fab80 --- /dev/null +++ b/src/application/commands/open_document.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/open_document.rs +// +// Open document command: load a document from a file path. +// Reserved for future CQRS pattern - currently using direct DocumentManager methods. + +#![allow(dead_code)] + +use std::path::Path; + +use crate::application::document_manager::DocumentManager; +use crate::domain::document::core::document::DocResult; + +/// Open document command. +pub struct OpenDocumentCommand; + +impl OpenDocumentCommand { + /// Create a new open document command. + #[must_use] + pub fn new() -> Self { + Self + } + + /// Execute the open document command. + pub fn execute(&self, manager: &mut DocumentManager, path: &Path) -> DocResult<()> { + manager.open_document(path) + } +} + +impl Default for OpenDocumentCommand { + fn default() -> Self { + Self::new() + } +} diff --git a/src/application/commands/save_document.rs b/src/application/commands/save_document.rs new file mode 100644 index 0000000..9761ab1 --- /dev/null +++ b/src/application/commands/save_document.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/save_document.rs +// +// Save document command: export document to a file. +// Reserved for future implementation - not yet used. + +#![allow(dead_code)] + +use std::path::Path; + +use crate::application::document_manager::DocumentManager; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::operations::export::ExportFormat; + +/// Save document command. +pub struct SaveDocumentCommand { + /// Target format for export. + format: Option, +} + +impl SaveDocumentCommand { + /// Create a new save document command with automatic format detection. + #[must_use] + pub fn new() -> Self { + Self { format: None } + } + + /// Create a save document command with a specific format. + #[must_use] + pub fn with_format(format: ExportFormat) -> Self { + Self { + format: Some(format), + } + } + + /// Execute the save document command. + pub fn execute(&self, manager: &DocumentManager, path: &Path) -> DocResult<()> { + let _document = manager + .current_document() + .ok_or_else(|| anyhow::anyhow!("No document loaded"))?; + + // Detect format from path or use specified format + let format = self + .format + .or_else(|| ExportFormat::from_path(path)) + .ok_or_else(|| anyhow::anyhow!("Could not determine export format"))?; + + // TODO: Implement actual save logic + // This would involve: + // 1. Getting the rendered image from the document + // 2. Applying any necessary transformations + // 3. Exporting to the target format + + log::info!("Save to {} as {:?}", path.display(), format); + + Err(anyhow::anyhow!("Save operation not yet implemented")) + } +} + +impl Default for SaveDocumentCommand { + fn default() -> Self { + Self::new() + } +} diff --git a/src/application/commands/transform_document.rs b/src/application/commands/transform_document.rs new file mode 100644 index 0000000..d2f5bc4 --- /dev/null +++ b/src/application/commands/transform_document.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/commands/transform_document.rs +// +// Transform document command: rotate, flip, and other transformations. + +use crate::application::document_manager::DocumentManager; +use crate::domain::document::core::document::{DocResult, Rotation}; +use crate::domain::document::operations::transform; + +/// Transformation operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum TransformOperation { + /// Rotate clockwise by 90 degrees. + RotateCw, + /// Rotate counter-clockwise by 90 degrees. + RotateCcw, + /// Flip horizontally. + FlipHorizontal, + /// Flip vertically. + FlipVertical, + /// Rotate to a specific angle. + RotateTo(Rotation), +} + +/// Transform document command. +pub struct TransformDocumentCommand { + operation: TransformOperation, +} + +impl TransformDocumentCommand { + /// Create a new transform document command. + #[must_use] + pub fn new(operation: TransformOperation) -> Self { + Self { operation } + } + + /// Execute the transform command. + /// + /// Uses high-level transform operations that work across all document types + /// (Raster, Vector, Portable). + pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> { + let document = manager + .current_document_mut() + .ok_or_else(|| anyhow::anyhow!("No document loaded"))?; + + match self.operation { + TransformOperation::RotateCw => { + transform::rotate_document_cw(document)?; + } + TransformOperation::RotateCcw => { + transform::rotate_document_ccw(document)?; + } + TransformOperation::FlipHorizontal => { + transform::flip_document_horizontal(document)?; + } + TransformOperation::FlipVertical => { + transform::flip_document_vertical(document)?; + } + TransformOperation::RotateTo(rotation) => { + transform::rotate_document_to(document, rotation)?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_command_creation() { + let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw); + assert_eq!(cmd.operation, TransformOperation::RotateCw); + + let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal); + assert_eq!(cmd.operation, TransformOperation::FlipHorizontal); + } +} diff --git a/src/application/document_manager.rs b/src/application/document_manager.rs new file mode 100644 index 0000000..29f9f8b --- /dev/null +++ b/src/application/document_manager.rs @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/document_manager.rs +// +// Document manager: orchestrates document lifecycle and navigation. + +use std::path::{Path, PathBuf}; + +use crate::domain::document::collection::DocumentCollection; +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::core::metadata::DocumentMeta; +use crate::infrastructure::filesystem::file_ops; +use crate::infrastructure::loaders::DocumentLoaderFactory; + +/// Central document manager. +/// +/// Orchestrates document loading, metadata extraction, and folder navigation. +/// Uses DocumentCollection (Domain Layer) for navigation logic. +pub struct DocumentManager { + /// Document collection for navigation (Domain Layer abstraction). + collection: DocumentCollection, + /// Current document metadata. + current_metadata: Option, + /// Document loader factory. + loader: DocumentLoaderFactory, +} + +impl DocumentManager { + /// Create a new document manager. + #[must_use] + pub fn new() -> Self { + Self { + collection: DocumentCollection::new(), + current_metadata: None, + loader: DocumentLoaderFactory::new(), + } + } + + /// Open a document from a file path or directory. + /// + /// If a directory is provided, opens the first supported file found. + /// Also scans the parent folder for navigation. + pub fn open_document(&mut self, path: &Path) -> DocResult<()> { + // Determine the actual file to open + let file_path = if path.is_dir() { + // Scan directory and find first supported file + let paths = file_ops::collect_supported_files(path); + self.collection = DocumentCollection::from_paths(paths); + + self.collection + .current_path() + .ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))? + .clone() + } else { + path.to_path_buf() + }; + + // Load the document + let document = self.loader.load(&file_path)?; + + // Extract metadata + let metadata = self.extract_metadata(&file_path, &document); + + // Scan folder for navigation if not already done + if !path.is_dir() { + if let Some(parent) = file_path.parent() { + let paths = file_ops::collect_supported_files(parent); + self.collection = DocumentCollection::from_paths(paths); + // Find and set current document index + if let Some(idx) = self.collection.paths().iter().position(|p| p == &file_path) { + self.collection.goto(idx); + } + } + } + + // Generate thumbnails for multi-page documents (PDF) + let mut document = document; + if document.is_multi_page() { + log::info!("Generating thumbnails for multi-page document..."); + if let Err(e) = document.generate_thumbnails() { + log::warn!("Failed to generate thumbnails: {e}"); + } + } + + // Store document in collection + self.collection.set_current_document(document); + self.current_metadata = Some(metadata); + + Ok(()) + } + + /// Get the current document. + #[must_use] + pub fn current_document(&self) -> Option<&DocumentContent> { + self.collection.current_document() + } + + /// Get a mutable reference to the current document. + #[must_use] + pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> { + self.collection.current_document_mut() + } + + /// Get thumbnail handle for a specific page (read-only access). + /// Returns None if the thumbnail hasn't been generated yet. + #[must_use] + pub fn get_thumbnail_handle(&self, page: usize) -> Option { + self.collection + .current_document()? + .get_thumbnail_handle(page) + } + + /// Get the current document path. + #[must_use] + pub fn current_path(&self) -> Option<&Path> { + self.collection.current_path().map(|p| p.as_path()) + } + + /// Get the current document metadata. + #[must_use] + pub fn current_metadata(&self) -> Option<&DocumentMeta> { + self.current_metadata.as_ref() + } + + /// Get all folder entries for navigation. + #[must_use] + pub fn folder_entries(&self) -> &[PathBuf] { + self.collection.paths() + } + + /// Get current index in folder. + #[must_use] + pub fn current_index(&self) -> Option { + self.collection.current_index() + } + + /// Navigate to the next document in the folder. + /// + /// Wraps around to the first document when at the end. + pub fn next_document(&mut self) -> Option { + // Use DocumentCollection navigation + if self.collection.has_next() { + self.collection.next(); + } else if !self.collection.is_empty() { + // Wrap around to first + self.collection.goto(0); + } else { + return None; + } + + let next_path = self.collection.current_path()?.clone(); + if self.open_document(&next_path).is_ok() { + Some(next_path) + } else { + None + } + } + + /// Navigate to the previous document in the folder. + /// + /// Wraps around to the last document when at the beginning. + pub fn previous_document(&mut self) -> Option { + // Use DocumentCollection navigation + if self.collection.has_previous() { + self.collection.previous(); + } else if !self.collection.is_empty() { + // Wrap around to last + let last_idx = self.collection.len() - 1; + self.collection.goto(last_idx); + } else { + return None; + } + + let prev_path = self.collection.current_path()?.clone(); + if self.open_document(&prev_path).is_ok() { + Some(prev_path) + } else { + None + } + } + + /// Close the current document. + #[allow(dead_code)] + pub fn close_document(&mut self) { + self.collection.clear_current_document(); + self.current_metadata = None; + } + + /// Extract metadata from a document. + fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta { + // Use the document's own extract_meta() method + // This properly delegates to the type-specific implementation + // (RasterDocument, VectorDocument, or PortableDocument) + document.extract_meta(path) + } + + /// Check if there is a next document available. + #[must_use] + #[allow(dead_code)] + pub fn has_next(&self) -> bool { + self.collection.has_next() + } + + /// Check if there is a previous document available. + #[must_use] + #[allow(dead_code)] + pub fn has_previous(&self) -> bool { + self.collection.has_previous() + } +} + +impl Default for DocumentManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..00e19f2 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/mod.rs +// +// Application layer: use cases, commands, queries, and services. + +pub mod commands; +pub mod document_manager; +pub mod services; + +// Re-export document manager +pub use document_manager::DocumentManager; diff --git a/src/application/services/cache_service.rs b/src/application/services/cache_service.rs new file mode 100644 index 0000000..2fda4bc --- /dev/null +++ b/src/application/services/cache_service.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/services/cache_service.rs +// +// Cache service: manages document and thumbnail caching. +// Reserved for future caching layer implementation. + +#![allow(dead_code)] + +use std::path::Path; + +use cosmic::widget::image::Handle as ImageHandle; +use image::DynamicImage; + +use crate::infrastructure::cache::ThumbnailCache; + +/// Cache service for managing document caches. +/// +/// Provides high-level caching operations for the application layer. +pub struct CacheService; + +impl CacheService { + /// Create a new cache service. + #[must_use] + pub fn new() -> Self { + Self + } + + /// Load a thumbnail from cache. + /// + /// Returns None if the thumbnail is not cached or the cache is invalid. + #[must_use] + pub fn get_thumbnail(&self, path: &Path, page: usize) -> Option { + ThumbnailCache::load(path, page) + } + + /// Save a thumbnail to cache. + /// + /// Returns true if the thumbnail was successfully cached. + pub fn put_thumbnail(&self, path: &Path, page: usize, image: &DynamicImage) -> bool { + ThumbnailCache::save(path, page, image).is_some() + } + + /// Clear all cached thumbnails. + /// + /// This operation is not yet implemented. + pub fn clear_cache(&self) -> Result<(), String> { + ThumbnailCache::clear_cache().map_err(|e| e.to_string()) + } + + /// Get the size of the cache directory. + /// + /// Returns the total size in bytes, or None if it cannot be determined. + #[must_use] + pub fn cache_size(&self) -> Option { + // TODO: Implement cache size calculation + None + } +} + +impl Default for CacheService { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_service_creation() { + let service = CacheService::new(); + assert!(std::ptr::eq(&service, &service)); // Dummy test + } + + #[test] + fn test_cache_service_default() { + let service = CacheService::default(); + assert!(std::ptr::eq(&service, &service)); // Dummy test + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs new file mode 100644 index 0000000..8efbbe9 --- /dev/null +++ b/src/application/services/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/services/mod.rs +// +// Application services: cache management and preview generation. + +pub mod cache_service; +pub mod preview_service; diff --git a/src/application/services/preview_service.rs b/src/application/services/preview_service.rs new file mode 100644 index 0000000..ab23906 --- /dev/null +++ b/src/application/services/preview_service.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/application/services/preview_service.rs +// +// Preview service: generates thumbnails and previews for documents. +// Reserved for future async thumbnail generation implementation. + +#![allow(dead_code)] + +use cosmic::widget::image::Handle as ImageHandle; + +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::DocResult; + +/// Preview service for generating document thumbnails and previews. +/// +/// Provides high-level preview generation operations for the application layer. +pub struct PreviewService { + /// Target thumbnail size (width in pixels). + thumbnail_size: u32, +} + +impl PreviewService { + /// Create a new preview service with default thumbnail size. + #[must_use] + pub fn new() -> Self { + Self { + thumbnail_size: 256, + } + } + + /// Create a preview service with a specific thumbnail size. + #[must_use] + pub fn with_thumbnail_size(size: u32) -> Self { + Self { + thumbnail_size: size, + } + } + + /// Set the thumbnail size. + pub fn set_thumbnail_size(&mut self, size: u32) { + self.thumbnail_size = size; + } + + /// Get the current thumbnail size. + #[must_use] + pub fn thumbnail_size(&self) -> u32 { + self.thumbnail_size + } + + /// Generate a thumbnail for a document page. + /// + /// For single-page documents, the page parameter is ignored. + pub fn generate_thumbnail( + &self, + document: &mut DocumentContent, + page: usize, + ) -> DocResult> { + if document.is_multi_page() { + document.get_thumbnail(page) + } else { + // For single-page documents, return the current handle + Ok(document.handle()) + } + } + + /// Generate all thumbnails for a multi-page document. + /// + /// Returns the number of thumbnails generated. + pub fn generate_all_thumbnails(&self, document: &mut DocumentContent) -> DocResult { + if !document.is_multi_page() { + return Ok(0); + } + + document.generate_thumbnails()?; + Ok(document.thumbnails_loaded()) + } + + /// Check if all thumbnails are ready for a document. + #[must_use] + pub fn thumbnails_ready(&self, document: &DocumentContent) -> bool { + document.thumbnails_ready() + } + + /// Get the number of thumbnails loaded for a document. + #[must_use] + pub fn thumbnails_loaded(&self, document: &DocumentContent) -> usize { + document.thumbnails_loaded() + } +} + +impl Default for PreviewService { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_preview_service_creation() { + let service = PreviewService::new(); + assert_eq!(service.thumbnail_size(), 256); + } + + #[test] + fn test_preview_service_with_size() { + let service = PreviewService::with_thumbnail_size(512); + assert_eq!(service.thumbnail_size(), 512); + } + + #[test] + fn test_set_thumbnail_size() { + let mut service = PreviewService::new(); + service.set_thumbnail_size(128); + assert_eq!(service.thumbnail_size(), 128); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..adcf9eb --- /dev/null +++ b/src/config.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/config.rs +// +// Global configuration for the application with cosmic-config support. + +use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; +use std::path::PathBuf; + +/// Global configuration for the application. +#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)] +#[version = 1] +pub struct AppConfig { + /// Default directory to open when browsing for documents. + pub default_image_dir: Option, + /// Show page navigation panel (left sidebar for multi-page documents). + pub nav_bar_visible: bool, + /// Show properties panel (right sidebar with metadata). + pub context_drawer_visible: bool, + /// Zoom step multiplier for keyboard shortcuts (1.1 = 10% increase per step). + pub scale_step: f32, + /// Pan distance in pixels per arrow key press. + pub pan_step: f32, + /// Minimum zoom level (0.1 = 10% of original size). + pub min_scale: f32, + /// Maximum zoom level (8.0 = 800% of original size). + pub max_scale: f32, + /// Show 3x3 grid during crop selection. + pub crop_show_grid: bool, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + default_image_dir: dirs::picture_dir().or_else(dirs::home_dir), + nav_bar_visible: false, + context_drawer_visible: false, + scale_step: 1.1, + pan_step: 50.0, + min_scale: 0.1, + max_scale: 8.0, + crop_show_grid: true, + } + } +} diff --git a/src/document/command.rs b/src/document/command.rs deleted file mode 100644 index 7fe0ee1..0000000 --- a/src/document/command.rs +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/command.rs -// -// Commands that operate on the active document itself. - -/// Commands operating on the active document (zoom, export, annotate, …). -/// -/// The `DocumentManager` dispatches these after resolving which document is -/// active – the UI never needs to know the underlying format. -#[derive(Debug, Clone)] -pub enum DocumentCommand { - // Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, … -} diff --git a/src/document/loader.rs b/src/document/loader.rs deleted file mode 100644 index f361d0b..0000000 --- a/src/document/loader.rs +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/loader.rs -// -// Document loading: format detection, format dispatch, and directory scanning. - -use crate::document::model::{PortableModel, RasterModel, VectorModel}; -use crate::document::session::data::{SessionData, SessionItem}; -use crate::document::types::PageLayout; -use crate::document::DocumentContent; -use crate::error::{Error, Result}; -use std::path::Path; - -/// The three document formats Noctua understands. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum DocumentFormat { - /// Raster images – anything the image crate can decode. - Raster, - /// Vector graphics (SVG). - Vector, - /// Portable documents (PDF). - Portable, -} - -/// Detects the document format from the file extension. -/// -/// Returns `None` for unknown extensions so directory scans can skip -/// unsupported files early. At load time the underlying library -/// (`ImageReader`, `usvg`, `pdfium`) is the final authority. -fn detect_format(path: &Path) -> Option { - match path - .extension() - .and_then(|e| e.to_str()) - .map(str::to_lowercase) - .as_deref() - { - Some("pdf") => Some(DocumentFormat::Portable), - Some("svg") => Some(DocumentFormat::Vector), - Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => { - Some(DocumentFormat::Raster) - } - _ => None, - } -} - -/// Loads a document from a path and returns its content. -/// -/// # Errors -/// -/// Returns [`Error::NotFound`] if the path does not exist. -/// Returns [`Error::Unsupported`] if the file extension is not recognised. -pub(crate) fn load_document(path: &Path) -> Result { - if !path.exists() { - return Err(Error::NotFound(format!("{}", path.display()))); - } - - match detect_format(path) { - Some(DocumentFormat::Portable) => { - // TODO: Replace with actual pdfium-render loading. - Ok(DocumentContent::Portable(PortableModel { - path: path.to_path_buf(), - page_count: 3, - layout: PageLayout::new(vec![(595.0, 842.0); 3], 10.0), - })) - } - Some(DocumentFormat::Vector) => { - // TODO: Replace with actual usvg parsing. - Ok(DocumentContent::Vector(VectorModel { - path: path.to_path_buf(), - view_box: None, - })) - } - Some(DocumentFormat::Raster) => { - // TODO: Replace with actual image-crate loading. - Ok(DocumentContent::Raster(RasterModel { - path: path.to_path_buf(), - dimensions: (1920, 1080), - })) - } - None => Err(Error::Unsupported(format!( - "extension: {}", - path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("(none)") - ))), - } -} - -/// Scans a directory for supported files and populates the session. -pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) { - if let Ok(entries) = std::fs::read_dir(dir) { - let mut paths: Vec<_> = entries - .filter_map(std::result::Result::ok) - .map(|e| e.path()) - .filter(|p| p.is_file() && detect_format(p).is_some()) - .collect(); - - paths.sort(); - - session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect(); - } -} diff --git a/src/document/manager.rs b/src/document/manager.rs deleted file mode 100644 index 19de638..0000000 --- a/src/document/manager.rs +++ /dev/null @@ -1,418 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/manager.rs -// -// Document manager: session state, cursors, clipboard, and command dispatch. - -use crate::document::loader::{load_document, scan_directory_into}; -use crate::document::session::command::SessionCommand; -use crate::document::session::data::{CollectionKind, SessionData, SessionItem}; -use crate::document::session::store; -use crate::document::DocumentContent; -use crate::error::Error; -use std::path::{Path, PathBuf}; - -/// The load state of the currently viewed document. -#[derive(Debug)] -pub enum DocumentState { - Empty, - Loading, - Loaded(DocumentContent), - Error(Error), -} - -/// Central state for all sessions, cursors, and the document clipboard. -/// -/// Two cursors are maintained: -/// - `active_session_index` – the currently active session -/// - `active_document_index` – the currently active document within that session -/// -/// All `SessionCommand` variants that do not carry an explicit target operate -/// on the active cursor. `SelectSession` and `SelectDocument` set them. -pub struct DocumentManager { - sessions: Vec, - active_session_index: Option, - active_document_index: Option, - clipboard: Option, - session_file_path: Option, - pub state: DocumentState, -} - -impl Default for DocumentManager { - fn default() -> Self { - Self { - sessions: Vec::new(), - active_session_index: None, - active_document_index: None, - clipboard: None, - session_file_path: None, - state: DocumentState::Empty, - } - } -} - -impl DocumentManager { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// The single public entry point for all session and document state changes. - pub fn handle(&mut self, command: SessionCommand) { - match command { - // Session lifecycle - SessionCommand::NewSession { name } => self.new_session(&name), - SessionCommand::OpenSession { path } => self.open_session_file(&path), - SessionCommand::DeleteSession => self.delete_session(), - SessionCommand::SaveSession => self.save_session(), - SessionCommand::SelectSession { name } => self.select_session(&name), - SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name), - SessionCommand::RenameSession { name } => self.rename_session(&name), - - // Collection management - SessionCommand::AddDirectoryToSession { dir } => { - if let Some(session) = self.active_session_mut() { - scan_directory_into(session, &dir); - } - self.reset_document_cursor(); - } - SessionCommand::AddDocumentToSession { path } => self.add_document(&path), - - // Document management - SessionCommand::SelectDocument { index } => self.select_document(index), - SessionCommand::CopyDocument => self.copy_document(), - SessionCommand::PasteDocument => self.paste_document(), - SessionCommand::RemoveDocument => self.remove_document(), - SessionCommand::NextDocument => self.navigate_next(), - SessionCommand::PreviousDocument => self.navigate_previous(), - SessionCommand::MoveDocumentUp => self.move_document_up(), - SessionCommand::MoveDocumentDown => self.move_document_down(), - SessionCommand::MoveDocumentTo { index } => self.move_document_to(index), - } - } - - /// Read-only access to the active session. - #[must_use] - pub fn active_session(&self) -> Option<&SessionData> { - self.active_session_index.map(|i| &self.sessions[i]) - } - - /// The index of the currently active document within the active session. - #[must_use] - pub fn active_document_index(&self) -> Option { - self.active_document_index - } - - /// Read-only access to all sessions. - #[must_use] - pub fn sessions(&self) -> &[SessionData] { - &self.sessions - } - - /// Read-only access to a session by name. - #[must_use] - pub fn session_by_name(&self, name: &str) -> Option<&SessionData> { - self.sessions.iter().find(|s| s.name == name) - } - - // -- Private helpers -- - - fn active_session_mut(&mut self) -> Option<&mut SessionData> { - self.active_session_index.map(|i| &mut self.sessions[i]) - } - - fn index_by_name(&self, name: &str) -> Option { - self.sessions.iter().position(|s| s.name == name) - } - - /// Sets `active_document_index` to `Some(0)` if the active session has items, else `None`. - fn reset_document_cursor(&mut self) { - self.active_document_index = match self.active_session() { - Some(s) if !s.items.is_empty() => Some(0), - _ => None, - }; - } - - fn new_session(&mut self, name: &str) { - if self.index_by_name(name).is_some() { - return; - } - self.sessions.push(SessionData::new( - name, - CollectionKind::DocumentCollection, - None, - Vec::new(), - )); - } - - fn open_session_file(&mut self, path: &Path) { - match store::load_sessions(path) { - Ok(mut loaded) => { - for session in &mut loaded { - session.items.retain(|item| { - if item.path.exists() { - true - } else { - log::error!( - "Session '{}': {}", - session.name, - Error::NotFound(item.path.display().to_string()) - ); - false - } - }); - } - - let browser_index = loaded - .iter() - .position(|s| s.kind == CollectionKind::DirectoryBrowser); - - self.sessions = loaded; - self.active_session_index = browser_index; - self.session_file_path = Some(path.to_path_buf()); - self.state = DocumentState::Empty; - self.reset_document_cursor(); - } - Err(e) => { - self.state = DocumentState::Error(e); - } - } - } - - // We have no 'Close session' it wouldbe only removes it from runtime, keeps it in .ron file - // For actual deletion, use delete_session() - // So 'close_session' from ui view is save_session and close session view. - - fn delete_session(&mut self) { - let Some(idx) = self.active_session_index else { - return; - }; - - // The DirectoryBrowser session is managed by the UI and cannot be deleted. - if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { - return; - } - - self.sessions.remove(idx); - self.active_document_index = None; - - self.active_session_index = if self.sessions.is_empty() { - None - } else { - Some(idx.saturating_sub(1)) - }; - } - - fn save_session(&mut self) { - let Some(ref path) = self.session_file_path else { - return; - }; - if let Err(e) = store::save_sessions(&path, &self.sessions) { - self.state = DocumentState::Error(e); - } - } - - fn select_session(&mut self, name: &str) { - if let Some(idx) = self.index_by_name(name) { - self.active_session_index = Some(idx); - self.reset_document_cursor(); - } - } - - fn duplicate_session(&mut self, new_name: &str) { - if self.index_by_name(new_name).is_some() { - return; - } - let Some(idx) = self.active_session_index else { - return; - }; - let items = self.sessions[idx].items.clone(); - self.sessions.push(SessionData::new( - new_name, - CollectionKind::DocumentCollection, - None, - items, - )); - } - - fn rename_session(&mut self, name: &str) { - let Some(idx) = self.active_session_index else { - return; - }; - - // The DirectoryBrowser session cannot be renamed. - if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { - return; - } - - if self.index_by_name(name).is_some() { - return; - } - - self.sessions[idx].name = name.to_string(); - } - - fn add_document(&mut self, path: &Path) { - self.state = DocumentState::Loading; - match load_document(path) { - Ok(content) => { - if let Some(session) = self.active_session_mut() { - if session.kind == CollectionKind::DocumentCollection - && !session.items.iter().any(|i| i.path == path) - { - session.items.push(SessionItem::new(path.to_path_buf(), 0)); - } - } - self.active_document_index = self - .active_session() - .and_then(|s| s.items.iter().position(|i| i.path == path)); - self.state = DocumentState::Loaded(content); - } - Err(e) => { - self.state = DocumentState::Error(e); - } - } - } - - fn select_document(&mut self, index: usize) { - if let Some(session) = self.active_session() { - if index < session.items.len() { - self.active_document_index = Some(index); - } - } - } - - fn copy_document(&mut self) { - let Some(idx) = self.active_document_index else { - return; - }; - self.clipboard = self - .active_session() - .and_then(|s| s.items.get(idx)) - .cloned(); - } - - fn paste_document(&mut self) { - let Some(item) = self.clipboard.clone() else { - return; - }; - - if !matches!( - self.active_session().map(|s| &s.kind), - Some(CollectionKind::DocumentCollection) - ) { - return; - } - - let new_index = { - let Some(session) = self.active_session_mut() else { - return; - }; - - if session - .items - .iter() - .any(|i| i.path == item.path && i.page_index == item.page_index) - { - return; - } - - session.items.push(item); - session.items.len() - 1 - }; - - self.active_document_index = Some(new_index); - } - - fn remove_document(&mut self) { - if !matches!( - self.active_session().map(|s| &s.kind), - Some(CollectionKind::DocumentCollection) - ) { - return; - } - - let Some(idx) = self.active_document_index else { - return; - }; - - let new_index = { - let Some(session) = self.active_session_mut() else { - return; - }; - - if idx >= session.items.len() { - return; - } - - session.items.remove(idx); - let new_len = session.items.len(); - if new_len == 0 { - None - } else { - Some(idx.min(new_len - 1)) - } - }; - - self.active_document_index = new_index; - } - - fn navigate_next(&mut self) { - let len = self.active_session().map_or(0, |s| s.items.len()); - if let Some(idx) = self.active_document_index { - if idx + 1 < len { - self.active_document_index = Some(idx + 1); - } - } - } - - fn navigate_previous(&mut self) { - if let Some(idx) = self.active_document_index { - if idx > 0 { - self.active_document_index = Some(idx - 1); - } - } - } - - fn move_document_up(&mut self) { - let Some(idx) = self.active_document_index else { - return; - }; - if idx == 0 { - return; - } - let Some(session) = self.active_session_mut() else { - return; - }; - session.items.swap(idx, idx - 1); - self.active_document_index = Some(idx - 1); - } - - fn move_document_down(&mut self) { - let Some(idx) = self.active_document_index else { - return; - }; - let Some(session) = self.active_session_mut() else { - return; - }; - if idx + 1 >= session.items.len() { - return; - } - session.items.swap(idx, idx + 1); - self.active_document_index = Some(idx + 1); - } - - fn move_document_to(&mut self, target: usize) { - let Some(idx) = self.active_document_index else { - return; - }; - let Some(session) = self.active_session_mut() else { - return; - }; - if idx == target || target >= session.items.len() { - return; - } - let item = session.items.remove(idx); - session.items.insert(target, item); - self.active_document_index = Some(target); - } -} diff --git a/src/document/mod.rs b/src/document/mod.rs deleted file mode 100644 index f620821..0000000 --- a/src/document/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/mod.rs -// -// Document abstraction – the public surface of the document subsystem. - -pub mod command; -pub mod loader; -pub mod manager; -pub mod model; -pub mod session; -pub mod types; - -use self::model::{PortableModel, RasterModel, VectorModel}; - -/// The decoded content of a loaded document. -/// -/// This enum is internal to Noctua. The UI only ever sees a `RawImage` -/// produced by the render subsystem – it never matches on this type. -#[derive(Debug)] -pub enum DocumentContent { - Raster(RasterModel), - Vector(VectorModel), - Portable(PortableModel), -} diff --git a/src/document/model/mod.rs b/src/document/model/mod.rs deleted file mode 100644 index 7ede092..0000000 --- a/src/document/model/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/model/mod.rs -// -// The three concrete document models Noctua understands. - -pub mod portable; -pub mod raster; -pub mod vector; - -pub use portable::PortableModel; -pub use raster::RasterModel; -pub use vector::VectorModel; diff --git a/src/document/model/portable.rs b/src/document/model/portable.rs deleted file mode 100644 index cdcdc43..0000000 --- a/src/document/model/portable.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/model/portable.rs -// -// Portable document format model (PDF). - -use crate::document::types::PageLayout; -use std::path::PathBuf; - -#[derive(Clone, Debug)] -pub struct PortableModel { - pub path: PathBuf, - pub page_count: usize, - pub layout: PageLayout, -} diff --git a/src/document/model/raster.rs b/src/document/model/raster.rs deleted file mode 100644 index 83228bc..0000000 --- a/src/document/model/raster.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/model/raster.rs -// -// Raster image document model (JPEG, PNG, etc.). - -use std::path::PathBuf; - -#[derive(Clone, Debug)] -pub struct RasterModel { - pub path: PathBuf, - pub dimensions: (u32, u32), -} diff --git a/src/document/model/vector.rs b/src/document/model/vector.rs deleted file mode 100644 index 15e9bf9..0000000 --- a/src/document/model/vector.rs +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/model/vector.rs -// -// Vector image document model (SVG). - -use crate::document::types::ViewBox; -use std::path::PathBuf; - -#[derive(Clone, Debug)] -pub struct VectorModel { - pub path: PathBuf, - pub view_box: Option, -} diff --git a/src/document/session/command.rs b/src/document/session/command.rs deleted file mode 100644 index 4b74877..0000000 --- a/src/document/session/command.rs +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/session/command.rs -// -// Commands that operate on sessions and their document collections. - -use std::path::PathBuf; - -/// Commands operating on sessions and their document collections. -/// -/// Two implicit cursors exist in the manager: -/// - `active_session_index` – the currently active session -/// - `active_document_index` – the currently active document within that session -/// -/// `SelectSession` and `SelectDocument` set these cursors. All commands that -/// do not carry an explicit target operate on the active cursor. -/// -/// Only `DocumentCollection` sessions are user-managed. The single -/// `DirectoryBrowser` session is created by the UI and cannot be closed, -/// renamed, or removed. -#[derive(Debug, Clone)] -pub enum SessionCommand { - // Session lifecycle - /// Create a new empty `DocumentCollection` session. - NewSession { name: String }, - /// Load sessions from an existing .ron file. - OpenSession { path: PathBuf }, - /// Persist all sessions to the .ron file. - SaveSession, - /// Set the active session cursor by name. - SelectSession { name: String }, - /// Duplicate the active session as a new `DocumentCollection`. - /// - /// When the source is a `DirectoryBrowser` this produces a snapshot: - /// the current items are copied into a new `DocumentCollection`. - DuplicateSession { new_name: String }, - /// Delete the active `DocumentCollection` session permanently (removed from .ron on save). - DeleteSession, - /// Rename the active `DocumentCollection` session. - RenameSession { name: String }, - - // Collection management within the active session - /// Scan a directory and populate the active `DirectoryBrowser` session. - AddDirectoryToSession { dir: PathBuf }, - /// Add a single document to the active `DocumentCollection` session. - AddDocumentToSession { path: PathBuf }, - - // Document management within the active session - /// Set the active document cursor by index. - SelectDocument { index: usize }, - /// Copy the active document into the clipboard. - CopyDocument, - /// Paste the clipboard item at the end of the active session. - PasteDocument, - /// Remove the active document from the active session. - RemoveDocument, - /// Move the active document cursor to the next item. - NextDocument, - /// Move the active document cursor to the previous item. - PreviousDocument, - /// Move the active document one position towards the front. - MoveDocumentUp, - /// Move the active document one position towards the back. - MoveDocumentDown, - /// Move the active document to an explicit position. - MoveDocumentTo { index: usize }, -} diff --git a/src/document/session/data.rs b/src/document/session/data.rs deleted file mode 100644 index 9697fdd..0000000 --- a/src/document/session/data.rs +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/session/data.rs -// -// Pure data structures for sessions and their document items. -// -// Operational state (active session cursor, active document cursor, clipboard) -// lives in the manager, not here. This module contains only what is persisted. - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -/// Determines how a session's items are managed. -/// -/// A `DirectoryBrowser` session is created once by the UI and cannot be closed -/// or removed by the user. It can be duplicated via `DuplicateSession`, which -/// produces a new `DocumentCollection` containing a snapshot of the current items. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum CollectionKind { - /// Items are derived from a directory scan; `path` holds the source directory. - DirectoryBrowser, - /// Items are a curated list of documents or pages; `path` is None. - DocumentCollection, -} - -/// A single entry in a session – a file path and an optional page within that file. -/// -/// `page_index` is 0 for single-page formats (raster, SVG) and non-zero for -/// multi-page documents (PDF, multi-frame TIFF) where each page is its own item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionItem { - pub path: PathBuf, - pub page_index: usize, -} - -impl SessionItem { - #[must_use] - pub fn new(path: PathBuf, page_index: usize) -> Self { - Self { path, page_index } - } -} - -/// The persistable state of a single session. -/// -/// Operational state (active document index, clipboard) lives in the manager. -/// This struct is serialized as-is to the RON session file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionData { - pub name: String, - pub kind: CollectionKind, - /// For `DirectoryBrowser`: the source directory to scan. - /// For `DocumentCollection`: `None` (items are managed individually). - #[serde(default)] - pub path: Option, - pub items: Vec, -} - -impl SessionData { - #[must_use] - pub fn new( - name: &str, - kind: CollectionKind, - path: Option, - items: Vec, - ) -> Self { - Self { - name: name.to_string(), - kind, - path, - items, - } - } -} diff --git a/src/document/session/mod.rs b/src/document/session/mod.rs deleted file mode 100644 index 5d25ca6..0000000 --- a/src/document/session/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/session/mod.rs -// -// Session subsystem: data model, commands, and persistence. - -pub mod command; -pub mod data; -pub mod store; diff --git a/src/document/session/store.rs b/src/document/session/store.rs deleted file mode 100644 index 9d00c3c..0000000 --- a/src/document/session/store.rs +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/session/store.rs -// -// RON-based persistence for session data: load and save session lists. -// -// For the session concept and the meaning of each field see `session.rs`. -// -// On disk a session file contains a RON list of SessionData records: -// -// [ -// ( -// name: "Pictures", -// kind: DirectoryBrowser, -// path: Some("/home/user/Pictures"), -// items: [], -// current_index: None, -// ), -// ( -// name: "Project Docs", -// kind: DocumentCollection, -// path: None, -// items: [ -// (path: "/home/user/docs/spec.pdf", page_index: 0), -// (path: "/home/user/docs/notes.pdf", page_index: 2), -// ], -// current_index: Some(0), -// ), -// ] - -use crate::document::session::data::SessionData; -use crate::error::{Error, Result}; -use std::path::Path; - -/// Loads all sessions from a RON file. -/// -/// # Errors -/// -/// Returns [`Error::Io`] if the file cannot be read. -/// Returns [`Error::Session`] if the file cannot be parsed. -pub fn load_sessions(path: &Path) -> Result> { - let content = std::fs::read_to_string(path).map_err(Error::Io)?; - - ron::from_str(&content).map_err(|e| { - Error::Session(format!( - "Failed to parse session file {}: {e}", - path.display() - )) - }) -} - -/// Saves all sessions to a RON file. -/// -/// # Errors -/// -/// Returns [`Error::Session`] if the sessions cannot be serialized. -/// Returns [`Error::Io`] if the file cannot be written. -pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> { - let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default()) - .map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?; - - std::fs::write(path, content).map_err(Error::Io) -} diff --git a/src/document/types/image.rs b/src/document/types/image.rs deleted file mode 100644 index 3791631..0000000 --- a/src/document/types/image.rs +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/types/image.rs -// -// Raw pixel buffer passed from the render subsystem to the UI. - -use std::sync::Arc; - -/// A decoded, format-independent pixel buffer ready for display. -/// -/// The UI receives this type from the `DocumentManager` and never needs to -/// know which document format (PDF, SVG, raster) produced it. -#[derive(Clone, Debug)] -pub struct RawImage { - pub data: Arc>, - pub width: u32, - pub height: u32, -} diff --git a/src/document/types/layout.rs b/src/document/types/layout.rs deleted file mode 100644 index e86628d..0000000 --- a/src/document/types/layout.rs +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/types/layout.rs -// -// PageLayout – describes the vertical arrangement of pages in a document. - -/// The computed layout of all pages in a multi-page document. -/// -/// Stores absolute y-offsets for each page so the render subsystem can -/// determine which pages are visible at a given scroll position without -/// iterating the full list on every frame. -#[derive(Clone, Debug)] -pub struct PageLayout { - pub page_starts: Vec, - pub page_sizes: Vec<(f64, f64)>, - pub gap: f64, - pub total_height: f64, - pub max_width: f64, -} - -impl PageLayout { - #[must_use] - pub fn new(page_sizes: Vec<(f64, f64)>, gap: f64) -> Self { - let mut page_starts = Vec::with_capacity(page_sizes.len()); - let mut y = 0.0_f64; - let mut max_width = 0.0_f64; - - for &(w, h) in &page_sizes { - page_starts.push(y); - y += h + gap; - if w > max_width { - max_width = w; - } - } - - let total_height = if page_sizes.is_empty() { 0.0 } else { y - gap }; - - Self { - page_starts, - page_sizes, - gap, - total_height, - max_width, - } - } -} diff --git a/src/document/types/mod.rs b/src/document/types/mod.rs deleted file mode 100644 index 7a5f836..0000000 --- a/src/document/types/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/types/mod.rs -// -// Shared domain primitives used across the document and render subsystems. - -pub mod image; -pub mod layout; -pub mod viewbox; - -pub use layout::PageLayout; -pub use viewbox::ViewBox; diff --git a/src/document/types/viewbox.rs b/src/document/types/viewbox.rs deleted file mode 100644 index 45715c6..0000000 --- a/src/document/types/viewbox.rs +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/document/types/viewbox.rs -// -// ViewBox – describes the visible coordinate space of a vector document. - -/// The coordinate space declared by an SVG `viewBox` attribute. -/// -/// Used by the render subsystem to map document coordinates to screen pixels -/// without exposing SVG internals to the UI. -#[derive(Clone, Debug)] -pub struct ViewBox { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} diff --git a/src/domain/document/collection.rs b/src/domain/document/collection.rs new file mode 100644 index 0000000..43c4c09 --- /dev/null +++ b/src/domain/document/collection.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/collection.rs +// +// Document collection for managing multiple documents. + +use std::path::PathBuf; + +use crate::domain::document::core::content::DocumentContent; + +/// A collection of documents with navigation support. +/// +/// This abstraction is useful for: +/// - Browsing through folders of images +/// - Batch operations on multiple documents +/// - Comparison views (showing multiple documents side-by-side) +#[derive(Debug)] +pub struct DocumentCollection { + /// List of document paths in the collection. + paths: Vec, + /// Currently active document index. + current_index: Option, + /// Currently loaded document (lazy-loaded). + current_document: Option, +} + +impl DocumentCollection { + /// Create an empty collection. + #[must_use] + pub fn new() -> Self { + Self { + paths: Vec::new(), + current_index: None, + current_document: None, + } + } + + /// Create a collection from a list of paths. + #[must_use] + pub fn from_paths(paths: Vec) -> Self { + let current_index = if paths.is_empty() { None } else { Some(0) }; + + Self { + paths, + current_index, + current_document: None, + } + } + + /// Get the number of documents in the collection. + #[must_use] + pub fn len(&self) -> usize { + self.paths.len() + } + + /// Check if the collection is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } + + /// Get the current document index (0-based). + #[must_use] + pub fn current_index(&self) -> Option { + self.current_index + } + + /// Get the current document path. + #[must_use] + pub fn current_path(&self) -> Option<&PathBuf> { + self.current_index.and_then(|idx| self.paths.get(idx)) + } + + /// Get all paths in the collection. + #[must_use] + pub fn paths(&self) -> &[PathBuf] { + &self.paths + } + + /// Get a reference to the currently loaded document. + #[must_use] + pub fn current_document(&self) -> Option<&DocumentContent> { + self.current_document.as_ref() + } + + /// Get a mutable reference to the currently loaded document. + #[must_use] + pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> { + self.current_document.as_mut() + } + + /// Set the currently loaded document. + pub fn set_current_document(&mut self, document: DocumentContent) { + self.current_document = Some(document); + } + + /// Clear the currently loaded document. + pub fn clear_current_document(&mut self) { + self.current_document = None; + } + + /// Navigate to the next document in the collection. + /// + /// Returns the new index if successful, None if already at the end. + pub fn next(&mut self) -> Option { + if let Some(current) = self.current_index + && current + 1 < self.paths.len() + { + self.current_index = Some(current + 1); + self.current_document = None; // Clear document (needs reload) + return self.current_index; + } + None + } + + /// Navigate to the previous document in the collection. + /// + /// Returns the new index if successful, None if already at the start. + pub fn previous(&mut self) -> Option { + if let Some(current) = self.current_index + && current > 0 + { + self.current_index = Some(current - 1); + self.current_document = None; // Clear document (needs reload) + return self.current_index; + } + None + } + + /// Navigate to a specific index. + /// + /// Returns true if the index is valid and navigation succeeded. + pub fn goto(&mut self, index: usize) -> bool { + if index < self.paths.len() { + self.current_index = Some(index); + self.current_document = None; // Clear document (needs reload) + true + } else { + false + } + } + + /// Add a document path to the collection. + pub fn add_path(&mut self, path: PathBuf) { + self.paths.push(path); + if self.current_index.is_none() { + self.current_index = Some(0); + } + } + + /// Remove a document path at the given index. + /// + /// Returns the removed path if successful. + pub fn remove_at(&mut self, index: usize) -> Option { + if index < self.paths.len() { + let removed = self.paths.remove(index); + + // Update current index if needed + if let Some(current) = self.current_index { + if current == index { + // Removed current document + self.current_document = None; + if self.paths.is_empty() { + self.current_index = None; + } else if current >= self.paths.len() { + self.current_index = Some(self.paths.len() - 1); + } + } else if current > index { + // Adjust index after removal + self.current_index = Some(current - 1); + } + } + + Some(removed) + } else { + None + } + } + + /// Clear the entire collection. + pub fn clear(&mut self) { + self.paths.clear(); + self.current_index = None; + self.current_document = None; + } + + /// Check if there is a next document available. + #[must_use] + pub fn has_next(&self) -> bool { + if let Some(current) = self.current_index { + current + 1 < self.paths.len() + } else { + false + } + } + + /// Check if there is a previous document available. + #[must_use] + pub fn has_previous(&self) -> bool { + if let Some(current) = self.current_index { + current > 0 + } else { + false + } + } + + /// Get the path at a specific index. + #[must_use] + pub fn path_at(&self, index: usize) -> Option<&PathBuf> { + self.paths.get(index) + } +} + +impl Default for DocumentCollection { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_collection() { + let collection = DocumentCollection::new(); + assert!(collection.is_empty()); + assert_eq!(collection.len(), 0); + assert_eq!(collection.current_index(), None); + } + + #[test] + fn test_navigation() { + let paths = vec![ + PathBuf::from("a.png"), + PathBuf::from("b.png"), + PathBuf::from("c.png"), + ]; + let mut collection = DocumentCollection::from_paths(paths); + + assert_eq!(collection.current_index(), Some(0)); + assert_eq!(collection.next(), Some(1)); + assert_eq!(collection.next(), Some(2)); + assert_eq!(collection.next(), None); // At end + assert_eq!(collection.previous(), Some(1)); + assert_eq!(collection.previous(), Some(0)); + assert_eq!(collection.previous(), None); // At start + } + + #[test] + fn test_goto() { + let paths = vec![ + PathBuf::from("a.png"), + PathBuf::from("b.png"), + PathBuf::from("c.png"), + ]; + let mut collection = DocumentCollection::from_paths(paths); + + assert!(collection.goto(2)); + assert_eq!(collection.current_index(), Some(2)); + assert!(!collection.goto(10)); // Invalid index + } + + #[test] + fn test_remove() { + let paths = vec![ + PathBuf::from("a.png"), + PathBuf::from("b.png"), + PathBuf::from("c.png"), + ]; + let mut collection = DocumentCollection::from_paths(paths); + + collection.goto(1); + assert_eq!(collection.remove_at(1), Some(PathBuf::from("b.png"))); + assert_eq!(collection.len(), 2); + assert_eq!(collection.current_index(), Some(1)); // Now points to c.png + } +} diff --git a/src/domain/document/core/content.rs b/src/domain/document/core/content.rs new file mode 100644 index 0000000..3cdf26c --- /dev/null +++ b/src/domain/document/core/content.rs @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/core/content.rs +// +// Type-erased document content enum. + +use std::fmt; +use std::path::Path; + +use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat; +use cosmic::widget::image::Handle as ImageHandle; + +use super::document::{ + DocResult, DocumentInfo, FlipDirection, InterpolationQuality, MultiPage, MultiPageThumbnails, + RenderOutput, Renderable, Rotation, RotationMode, Transformable, TransformState, +}; + +use crate::domain::document::types::raster::RasterDocument; +#[cfg(feature = "vector")] +use crate::domain::document::types::vector::VectorDocument; +#[cfg(feature = "portable")] +use crate::domain::document::types::portable::PortableDocument; + +// ============================================================================ +// Document Kind +// ============================================================================ + +/// Supported document kinds (for format detection). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentKind { + Raster, + Vector, + Portable, +} + +impl DocumentKind { + /// Detect document kind from file path. + #[must_use] + pub fn from_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?.to_lowercase(); + + // SVG + #[cfg(feature = "vector")] + if ext == "svg" || ext == "svgz" { + return Some(Self::Vector); + } + + // PDF + #[cfg(feature = "portable")] + if ext == "pdf" { + return Some(Self::Portable); + } + + // Raster: Check via cosmic/image-rs + if CosmicImageFormat::from_path(path).is_ok() { + return Some(Self::Raster); + } + + None + } +} + +impl fmt::Display for DocumentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Raster => write!(f, "Raster"), + Self::Vector => write!(f, "Vector"), + Self::Portable => write!(f, "Portable"), + } + } +} + +// ============================================================================ +// Document Content Enum +// ============================================================================ + +/// Type-erased document content. +/// +/// The application only holds one document at a time, so the size difference +/// between variants is acceptable. Boxing would add unnecessary indirection. +#[allow(clippy::large_enum_variant)] +pub enum DocumentContent { + Raster(RasterDocument), + #[cfg(feature = "vector")] + Vector(VectorDocument), + #[cfg(feature = "portable")] + Portable(PortableDocument), +} + +impl fmt::Debug for DocumentContent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Raster(_) => write!(f, "DocumentContent::Raster(...)"), + #[cfg(feature = "vector")] + Self::Vector(_) => write!(f, "DocumentContent::Vector(...)"), + #[cfg(feature = "portable")] + Self::Portable(_) => write!(f, "DocumentContent::Portable(...)"), + } + } +} + +// ============================================================================ +// Trait Implementations +// ============================================================================ + +impl Renderable for DocumentContent { + fn render(&mut self, scale: f64) -> DocResult { + match self { + Self::Raster(doc) => doc.render(scale), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.render(scale), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.render(scale), + } + } + + fn info(&self) -> DocumentInfo { + match self { + Self::Raster(doc) => doc.info(), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.info(), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.info(), + } + } +} + +impl Transformable for DocumentContent { + fn rotate(&mut self, rotation: Rotation) { + match self { + Self::Raster(doc) => doc.rotate(rotation), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.rotate(rotation), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.rotate(rotation), + } + } + + fn flip(&mut self, direction: FlipDirection) { + match self { + Self::Raster(doc) => doc.flip(direction), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.flip(direction), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.flip(direction), + } + } + + fn transform_state(&self) -> TransformState { + match self { + Self::Raster(doc) => doc.transform_state(), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.transform_state(), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.transform_state(), + } + } + + fn rotate_fine(&mut self, angle_degrees: f32) { + match self { + Self::Raster(doc) => doc.rotate_fine(angle_degrees), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.rotate_fine(angle_degrees), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.rotate_fine(angle_degrees), + } + } + + fn reset_fine_rotation(&mut self) { + match self { + Self::Raster(doc) => doc.reset_fine_rotation(), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.reset_fine_rotation(), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.reset_fine_rotation(), + } + } + + fn set_interpolation_quality(&mut self, quality: InterpolationQuality) { + match self { + Self::Raster(doc) => doc.set_interpolation_quality(quality), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.set_interpolation_quality(quality), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.set_interpolation_quality(quality), + } + } +} + +// ============================================================================ +// Convenience Methods +// ============================================================================ + +impl DocumentContent { + /// Rotate document 90 degrees clockwise. + pub fn rotate_cw(&mut self) { + let new_rotation_mode = self.transform_state().rotation.rotate_cw(); + match new_rotation_mode { + RotationMode::Standard(rot) => self.rotate(rot), + RotationMode::Fine(deg) => { + let normalized = ((deg / 90.0).round() as i16 * 90) % 360; + let rot = match normalized { + 0 => Rotation::None, + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => Rotation::None, + }; + self.rotate(rot); + } + } + } + + /// Rotate document 90 degrees counter-clockwise. + pub fn rotate_ccw(&mut self) { + let new_rotation_mode = self.transform_state().rotation.rotate_ccw(); + match new_rotation_mode { + RotationMode::Standard(rot) => self.rotate(rot), + RotationMode::Fine(deg) => { + let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360; + let rot = match normalized { + 0 => Rotation::None, + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => Rotation::None, + }; + self.rotate(rot); + } + } + } + + /// Flip document horizontally. + pub fn flip_horizontal(&mut self) { + self.flip(FlipDirection::Horizontal); + } + + /// Flip document vertically. + pub fn flip_vertical(&mut self) { + self.flip(FlipDirection::Vertical); + } + + /// Get the document kind. + #[must_use] + pub fn kind(&self) -> DocumentKind { + match self { + Self::Raster(_) => DocumentKind::Raster, + #[cfg(feature = "vector")] + Self::Vector(_) => DocumentKind::Vector, + #[cfg(feature = "portable")] + Self::Portable(_) => DocumentKind::Portable, + } + } + + /// Check if document supports multiple pages. + #[must_use] + pub fn is_multi_page(&self) -> bool { + matches!(self, Self::Portable(_)) + } + + /// Get total page count (returns 1 for single-page documents). + #[must_use] + pub fn page_count(&self) -> usize { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.page_count(), + _ => 1, + } + } + + /// Get current page index (0 for single-page documents). + #[must_use] + pub fn current_page(&self) -> usize { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.current_page(), + _ => 0, + } + } + + /// Navigate to a specific page (no-op for single-page documents). + pub fn go_to_page(&mut self, page: usize) -> DocResult<()> { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.go_to_page(page), + _ => Ok(()), + } + } + + /// Get thumbnail for a specific page (mutable access for trait compatibility). + pub fn get_thumbnail(&mut self, page: usize) -> DocResult> { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.get_thumbnail(page), + _ => Ok(None), + } + } + + /// Get thumbnail handle for a specific page (read-only access). + /// Returns None if the thumbnail hasn't been generated yet. + #[must_use] + pub fn get_thumbnail_handle(&self, page: usize) -> Option { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.get_thumbnail_handle(page), + _ => None, + } + } + + /// Check if thumbnails are ready to be generated. + #[must_use] + pub fn thumbnails_ready(&self) -> bool { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.thumbnails_ready(), + _ => false, + } + } + + /// Get count of thumbnails currently loaded. + #[must_use] + pub fn thumbnails_loaded(&self) -> usize { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => PortableDocument::thumbnails_loaded(doc), + _ => 0, + } + } + + /// Check if all thumbnails have been loaded (trait-compliant). + #[must_use] + pub fn all_thumbnails_loaded(&self) -> bool { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => MultiPageThumbnails::thumbnails_loaded(doc), + _ => false, + } + } + + /// Generate thumbnail for a specific page. + pub fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => MultiPageThumbnails::generate_thumbnail_page(doc, page), + _ => Ok(()), + } + } + + /// Generate all thumbnails. + pub fn generate_thumbnails(&mut self) -> DocResult<()> { + match self { + #[cfg(feature = "portable")] + Self::Portable(doc) => MultiPageThumbnails::generate_all_thumbnails(doc), + _ => Ok(()), + } + } + + /// Get the current rendered image handle. + #[must_use] + pub fn handle(&self) -> Option { + match self { + Self::Raster(doc) => Some(doc.handle()), + #[cfg(feature = "vector")] + Self::Vector(doc) => Some(doc.handle()), + #[cfg(feature = "portable")] + Self::Portable(doc) => Some(doc.handle()), + } + } + + /// Get current dimensions after transformations. + #[must_use] + pub fn dimensions(&self) -> (u32, u32) { + match self { + Self::Raster(doc) => doc.dimensions(), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.dimensions(), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.dimensions(), + } + } + + /// Crop the document (supported for all types - works on rendered output). + pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> { + match self { + Self::Raster(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)), + } + } + + /// Extract document metadata (basic info and EXIF if available). + #[must_use] + pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta { + match self { + Self::Raster(doc) => doc.extract_meta(path), + #[cfg(feature = "vector")] + Self::Vector(doc) => doc.extract_meta(path), + #[cfg(feature = "portable")] + Self::Portable(doc) => doc.extract_meta(path), + } + } +} diff --git a/src/domain/document/core/document.rs b/src/domain/document/core/document.rs new file mode 100644 index 0000000..3e1fdd7 --- /dev/null +++ b/src/domain/document/core/document.rs @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/core/document.rs +// +// Core document traits and abstractions. + +use cosmic::widget::image::Handle as ImageHandle; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// Result type alias for document operations. +pub type DocResult = anyhow::Result; + +/// Rotation state for documents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Rotation { + /// No rotation (0 degrees). + #[default] + None, + /// 90 degrees clockwise. + Cw90, + /// 180 degrees. + Cw180, + /// 270 degrees clockwise (90 counter-clockwise). + Cw270, +} + +impl Rotation { + /// Rotate clockwise by 90 degrees. + #[must_use] + pub fn rotate_cw(self) -> Self { + match self { + Self::None => Self::Cw90, + Self::Cw90 => Self::Cw180, + Self::Cw180 => Self::Cw270, + Self::Cw270 => Self::None, + } + } + + /// Rotate counter-clockwise by 90 degrees. + #[must_use] + pub fn rotate_ccw(self) -> Self { + match self { + Self::None => Self::Cw270, + Self::Cw270 => Self::Cw180, + Self::Cw180 => Self::Cw90, + Self::Cw90 => Self::None, + } + } + + /// Convert to degrees (0, 90, 180, 270). + #[must_use] + pub fn to_degrees(self) -> i16 { + match self { + Self::None => 0, + Self::Cw90 => 90, + Self::Cw180 => 180, + Self::Cw270 => 270, + } + } +} + +/// Rotation mode: standard 90° steps or fine-grained rotation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RotationMode { + /// Standard 90° rotation (lossless for most formats). + Standard(Rotation), + /// Fine-grained rotation in degrees (0.0 - 360.0) with interpolation. + Fine(f32), +} + +impl Default for RotationMode { + fn default() -> Self { + Self::Standard(Rotation::None) + } +} + +impl RotationMode { + /// Convert rotation to degrees (0.0 - 360.0). + #[must_use] + pub fn to_degrees(self) -> f32 { + match self { + Self::Standard(r) => f32::from(r.to_degrees()), + Self::Fine(deg) => deg, + } + } + + /// Check if rotation is a multiple of 90 degrees. + #[must_use] + pub fn is_multiple_of_90(self) -> bool { + match self { + Self::Standard(_) => true, + Self::Fine(deg) => (deg % 90.0).abs() < 0.01, + } + } + + /// Check if no rotation is applied. + #[must_use] + pub fn is_none(self) -> bool { + match self { + Self::Standard(Rotation::None) => true, + Self::Standard(_) => false, + Self::Fine(deg) => deg.abs() < 0.01, + } + } + + /// Rotate clockwise by 90 degrees. + #[must_use] + pub fn rotate_cw(self) -> Self { + match self { + Self::Standard(r) => Self::Standard(r.rotate_cw()), + Self::Fine(deg) => Self::Fine((deg + 90.0) % 360.0), + } + } + + /// Rotate counter-clockwise by 90 degrees. + #[must_use] + pub fn rotate_ccw(self) -> Self { + match self { + Self::Standard(r) => Self::Standard(r.rotate_ccw()), + Self::Fine(deg) => Self::Fine((deg - 90.0 + 360.0) % 360.0), + } + } +} + +/// Interpolation quality for fine rotation and resizing operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[allow(dead_code)] +pub enum InterpolationQuality { + /// Fast, nearest neighbor interpolation. + Fast, + /// Balanced bilinear interpolation (default). + #[default] + Balanced, + /// Best quality, bicubic interpolation. + Best, +} + +/// Flip direction for documents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlipDirection { + /// Flip along the vertical axis (mirror left-right). + Horizontal, + /// Flip along the horizontal axis (mirror top-bottom). + Vertical, +} + +/// Current transformation state of a document. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct TransformState { + /// Current rotation mode (standard 90° or fine rotation). + pub rotation: RotationMode, + /// Whether flipped horizontally. + pub flip_h: bool, + /// Whether flipped vertically. + pub flip_v: bool, +} + +/// Output of a render operation. +#[derive(Debug, Clone)] +pub struct RenderOutput { + /// Image handle for display. + pub handle: ImageHandle, + /// Rendered width in pixels. + pub width: u32, + /// Rendered height in pixels. + pub height: u32, +} + +/// Document metadata/information. +#[derive(Debug, Clone)] +pub struct DocumentInfo { + /// Native width in pixels (before transforms). + pub width: u32, + /// Native height in pixels (before transforms). + pub height: u32, + /// Document format description. + pub format: String, +} + +// ============================================================================ +// Traits +// ============================================================================ + +/// Trait for documents that can be rendered to an image. +pub trait Renderable { + /// Render the document at the given scale factor. + fn render(&mut self, scale: f64) -> DocResult; + + /// Get document information (dimensions, format). + fn info(&self) -> DocumentInfo; +} + +/// Trait for documents that support geometric transformations. +pub trait Transformable { + /// Apply a standard 90° rotation. + fn rotate(&mut self, rotation: Rotation); + + /// Flip in the given direction. + fn flip(&mut self, direction: FlipDirection); + + /// Get the current transformation state. + fn transform_state(&self) -> TransformState; + + /// Apply fine-grained rotation in degrees (0.0 - 360.0). + fn rotate_fine(&mut self, _angle_degrees: f32) { + // Default: no-op (not all formats support fine rotation) + } + + /// Reset any accumulated fine rotation. + fn reset_fine_rotation(&mut self) { + // Default: no-op + } + + /// Set interpolation quality for transformations. + fn set_interpolation_quality(&mut self, _quality: InterpolationQuality) { + // Default: no-op + } +} + +/// Trait for documents with multiple pages. +pub trait MultiPage { + /// Get total number of pages. + fn page_count(&self) -> usize; + + /// Get current page index (0-based). + fn current_page(&self) -> usize; + + /// Navigate to a specific page. + fn go_to_page(&mut self, page: usize) -> DocResult<()>; +} + +/// Trait for multi-page documents that support thumbnail generation. +pub trait MultiPageThumbnails: MultiPage { + /// Get thumbnail for a specific page. + fn get_thumbnail(&mut self, page: usize) -> DocResult>; + + /// Check if thumbnails are ready to be generated. + fn thumbnails_ready(&self) -> bool; + + /// Check if all thumbnails have been loaded. + fn thumbnails_loaded(&self) -> bool; + + /// Generate thumbnail for a specific page. + fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()>; + + /// Generate all thumbnails. + fn generate_all_thumbnails(&mut self) -> DocResult<()>; +} diff --git a/src/domain/document/core/metadata.rs b/src/domain/document/core/metadata.rs new file mode 100644 index 0000000..b62db25 --- /dev/null +++ b/src/domain/document/core/metadata.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/core/metadata.rs +// +// Document metadata structures and EXIF parsing. + +use std::io::Cursor; + +/// Minutes per degree for GPS coordinate conversion (DMS to decimal degrees). +const MINUTES_PER_DEGREE: f64 = 60.0; + +/// Seconds per degree for GPS coordinate conversion (DMS to decimal degrees). +const SECONDS_PER_DEGREE: f64 = 3600.0; + +/// Basic document metadata (always available). +#[derive(Debug, Clone)] +pub struct BasicMeta { + /// File name (without path). + pub file_name: String, + /// Full file path. + pub file_path: String, + /// Image format as string (e.g., "PNG", "JPEG", "PDF"). + pub format: String, + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// File size in bytes. + pub file_size: u64, + /// Color type description (e.g., "RGBA8", "RGB8", "Grayscale"). + pub color_type: String, +} + +impl BasicMeta { + /// Format file size as human-readable string. + pub fn file_size_display(&self) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + #[allow(clippy::cast_precision_loss)] + if self.file_size >= GB { + let size_gb = self.file_size as f64 / GB as f64; + format!("{size_gb:.2} GB") + } else if self.file_size >= MB { + let size_mb = self.file_size as f64 / MB as f64; + format!("{size_mb:.2} MB") + } else if self.file_size >= KB { + let size_kb = self.file_size as f64 / KB as f64; + format!("{size_kb:.1} KB") + } else { + let size = self.file_size; + format!("{size} B") + } + } + + /// Format resolution as "W × H". + pub fn resolution_display(&self) -> String { + format!("{} × {}", self.width, self.height) + } +} + +/// EXIF metadata (optional, mainly for JPEG/TIFF). +#[derive(Debug, Clone, Default)] +pub struct ExifMeta { + pub camera_make: Option, + pub camera_model: Option, + pub date_time: Option, + pub exposure_time: Option, + pub f_number: Option, + pub iso: Option, + pub focal_length: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, +} + +impl ExifMeta { + /// Parse EXIF data from raw image bytes. + /// + /// Extracts camera information, exposure settings, and GPS coordinates + /// from JPEG/TIFF EXIF metadata using the kamadak-exif crate. + pub fn from_bytes(bytes: &[u8]) -> Option { + use exif::{In, Reader, Tag}; + + let cursor = Cursor::new(bytes); + let exif_reader = Reader::new(); + let exif = exif_reader.read_from_container(&mut cursor.clone()).ok()?; + + let mut meta = Self::default(); + + // Camera make and model + if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) { + meta.camera_make = Some(field.display_value().to_string().trim().to_string()); + } + if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) { + meta.camera_model = Some(field.display_value().to_string().trim().to_string()); + } + + // Date and time + if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) { + meta.date_time = Some(field.display_value().to_string()); + } + + // Exposure time + if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) { + meta.exposure_time = Some(field.display_value().to_string()); + } + + // F-number (aperture) + if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) { + meta.f_number = Some(field.display_value().to_string()); + } + + // ISO speed + if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) { + if let exif::Value::Short(ref vec) = field.value { + if let Some(&iso) = vec.first() { + meta.iso = Some(u32::from(iso)); + } + } + } + + // Focal length + if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) { + meta.focal_length = Some(field.display_value().to_string()); + } + + // GPS coordinates + meta.gps_latitude = Self::parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef); + meta.gps_longitude = Self::parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef); + + Some(meta) + } + + /// Parse GPS coordinate from EXIF data (converts DMS to decimal degrees). + fn parse_gps_coord(exif: &exif::Exif, coord_tag: exif::Tag, ref_tag: exif::Tag) -> Option { + use exif::{In, Value}; + + let coord_field = exif.get_field(coord_tag, In::PRIMARY)?; + let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; + + // Get reference (N/S for latitude, E/W for longitude) + let reference = ref_field.display_value().to_string(); + + // Parse DMS (Degrees, Minutes, Seconds) values + if let Value::Rational(ref rationals) = coord_field.value { + if rationals.len() >= 3 { + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + + // Convert to decimal degrees + let mut decimal = + degrees + (minutes / MINUTES_PER_DEGREE) + (seconds / SECONDS_PER_DEGREE); + + // Apply sign based on hemisphere + if reference == "S" || reference == "W" { + decimal = -decimal; + } + + return Some(decimal); + } + } + + None + } + + /// Combined camera make and model for display. + pub fn camera_display(&self) -> Option { + match (&self.camera_make, &self.camera_model) { + (Some(make), Some(model)) => { + if model.starts_with(make) { + Some(model.clone()) + } else { + Some(format!("{make} {model}")) + } + } + (Some(make), None) => Some(make.clone()), + (None, Some(model)) => Some(model.clone()), + (None, None) => None, + } + } + + /// Format GPS coordinates for display. + pub fn gps_display(&self) -> Option { + match (self.gps_latitude, self.gps_longitude) { + (Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")), + _ => None, + } + } +} + +/// Complete document metadata container. +#[derive(Debug, Clone)] +pub struct DocumentMeta { + pub basic: BasicMeta, + pub exif: Option, +} diff --git a/src/domain/document/core/mod.rs b/src/domain/document/core/mod.rs new file mode 100644 index 0000000..cb37441 --- /dev/null +++ b/src/domain/document/core/mod.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/core/mod.rs +// +// Core document abstractions: traits, types, and metadata. + +pub mod content; +pub mod document; +pub mod metadata; +pub mod page; + +// Re-export commonly used types +pub use content::DocumentContent; +pub use metadata::DocumentMeta; diff --git a/src/domain/document/core/page.rs b/src/domain/document/core/page.rs new file mode 100644 index 0000000..98929e6 --- /dev/null +++ b/src/domain/document/core/page.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/core/page.rs +// +// Page abstraction for multi-page documents. + +use cosmic::widget::image::Handle as ImageHandle; + +/// Represents a single page in a multi-page document. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Page { + /// Page index (0-based). + pub index: usize, + /// Page width in pixels. + pub width: u32, + /// Page height in pixels. + pub height: u32, + /// Optional thumbnail handle. + pub thumbnail: Option, +} + +impl Page { + /// Create a new page. + #[must_use] + pub fn new(index: usize, width: u32, height: u32) -> Self { + Self { + index, + width, + height, + thumbnail: None, + } + } + + /// Create a page with a thumbnail. + #[must_use] + pub fn with_thumbnail(index: usize, width: u32, height: u32, thumbnail: ImageHandle) -> Self { + Self { + index, + width, + height, + thumbnail: Some(thumbnail), + } + } + + /// Set the thumbnail for this page. + pub fn set_thumbnail(&mut self, thumbnail: ImageHandle) { + self.thumbnail = Some(thumbnail); + } + + /// Check if this page has a thumbnail. + #[must_use] + pub fn has_thumbnail(&self) -> bool { + self.thumbnail.is_some() + } + + /// Get the aspect ratio of the page. + #[must_use] + pub fn aspect_ratio(&self) -> f32 { + if self.height == 0 { + 1.0 + } else { + #[allow(clippy::cast_precision_loss)] + { + self.width as f32 / self.height as f32 + } + } + } + + /// Get page dimensions as a tuple. + #[must_use] + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } +} diff --git a/src/domain/document/mod.rs b/src/domain/document/mod.rs new file mode 100644 index 0000000..d4211e9 --- /dev/null +++ b/src/domain/document/mod.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/mod.rs +// +// Document domain: core abstractions, types, and operations. + +pub mod collection; +pub mod core; +pub mod operations; +pub mod types; + +// Re-export core abstractions (only used ones) +#[allow(unused_imports)] +pub use core::{DocumentContent, DocumentMeta}; + +// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image) +// are internal helpers used only by document type implementations. +// Use high-level operations above for all application and UI code. diff --git a/src/domain/document/operations/README.md b/src/domain/document/operations/README.md new file mode 100644 index 0000000..2dfb964 --- /dev/null +++ b/src/domain/document/operations/README.md @@ -0,0 +1,281 @@ +# Document Operations + +This module provides transformation, rendering, and export operations for documents. + +## Architecture: Two-Level Operations + +The operations module is designed with **two distinct levels** of abstraction: + +### 1. Low-Level Operations (Internal/Private) + +**Purpose:** Direct manipulation of pixel data for raster images. + +**Visibility:** `pub(crate)` - Internal to the crate only. + +**Location:** `transform.rs` (internal helpers) + +**Functions:** +- `apply_rotation(img, rotation)` - Rotate raster pixels +- `apply_flip(img, direction)` - Flip raster pixels +- `crop_to_image(img, x, y, w, h)` - Crop raster to image + +**When to use:** +- ONLY in document type implementations (RasterDocument, VectorDocument, PortableDocument) +- NOT accessible outside the crate +- NOT for application or UI code + +**Example:** +```rust +// INTERNAL USE ONLY - in document type implementations +impl Transformable for RasterDocument { + fn rotate(&mut self, rotation: Rotation) { + // Low-level operation used internally + self.image = apply_rotation(self.image, rotation); + } +} +``` + +### 2. High-Level Operations (Type-Agnostic) + +**Purpose:** Document transformations that work across **all** document types (Raster, Vector, Portable). + +**Location:** `transform.rs` (high-level section) + +**Functions:** +- `rotate_document_cw(document)` - Rotate any document 90° CW +- `rotate_document_ccw(document)` - Rotate any document 90° CCW +- `flip_document_horizontal(document)` - Flip any document horizontally +- `flip_document_vertical(document)` - Flip any document vertically +- `rotate_document_to(document, rotation)` - Rotate to specific angle +- `reset_document_transforms(document)` - Reset all transformations + +**When to use:** +- In application commands (`TransformDocumentCommand`) +- In UI message handlers +- Anywhere you work with `DocumentContent` (type-erased document) + +**Example:** +```rust +use crate::domain::document::operations::transform; + +// RECOMMENDED: Use high-level operations +let mut document = DocumentContent::Raster(raster_doc); +transform::rotate_document_cw(&mut document)?; +transform::flip_document_horizontal(&mut document)?; + +// Works with Vector and Portable too! +let mut svg = DocumentContent::Vector(vector_doc); +transform::rotate_document_cw(&mut svg)?; // Lossless viewport transform + +// Works with PDF! +let mut pdf = DocumentContent::Portable(portable_doc); +transform::rotate_document_cw(&mut pdf)?; // Backend handles rendering +``` + +## Why This Separation? + +### Why Low-Level Operations Are Internal + +**Problem:** Exposing low-level operations creates confusion: +- Developers don't know whether to use `apply_rotation()` or `rotate_document_cw()` +- Low-level operations only work on `DynamicImage`, not `DocumentContent` +- Creates two ways to do the same thing (violates DRY) + +**Solution:** Make them `pub(crate)`: +```rust +// NOT POSSIBLE - apply_rotation is internal +transform::apply_rotation(img, Rotation::Cw90); // Compile error! + +// USE THIS - high-level operation +transform::rotate_document_cw(&mut document)?; // Works! +``` + +### Why High-Level Operations Exist + +**Problem without them:** +```rust +// Coupled to implementation details +match document { + DocumentContent::Raster(ref mut doc) => doc.rotate(Rotation::Cw90), + DocumentContent::Vector(ref mut doc) => doc.rotate(Rotation::Cw90), + DocumentContent::Portable(ref mut doc) => doc.rotate(Rotation::Cw90), +} +``` + +**Solution:** +```rust +// Single API for all types +transform::rotate_document_cw(&mut document)?; +``` + +### Benefits + +1. **Single Source of Truth** + - Rotation logic (handling RotationMode::Fine, etc.) is in ONE place + - No duplication across UI handlers, commands, and tests + +2. **Type Safety** + - Works through `DocumentContent` abstraction + - Compiler ensures all document types implement required traits + +3. **Future-Proof** + - Adding new document types (DJVU, EPUB) doesn't require updating call sites + - Operations automatically work with new types + +4. **Testable** + - High-level operations can be tested independently + - No UI dependencies + +## Implementation Details + +### How It Works + +High-level operations use the `Transformable` trait: + +```rust +pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> { + let new_rotation_mode = document.transform_state().rotation.rotate_cw(); + + match new_rotation_mode { + RotationMode::Standard(rot) => document.rotate(rot), + RotationMode::Fine(deg) => { + // Convert fine rotation to nearest 90° standard rotation + // ... + } + } + + Ok(()) +} +``` + +This delegates to the document type's implementation: + +- **Raster:** Actual pixel rotation via `imageops::rotate90()` +- **Vector:** Viewport matrix transformation (lossless!) +- **Portable:** View rotation, rendered by backend (Poppler) + +### Each Type Transforms Differently + +```rust +// Raster: Pixel manipulation (lossy for fine rotations) +impl Transformable for RasterDocument { + fn rotate(&mut self, rotation: Rotation) { + self.image = apply_rotation(self.image, rotation); + } +} + +// Vector: Viewport transform (always lossless!) +impl Transformable for VectorDocument { + fn rotate(&mut self, rotation: Rotation) { + self.transform_matrix = self.transform_matrix.rotate(rotation.to_degrees()); + // No rasterization needed + } +} + +// Portable: View rotation (backend handles rendering) +impl Transformable for PortableDocument { + fn rotate(&mut self, rotation: Rotation) { + self.view_rotation = (self.view_rotation + rotation.to_degrees()) % 360; + } +} +``` + +## Usage Guidelines + +### Prefer High-Level Operations + +```rust +// In application commands +pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> { + let document = manager.current_document_mut()?; + transform::rotate_document_cw(document)?; + Ok(()) +} + +// In UI message handlers +AppMessage::RotateCW => { + if let Some(doc) = &mut self.model.document { + transform::rotate_document_cw(doc)?; + } +} +``` + +### Don't Use Low-Level Operations in Application/UI Code + +```rust +// COMPILE ERROR - Low-level operations are pub(crate) +let pixels = transform::apply_rotation(img, Rotation::Cw90); // Won't compile! + +// CORRECT - Use high-level operations +transform::rotate_document_cw(&mut document)?; +``` + +### ℹ️ Low-Level Operations in Document Implementations + +Low-level operations are only accessible within document type implementations: + +```rust +// INTERNAL ONLY - in domain/document/types/raster.rs +impl Transformable for RasterDocument { + fn rotate(&mut self, rotation: Rotation) { + // This works because we're inside the crate + self.image = apply_rotation(self.image, rotation); + } +} +``` + +## Module Structure + +``` +operations/ +├── mod.rs # Public API exports +├── transform.rs # Low-level + High-level transforms +├── render.rs # Rendering utilities (scale, fit, etc.) +├── export.rs # Export to various formats +└── README.md # This file +``` + +## Adding New Operations + +When adding a new operation: + +1. **Add low-level function** (if pixel manipulation is needed) - mark as `pub(crate)` +2. **Add high-level function** that works on `DocumentContent` - mark as `pub` +3. **Export high-level function only** from `mod.rs` +4. **Update domain exports** in `domain/document/mod.rs` +5. **Create command** in `application/commands/` + +Example: + +```rust +// 1. Low-level (internal only) - in transform.rs +pub(crate) fn apply_grayscale(img: DynamicImage) -> DynamicImage { ... } + +// 2. High-level (public API) - in transform.rs +pub fn grayscale_document(document: &mut DocumentContent) -> DocResult<()> { + // Delegates to Transformable trait or uses low-level helper + ... +} + +// 3. Export high-level only - in operations/mod.rs +pub use transform::{grayscale_document}; // NOT apply_grayscale! + +// 4. Export from domain - in document/mod.rs +pub use operations::{grayscale_document}; + +// 5. Command - in application/commands/ +pub struct GrayscaleCommand; +impl GrayscaleCommand { + pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> { + let doc = manager.current_document_mut()?; + transform::grayscale_document(doc) // High-level operation + } +} +``` + +## Related Concepts + +- **Traits:** `Renderable`, `Transformable`, `MultiPage` (in `domain/document/core/document.rs`) +- **Type Erasure:** `DocumentContent` enum (in `domain/document/core/content.rs`) +- **Commands:** Application layer operations (in `application/commands/`) +- **Domain Layer:** Pure business logic, no UI dependencies diff --git a/src/domain/document/operations/crop.rs b/src/domain/document/operations/crop.rs new file mode 100644 index 0000000..8bc0035 --- /dev/null +++ b/src/domain/document/operations/crop.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/operations/crop.rs +// +// Crop operation domain model. + +/// Crop region in pixel coordinates. +/// +/// Pure domain model - represents a rectangular region to crop. +/// No UI concerns, just data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CropRegion { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +impl CropRegion { + pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self { + Self { x, y, width, height } + } + + pub fn as_tuple(&self) -> (u32, u32, u32, u32) { + (self.x, self.y, self.width, self.height) + } + + /// Check if region has valid dimensions. + pub fn is_valid(&self) -> bool { + self.width > 0 && self.height > 0 + } +} diff --git a/src/domain/document/operations/export.rs b/src/domain/document/operations/export.rs new file mode 100644 index 0000000..aa78ffa --- /dev/null +++ b/src/domain/document/operations/export.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/operations/export.rs +// +// Document export operations to various formats. + +use std::path::Path; + +use image::DynamicImage; + +use crate::domain::document::core::document::DocResult; + +/// Supported export formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + /// PNG format (lossless). + Png, + /// JPEG format (lossy). + Jpeg, + /// WebP format. + WebP, + /// PDF format. + Pdf, + /// SVG format (for vector documents). + Svg, +} + +impl ExportFormat { + /// Get file extension for this format. + #[must_use] + pub fn extension(&self) -> &str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + Self::WebP => "webp", + Self::Pdf => "pdf", + Self::Svg => "svg", + } + } + + /// Get MIME type for this format. + #[must_use] + pub fn mime_type(&self) -> &str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::WebP => "image/webp", + Self::Pdf => "application/pdf", + Self::Svg => "image/svg+xml", + } + } + + /// Detect format from file extension. + #[must_use] + pub fn from_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?.to_lowercase(); + match ext.as_str() { + "png" => Some(Self::Png), + "jpg" | "jpeg" => Some(Self::Jpeg), + "webp" => Some(Self::WebP), + "pdf" => Some(Self::Pdf), + "svg" => Some(Self::Svg), + _ => None, + } + } +} + +/// Export options for image formats. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ImageExportOptions { + /// Quality setting (0-100) for lossy formats. + pub quality: u8, + /// Whether to preserve metadata (EXIF, etc.). + pub preserve_metadata: bool, +} + +impl Default for ImageExportOptions { + fn default() -> Self { + Self { + quality: 90, + preserve_metadata: true, + } + } +} + +/// Export a raster image to a file. +/// +/// This function handles format-specific encoding and options. +pub fn export_image( + img: &DynamicImage, + path: &Path, + format: ExportFormat, + _options: &ImageExportOptions, +) -> DocResult<()> { + match format { + ExportFormat::Png => { + img.save_with_format(path, image::ImageFormat::Png)?; + } + ExportFormat::Jpeg => { + // TODO: Apply quality settings + img.save_with_format(path, image::ImageFormat::Jpeg)?; + } + ExportFormat::WebP => { + img.save_with_format(path, image::ImageFormat::WebP)?; + } + ExportFormat::Pdf | ExportFormat::Svg => { + return Err(anyhow::anyhow!( + "Export to {} not yet implemented", + format.extension() + )); + } + } + + Ok(()) +} + +/// Export a document to a standard paper format (A4, Letter, etc.). +/// +/// This function resizes the document to fit the target format while maintaining +/// aspect ratio, then exports it. +pub fn export_to_paper_format( + img: &DynamicImage, + path: &Path, + target_width: u32, + target_height: u32, + format: ExportFormat, +) -> DocResult<()> { + use image::imageops::FilterType; + + // Resize to fit target dimensions + let resized = img.resize(target_width, target_height, FilterType::Lanczos3); + + // Export with default options + let options = ImageExportOptions::default(); + export_image(&resized, path, format, &options) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_extension() { + assert_eq!(ExportFormat::Png.extension(), "png"); + assert_eq!(ExportFormat::Jpeg.extension(), "jpg"); + assert_eq!(ExportFormat::Pdf.extension(), "pdf"); + } + + #[test] + fn test_format_from_path() { + assert_eq!( + ExportFormat::from_path(Path::new("test.png")), + Some(ExportFormat::Png) + ); + assert_eq!( + ExportFormat::from_path(Path::new("test.JPG")), + Some(ExportFormat::Jpeg) + ); + assert_eq!(ExportFormat::from_path(Path::new("test.txt")), None); + } +} diff --git a/src/domain/document/operations/mod.rs b/src/domain/document/operations/mod.rs new file mode 100644 index 0000000..72f7c23 --- /dev/null +++ b/src/domain/document/operations/mod.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/operations/mod.rs +// +// Document operations: transformations, rendering, and export. + +pub mod crop; +pub mod export; +pub mod render; +pub mod transform; + +// Re-export CropRegion for convenience +pub use crop::CropRegion; + +// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image) +// are internal helpers (pub(crate)) used only by document type implementations. +// Use high-level operations above for application and UI code. diff --git a/src/domain/document/operations/render.rs b/src/domain/document/operations/render.rs new file mode 100644 index 0000000..ce7c0ac --- /dev/null +++ b/src/domain/document/operations/render.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/operations/render.rs +// +// Rendering operations for documents. + +use cosmic::widget::image::Handle as ImageHandle; +use image::{DynamicImage, GenericImageView}; + +/// Create an image handle from RGBA pixel data. +/// +/// This is the primary way to create image handles for display in the UI. +#[must_use] +pub fn create_image_handle(pixels: Vec, width: u32, height: u32) -> ImageHandle { + ImageHandle::from_rgba(width, height, pixels) +} + +/// Create an image handle from a `DynamicImage`. +/// +/// Converts the image to RGBA8 format and creates a handle. +#[must_use] +pub fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle { + let (width, height) = img.dimensions(); + let pixels = img.to_rgba8().into_raw(); + create_image_handle(pixels, width, height) +} + +/// Refresh image handle from a `DynamicImage`. +/// +/// Alias for `create_image_handle_from_image` for compatibility. +#[must_use] +pub fn refresh_handle_from_image(img: &DynamicImage) -> ImageHandle { + create_image_handle_from_image(img) +} + +/// Calculate scaled dimensions maintaining aspect ratio. +/// +/// Returns (width, height) scaled by the given factor. +#[must_use] +pub fn scale_dimensions(width: u32, height: u32, scale: f64) -> (u32, u32) { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let scaled_width = (f64::from(width) * scale).round() as u32; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let scaled_height = (f64::from(height) * scale).round() as u32; + + (scaled_width.max(1), scaled_height.max(1)) +} + +/// Calculate scale factor to fit dimensions into a target size. +/// +/// Returns a scale factor that will make the image fit within the target +/// dimensions while maintaining aspect ratio. +#[must_use] +pub fn calculate_fit_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 { + if width == 0 || height == 0 { + return 1.0; + } + + let width_scale = f64::from(target_width) / f64::from(width); + let height_scale = f64::from(target_height) / f64::from(height); + + width_scale.min(height_scale) +} + +/// Calculate scale factor to fill dimensions. +/// +/// Returns a scale factor that will make the image fill the target dimensions +/// while maintaining aspect ratio (may crop). +#[must_use] +pub fn calculate_fill_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 { + if width == 0 || height == 0 { + return 1.0; + } + + let width_scale = f64::from(target_width) / f64::from(width); + let height_scale = f64::from(target_height) / f64::from(height); + + width_scale.max(height_scale) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scale_dimensions() { + assert_eq!(scale_dimensions(100, 200, 2.0), (200, 400)); + assert_eq!(scale_dimensions(100, 200, 0.5), (50, 100)); + assert_eq!(scale_dimensions(100, 200, 0.0), (1, 1)); // Minimum 1x1 + } + + #[test] + fn test_calculate_fit_scale() { + // Landscape image fitting into square + assert_eq!(calculate_fit_scale(200, 100, 100, 100), 0.5); + // Portrait image fitting into square + assert_eq!(calculate_fit_scale(100, 200, 100, 100), 0.5); + // Square into square + assert_eq!(calculate_fit_scale(100, 100, 100, 100), 1.0); + } + + #[test] + fn test_calculate_fill_scale() { + // Landscape image filling square + assert_eq!(calculate_fill_scale(200, 100, 100, 100), 1.0); + // Portrait image filling square + assert_eq!(calculate_fill_scale(100, 200, 100, 100), 1.0); + } +} diff --git a/src/domain/document/operations/transform.rs b/src/domain/document/operations/transform.rs new file mode 100644 index 0000000..86a0e0c --- /dev/null +++ b/src/domain/document/operations/transform.rs @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/operations/transform.rs +// +// Document transformation operations. +// +// This module provides two levels of transformation operations: +// +// 1. **Low-level operations** (internal) for direct pixel manipulation on raster images: +// - `apply_rotation()` - Rotate pixels by 90°, 180°, or 270° [pub(crate)] +// - `apply_flip()` - Flip pixels horizontally or vertically [pub(crate)] +// - `crop_image()` - Crop to a specific region [pub(crate)] +// These are used internally by document type implementations only. +// +// 2. **High-level operations** that work on any document type (raster, vector, PDF): +// - `rotate_document_cw()` - Rotate any document 90° clockwise +// - `rotate_document_ccw()` - Rotate any document 90° counter-clockwise +// - `flip_document_horizontal()` - Flip any document horizontally +// - `flip_document_vertical()` - Flip any document vertically +// - `rotate_document_to()` - Rotate to a specific angle +// - `reset_document_transforms()` - Reset all transformations +// +// ## Usage Example +// +// ```rust +// use crate::domain::document::operations::transform; +// +// // High-level: Works with any DocumentContent (RECOMMENDED) +// let mut document = DocumentContent::Raster(raster_doc); +// transform::rotate_document_cw(&mut document)?; +// transform::flip_document_horizontal(&mut document)?; +// ``` +// +// Note: Low-level operations (apply_rotation, apply_flip, crop_image) are +// internal helpers used by document type implementations and are not part +// of the public API. +// +// The high-level operations use the `Transformable` trait and work across all +// document types (Raster, Vector, Portable), while low-level operations work +// directly on pixel data. + +use image::{DynamicImage, GenericImageView}; + +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::{ + DocResult, FlipDirection, Rotation, RotationMode, Transformable, +}; + +/// Apply a 90-degree rotation to a raster image. +/// +/// This function performs the actual pixel manipulation for standard rotations. +/// Used internally by `RasterDocument` implementation. +#[must_use] +pub(crate) fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage { + use image::imageops::{rotate180, rotate270, rotate90}; + + match rotation { + Rotation::None => img, + Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())), + Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())), + Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())), + } +} + +/// Apply a flip transformation to a raster image. +/// +/// This function performs the actual pixel manipulation for flip operations. +/// Used internally by `RasterDocument` and `PortableDocument` implementations. +#[must_use] +pub(crate) fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage { + use image::imageops::{flip_horizontal, flip_vertical}; + + match direction { + FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())), + FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())), + } +} + +/// Crop a raster image to the specified region. +/// +/// Coordinates are in pixels relative to the top-left corner. +/// Returns None if the crop region is invalid. +/// Used internally for crop operations. +#[must_use] +pub(crate) fn crop_image( + img: &DynamicImage, + x: u32, + y: u32, + width: u32, + height: u32, +) -> Option { + let (img_width, img_height) = img.dimensions(); + + // Validate crop region + if x >= img_width || y >= img_height { + return None; + } + + // Clamp dimensions to image bounds + let crop_width = width.min(img_width - x); + let crop_height = height.min(img_height - y); + + if crop_width == 0 || crop_height == 0 { + return None; + } + + Some(img.crop_imm(x, y, crop_width, crop_height)) +} + +/// Calculate dimensions after rotation. +/// +/// For 90° and 270° rotations, width and height are swapped. +#[must_use] +pub fn dimensions_after_rotation(width: u32, height: u32, rotation: Rotation) -> (u32, u32) { + match rotation { + Rotation::None | Rotation::Cw180 => (width, height), + Rotation::Cw90 | Rotation::Cw270 => (height, width), + } +} + +// ============================================================================ +// High-Level Document Operations (Type-agnostic) +// ============================================================================ +// +// These operations work on ANY document type (Raster, Vector, Portable) through +// the DocumentContent abstraction. They should be preferred over direct trait +// calls when implementing UI commands or application logic. +// +// Benefits: +// - Single API for all document types +// - Handles rotation mode conversions (Standard ↔ Fine) +// - Returns Result for error handling +// - Future-proof for new document types + +/// Rotate a document 90 degrees clockwise. +/// +/// This operation works on any document type (Raster, Vector, Portable) by +/// delegating to the underlying document's `Transformable` implementation. +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::operations::transform::rotate_document_cw; +/// +/// // Works with any document type +/// rotate_document_cw(&mut document)?; +/// ``` +/// +/// # Implementation Details +/// +/// - Raster: Actual pixel rotation via image operations +/// - Vector: Viewport matrix transformation (lossless) +/// - Portable: View rotation, rendered by backend +pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> { + let new_rotation_mode = document.transform_state().rotation.rotate_cw(); + + match new_rotation_mode { + RotationMode::Standard(rot) => { + document.rotate(rot); + } + RotationMode::Fine(deg) => { + // Convert to nearest 90° rotation + let normalized = ((deg / 90.0).round() as i16 * 90) % 360; + let rot = match normalized { + 0 => Rotation::None, + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => Rotation::None, + }; + document.rotate(rot); + } + } + + Ok(()) +} + +/// Rotate a document 90 degrees counter-clockwise. +/// +/// This operation works on any document type (Raster, Vector, Portable) by +/// delegating to the underlying document's `Transformable` implementation. +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::operations::transform::rotate_document_ccw; +/// +/// rotate_document_ccw(&mut document)?; +/// ``` +pub fn rotate_document_ccw(document: &mut DocumentContent) -> DocResult<()> { + let new_rotation_mode = document.transform_state().rotation.rotate_ccw(); + + match new_rotation_mode { + RotationMode::Standard(rot) => { + document.rotate(rot); + } + RotationMode::Fine(deg) => { + // Convert to nearest 90° rotation + let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360; + let rot = match normalized { + 0 => Rotation::None, + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => Rotation::None, + }; + document.rotate(rot); + } + } + + Ok(()) +} + +/// Flip a document horizontally (mirror left-right). +/// +/// This operation works on any document type by delegating to the underlying +/// document's `Transformable` implementation. +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::operations::transform::flip_document_horizontal; +/// +/// flip_document_horizontal(&mut document)?; +/// ``` +pub fn flip_document_horizontal(document: &mut DocumentContent) -> DocResult<()> { + document.flip(FlipDirection::Horizontal); + Ok(()) +} + +/// Flip a document vertically (mirror top-bottom). +/// +/// This operation works on any document type by delegating to the underlying +/// document's `Transformable` implementation. +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::operations::transform::flip_document_vertical; +/// +/// flip_document_vertical(&mut document)?; +/// ``` +pub fn flip_document_vertical(document: &mut DocumentContent) -> DocResult<()> { + document.flip(FlipDirection::Vertical); + Ok(()) +} + +/// Rotate a document to a specific angle (0°, 90°, 180°, or 270°). +/// +/// This operation works on any document type by delegating to the underlying +/// document's `Transformable` implementation. +/// +/// # Arguments +/// +/// * `document` - The document to rotate +/// * `rotation` - Target rotation angle +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::core::document::Rotation; +/// use crate::domain::document::operations::transform::rotate_document_to; +/// +/// // Rotate to 180 degrees +/// rotate_document_to(&mut document, Rotation::Cw180)?; +/// ``` +pub fn rotate_document_to(document: &mut DocumentContent, rotation: Rotation) -> DocResult<()> { + document.rotate(rotation); + Ok(()) +} + +/// Reset all transformations on a document. +/// +/// This resets the document to its original state (no rotation, no flips). +/// Useful for implementing "Reset View" functionality. +/// +/// # Examples +/// +/// ```no_run +/// use crate::domain::document::operations::transform::reset_document_transforms; +/// +/// // Undo all rotations and flips +/// reset_document_transforms(&mut document)?; +/// ``` +pub fn reset_document_transforms(document: &mut DocumentContent) -> DocResult<()> { + // Reset to no rotation + document.rotate(Rotation::None); + + // Reset flips by checking current state and flipping back if needed + let state = document.transform_state(); + if state.flip_h { + document.flip(FlipDirection::Horizontal); + } + if state.flip_v { + document.flip(FlipDirection::Vertical); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dimensions_after_rotation() { + assert_eq!( + dimensions_after_rotation(100, 200, Rotation::None), + (100, 200) + ); + assert_eq!( + dimensions_after_rotation(100, 200, Rotation::Cw90), + (200, 100) + ); + assert_eq!( + dimensions_after_rotation(100, 200, Rotation::Cw180), + (100, 200) + ); + assert_eq!( + dimensions_after_rotation(100, 200, Rotation::Cw270), + (200, 100) + ); + } +} diff --git a/src/domain/document/types/mod.rs b/src/domain/document/types/mod.rs new file mode 100644 index 0000000..07e5470 --- /dev/null +++ b/src/domain/document/types/mod.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/types/mod.rs +// +// Concrete document type implementations. + +pub mod raster; +#[cfg(feature = "vector")] +pub mod vector; +#[cfg(feature = "portable")] +pub mod portable; diff --git a/src/domain/document/types/portable.rs b/src/domain/document/types/portable.rs new file mode 100644 index 0000000..4d41b5b --- /dev/null +++ b/src/domain/document/types/portable.rs @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/types/portable.rs +// +// Portable documents (PDF) with poppler backend. + +use std::path::{Path, PathBuf}; + +/// PDF page render quality multiplier (2.0 = double resolution for sharp display). +const PDF_RENDER_QUALITY: f64 = 3.0; + +/// PDF thumbnail size multiplier (0.25 = 25% for fast preview generation). +const PDF_THUMBNAIL_SIZE: f64 = 0.25; + +use cairo::{Context, Format, ImageSurface}; +use image::{DynamicImage, GenericImageView}; +use poppler::PopplerDocument; + +use cosmic::widget::image::Handle as ImageHandle; + +use crate::domain::document::core::document::{ + DocResult, DocumentInfo, FlipDirection, MultiPage, MultiPageThumbnails, Renderable, + RenderOutput, Rotation, RotationMode, TransformState, Transformable, +}; + +/// Represents a portable document (PDF). +pub struct PortableDocument { + /// The parsed PDF document. + document: PopplerDocument, + /// Path to the source file (for caching). + source_path: PathBuf, + /// Total number of pages. + num_pages: usize, + /// Current page index (0-based). + page_index: usize, + /// Current transformation state. + transform: TransformState, + /// Current rendered page as image. + pub rendered: DynamicImage, + /// Image handle for display. + pub handle: ImageHandle, + /// Cached thumbnail handles for each page (None = not yet generated). + thumbnail_cache: Option>, +} + +impl PortableDocument { + /// Open a PDF document and render the first page. + pub fn open(path: &Path) -> anyhow::Result { + let document = PopplerDocument::new_from_file(path, None) + .map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?; + + let num_pages = document.get_n_pages(); + if num_pages == 0 { + return Err(anyhow::anyhow!("PDF has no pages")); + } + + let rendered = Self::render_page(&document, 0, RotationMode::Standard(Rotation::None))?; + let handle = Self::create_image_handle_from_image(&rendered); + + Ok(Self { + document, + source_path: path.to_path_buf(), + num_pages, + page_index: 0, + transform: TransformState::default(), + rendered, + handle, + thumbnail_cache: None, + }) + } + + /// Returns the current pixel dimensions (width, height). + #[must_use] + pub fn dimensions(&self) -> (u32, u32) { + self.rendered.dimensions() + } + + /// Get the current image handle. + #[must_use] + pub fn handle(&self) -> ImageHandle { + self.handle.clone() + } + + /// Get native dimensions of current page. + #[must_use] + pub fn native_dimensions(&self) -> (u32, u32) { + self.rendered.dimensions() + } + + /// Get the number of thumbnails currently loaded. + pub fn thumbnails_loaded(&self) -> usize { + self.thumbnail_cache.as_ref().map_or(0, Vec::len) + } + + /// Get thumbnail handle for a specific page (read-only access). + /// Returns None if the thumbnail hasn't been generated yet. + #[must_use] + pub fn get_thumbnail_handle(&self, page: usize) -> Option { + self.thumbnail_cache + .as_ref() + .and_then(|cache| cache.get(page).cloned()) + } + + // Helper functions + + /// Extract metadata for this portable document. + pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta { + use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta}; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file_path = path.to_string_lossy().to_string(); + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + let (width, height) = self.dimensions(); + let format = format!("PDF ({} pages)", self.num_pages); + + let basic = BasicMeta { + file_name, + file_path, + format, + width, + height, + file_size, + color_type: "Rendered".to_string(), + }; + + DocumentMeta { basic, exif: None } + } + + /// Crop the current page to the specified rectangle. + /// Works on rendered output (raster). + pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> { + let (img_width, img_height) = self.rendered.dimensions(); + + // Validate crop region + if x >= img_width || y >= img_height { + return Err(format!( + "Crop region ({}, {}) is outside rendered bounds ({}, {})", + x, y, img_width, img_height + )); + } + + // Clamp dimensions + let crop_width = width.min(img_width - x); + let crop_height = height.min(img_height - y); + + if crop_width == 0 || crop_height == 0 { + return Err("Crop region has zero width or height".to_string()); + } + + // Crop rendered image + self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height); + + // Update handle + self.handle = Self::create_image_handle_from_image(&self.rendered); + + Ok(()) + } + fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle { + let (width, height) = img.dimensions(); + let pixels = img.to_rgba8().into_raw(); + ImageHandle::from_rgba(width, height, pixels) + } + + /// Initialize thumbnail cache (empty, ready for incremental loading). + fn init_thumbnail_cache(&mut self) { + if self.thumbnail_cache.is_none() { + self.thumbnail_cache = Some(Vec::with_capacity(self.num_pages)); + } + } + + /// Generate a single thumbnail page. Returns the next page to generate, or None if done. + pub fn generate_thumbnail_page(&mut self, page: usize) -> Option { + // Initialize cache if needed. + self.init_thumbnail_cache(); + + // Check if we should generate this page. + let should_generate = { + let cache = self.thumbnail_cache.as_ref()?; + page >= cache.len() && page < self.num_pages + }; + + if should_generate { + let handle = self.load_or_generate_thumbnail(page); + if let Some(cache) = self.thumbnail_cache.as_mut() { + cache.push(handle); + } + } + + // Return next page if not done. + let next = page + 1; + if next < self.num_pages { + Some(next) + } else { + None + } + } + + /// Load thumbnail from cache or generate and cache it. + fn load_or_generate_thumbnail(&self, page: usize) -> ImageHandle { + // TODO: Re-enable cache once infrastructure layer is set up + // if let Some(handle) = cache::load_thumbnail(&self.source_path, page) { + // return handle; + // } + + match Self::render_page_at_scale( + &self.document, + page, + RotationMode::Standard(Rotation::None), + PDF_THUMBNAIL_SIZE, + ) { + Ok(img) => { + // TODO: Re-enable cache once infrastructure layer is set up + // let _ = cache::save_thumbnail(&self.source_path, page, &img); + Self::create_image_handle_from_image(&img) + } + Err(e) => { + log::warn!("Failed to generate thumbnail for page {page}: {e}"); + ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0]) + } + } + } + + /// Render a specific page from the document to an image. + fn render_page( + document: &PopplerDocument, + page_index: usize, + rotation: RotationMode, + ) -> anyhow::Result { + Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_QUALITY) + } + + /// Render a specific page at a given scale. + fn render_page_at_scale( + document: &PopplerDocument, + page_index: usize, + rotation: RotationMode, + scale: f64, + ) -> anyhow::Result { + let page = document + .get_page(page_index) + .ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?; + + let (page_width, page_height) = page.get_size(); + let rotation_degrees = rotation.to_degrees() as i16; + + let (width, height) = if rotation_degrees == 90 || rotation_degrees == 270 { + (page_height, page_width) + } else { + (page_width, page_height) + }; + + #[allow(clippy::cast_possible_truncation)] + let scaled_width = (width * scale) as i32; + #[allow(clippy::cast_possible_truncation)] + let scaled_height = (height * scale) as i32; + + let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height) + .map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?; + + let context = Context::new(&surface) + .map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?; + + // Fill with white background. + context.set_source_rgb(1.0, 1.0, 1.0); + let _ = context.paint(); + + context.scale(scale, scale); + + if rotation != RotationMode::Standard(Rotation::None) { + let center_x = width / 2.0; + let center_y = height / 2.0; + context.translate(center_x, center_y); + context.rotate(f64::from(rotation_degrees) * std::f64::consts::PI / 180.0); + context.translate(-page_width / 2.0, -page_height / 2.0); + } + + page.render(&context); + + drop(context); + surface.flush(); + + // Direct conversion from Cairo surface to DynamicImage without PNG round-trip. + // This preserves exact pixel data and avoids ARgb32 → PNG → RGBA conversion artifacts. + let width = surface.width() as u32; + let height = surface.height() as u32; + let stride = surface.stride(); + + let data = surface + .take_data() + .map_err(|e| anyhow::anyhow!("Failed to take Cairo surface data: {e}"))?; + + // ARgb32 in Cairo is 4 bytes per pixel (A, R, G, B) with stride padding. + // Convert to standard RGBA by copying pixel-by-pixel, skipping stride padding. + let mut rgba_bytes = Vec::with_capacity((width * 4) as usize); + for y in 0..height { + let row_offset = (y as i32 * stride) as usize; + for x in 0..width { + let col_offset = (x as i32 * 4) as usize; + let idx = row_offset + col_offset; + // Cairo ARgb32: bytes are [A, R, G, B] + let a = data[idx]; + let r = data[idx + 1]; + let g = data[idx + 2]; + let b = data[idx + 3]; + // Output standard RGBA: [R, G, B, A] + rgba_bytes.extend_from_slice(&[r, g, b, a]); + } + } + + Ok(DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, rgba_bytes) + .expect("Failed to create RgbaImage from Cairo surface data"))) + } + + /// Re-render the current page with current transform. + fn rerender(&mut self) { + match Self::render_page(&self.document, self.page_index, self.transform.rotation) { + Ok(mut rendered) => { + // Apply flip transformations to the rendered result + if self.transform.flip_h { + rendered = Self::apply_flip(rendered, FlipDirection::Horizontal); + } + if self.transform.flip_v { + rendered = Self::apply_flip(rendered, FlipDirection::Vertical); + } + self.rendered = rendered; + self.handle = Self::create_image_handle_from_image(&self.rendered); + } + Err(e) => { + log::error!("Failed to render PDF page: {e}"); + } + } + } + + fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage { + use image::imageops::{flip_horizontal, flip_vertical}; + match direction { + FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())), + FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())), + } + } + + /// Navigate to the next page. + #[allow(dead_code)] + pub fn next_page(&mut self) -> bool { + if self.page_index + 1 < self.num_pages { + self.page_index += 1; + self.rerender(); + true + } else { + false + } + } + + /// Navigate to the previous page. + #[allow(dead_code)] + pub fn prev_page(&mut self) -> bool { + if self.page_index > 0 { + self.page_index -= 1; + self.rerender(); + true + } else { + false + } + } +} + +// ============================================================================ +// Trait Implementations +// ============================================================================ + +impl Renderable for PortableDocument { + fn render(&mut self, _scale: f64) -> DocResult { + // PDF rendering quality is fixed for now (PDF_RENDER_QUALITY) + let (width, height) = self.dimensions(); + Ok(RenderOutput { + handle: self.handle.clone(), + width, + height, + }) + } + + fn info(&self) -> DocumentInfo { + let (width, height) = self.dimensions(); + DocumentInfo { + width, + height, + format: "PDF".to_string(), + } + } +} + +impl Transformable for PortableDocument { + fn rotate(&mut self, rotation: Rotation) { + self.transform.rotation = RotationMode::Standard(rotation); + self.rerender(); + } + + fn flip(&mut self, direction: FlipDirection) { + match direction { + FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h, + FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v, + } + self.rerender(); + } + + fn transform_state(&self) -> TransformState { + self.transform + } +} + +impl MultiPage for PortableDocument { + fn page_count(&self) -> usize { + self.num_pages + } + + fn current_page(&self) -> usize { + self.page_index + } + + fn go_to_page(&mut self, page: usize) -> DocResult<()> { + if page >= self.num_pages { + return Err(anyhow::anyhow!( + "Page {} out of range (0-{})", + page, + self.num_pages - 1 + )); + } + self.page_index = page; + self.rerender(); + Ok(()) + } +} + +impl MultiPageThumbnails for PortableDocument { + fn thumbnails_ready(&self) -> bool { + self.thumbnail_cache + .as_ref() + .is_some_and(|c| c.len() >= self.num_pages) + } + + fn thumbnails_loaded(&self) -> bool { + let loaded = PortableDocument::thumbnails_loaded(self); + loaded >= self.num_pages + } + + fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> { + PortableDocument::generate_thumbnail_page(self, page); + Ok(()) + } + + fn generate_all_thumbnails(&mut self) -> DocResult<()> { + if self.thumbnails_ready() { + return Ok(()); + } + self.init_thumbnail_cache(); + for page in 0..self.num_pages { + PortableDocument::generate_thumbnail_page(self, page); + } + Ok(()) + } + + fn get_thumbnail(&mut self, page: usize) -> DocResult> { + Ok(self + .thumbnail_cache + .as_ref() + .and_then(|cache| cache.get(page).cloned())) + } +} diff --git a/src/domain/document/types/raster.rs b/src/domain/document/types/raster.rs new file mode 100644 index 0000000..85a7027 --- /dev/null +++ b/src/domain/document/types/raster.rs @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/types/raster.rs +// +// Raster image document support (PNG, JPEG, WebP, etc.). + +use std::path::Path; + +use image::{DynamicImage, GenericImageView, ImageReader}; + +use cosmic::widget::image::Handle as ImageHandle; + +use crate::domain::document::core::document::{ + DocResult, DocumentInfo, FlipDirection, InterpolationQuality, Renderable, RenderOutput, + Rotation, RotationMode, TransformState, Transformable, +}; + +/// Represents a raster image document (PNG, JPEG, WebP, ...). +pub struct RasterDocument { + /// The decoded image document. + document: DynamicImage, + /// Native width (original, before transforms). + native_width: u32, + /// Native height (original, before transforms). + native_height: u32, + /// Current transformation state. + transform: TransformState, + /// Cached handle for rendering. + handle: ImageHandle, + /// Accumulated fine rotation angle in degrees. + fine_rotation_angle: f32, + /// Interpolation quality for fine rotation and resize operations. + interpolation_quality: InterpolationQuality, +} + +impl RasterDocument { + /// Load a raster document from disk. + pub fn open(path: &Path) -> image::ImageResult { + let document = ImageReader::open(path)?.decode()?; + let (native_width, native_height) = document.dimensions(); + let handle = Self::create_image_handle_from_image(&document); + + Ok(Self { + document, + native_width, + native_height, + transform: TransformState::default(), + handle, + fine_rotation_angle: 0.0, + interpolation_quality: InterpolationQuality::default(), + }) + } + + /// Returns the current pixel dimensions (width, height) after transforms. + #[must_use] + pub fn dimensions(&self) -> (u32, u32) { + self.document.dimensions() + } + + /// Get the current image handle. + #[must_use] + pub fn handle(&self) -> ImageHandle { + self.handle.clone() + } + + /// Save the current document to disk. + #[allow(dead_code)] + pub fn save(&self, path: &Path) -> image::ImageResult<()> { + self.document.save(path) + } + + /// Get the underlying `DynamicImage`. + #[must_use] + pub fn image(&self) -> &DynamicImage { + &self.document + } + + /// Get native dimensions (before transformations). + #[must_use] + pub fn native_dimensions(&self) -> (u32, u32) { + (self.native_width, self.native_height) + } + + /// Get a reference to the rendered image (for cropping from screen coordinates). + pub fn get_rendered_image(&self) -> &DynamicImage { + &self.document + } + + /// Crop the document to a specified rectangular region (in-place). + /// + /// Coordinates are in pixels relative to the current image dimensions. + /// The crop region is clamped to image bounds if it extends beyond. + /// + /// # Errors + /// + /// Returns an error if the crop region is completely outside the image bounds. + pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> { + let (img_width, img_height) = self.document.dimensions(); + + // Validate crop region + if x >= img_width || y >= img_height { + return Err(format!( + "Crop region ({}, {}) is outside image bounds ({}, {})", + x, y, img_width, img_height + )); + } + + // Clamp dimensions to image bounds + let crop_width = width.min(img_width - x); + let crop_height = height.min(img_height - y); + + if crop_width == 0 || crop_height == 0 { + return Err("Crop region has zero width or height".to_string()); + } + + // Apply crop + self.document = self.document.crop_imm(x, y, crop_width, crop_height); + + // Update native dimensions to the cropped size + self.native_width = crop_width; + self.native_height = crop_height; + + // Reset transformations since we have a new "native" image + self.transform = TransformState::default(); + self.fine_rotation_angle = 0.0; + + // Regenerate handle + self.handle = Self::create_image_handle_from_image(&self.document); + + Ok(()) + } + /// Crop the image to the specified rectangle and return as DynamicImage. + /// + /// This does NOT modify the document - it's used for exporting cropped images. + pub fn crop_to_image( + &self, + x: u32, + y: u32, + width: u32, + height: u32, + ) -> Result { + let (img_width, img_height) = self.document.dimensions(); + + // Validate crop region + if x >= img_width || y >= img_height { + return Err(format!( + "Crop rectangle out of bounds: {}x{} at ({}, {}) exceeds image size {}x{}", + width, height, x, y, img_width, img_height + )); + } + + // Clamp dimensions to image bounds + let crop_width = width.min(img_width - x); + let crop_height = height.min(img_height - y); + + if crop_width == 0 || crop_height == 0 { + return Err("Crop region has zero width or height".to_string()); + } + + let cropped = self.document.crop_imm(x, y, crop_width, crop_height); + Ok(cropped) + } + + /// Extract metadata for this raster document. + /// + /// Returns basic metadata (dimensions, format, file size) and EXIF data if available. + pub fn extract_meta( + &self, + path: &Path, + ) -> crate::domain::document::core::metadata::DocumentMeta { + use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta}; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file_path = path.to_string_lossy().to_string(); + + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + // Detect format from path + let format = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("unknown") + .to_uppercase(); + + let color_type = format!("{:?}", self.document.color()); + + let basic = BasicMeta { + file_name, + file_path, + format, + width: self.native_width, + height: self.native_height, + file_size, + color_type, + }; + + // Try to extract EXIF data + let exif = std::fs::read(path) + .ok() + .and_then(|bytes| ExifMeta::from_bytes(&bytes)); + + DocumentMeta { basic, exif } + } + + /// Resize the document to specific dimensions (for format conversion). + /// + /// This is useful for converting images to standard paper formats (A4, US Letter, etc.). + pub fn resize_to_format(&mut self, target_width: u32, target_height: u32) { + use image::imageops::FilterType; + + let filter = match self.interpolation_quality { + InterpolationQuality::Fast => FilterType::Nearest, + InterpolationQuality::Balanced => FilterType::Triangle, + InterpolationQuality::Best => FilterType::CatmullRom, + }; + + self.document = self + .document + .resize_exact(target_width, target_height, filter); + self.handle = Self::create_image_handle_from_image(&self.document); + } + + // Helper functions + fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle { + let (width, height) = img.dimensions(); + let pixels = img.to_rgba8().into_raw(); + ImageHandle::from_rgba(width, height, pixels) + } + + fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage { + use image::imageops::{rotate180, rotate270, rotate90}; + match rotation { + Rotation::None => img, + Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())), + Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())), + Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())), + } + } + + fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage { + use image::imageops::{flip_horizontal, flip_vertical}; + match direction { + FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())), + FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())), + } + } +} + +// ============================================================================ +// Trait Implementations +// ============================================================================ + +impl Renderable for RasterDocument { + fn render(&mut self, _scale: f64) -> DocResult { + // Raster images don't re-render at different scales (lossy), + // we just return the current handle. + let (width, height) = self.dimensions(); + Ok(RenderOutput { + handle: self.handle.clone(), + width, + height, + }) + } + + fn info(&self) -> DocumentInfo { + DocumentInfo { + width: self.native_width, + height: self.native_height, + format: "Raster".to_string(), + } + } +} + +impl Transformable for RasterDocument { + fn rotate(&mut self, rotation: Rotation) { + // Extract current rotation in degrees + let current_deg = match self.transform.rotation { + RotationMode::Standard(r) => r.to_degrees(), + RotationMode::Fine(_) => { + // If we have fine rotation, reset it and apply standard rotation + self.fine_rotation_angle = 0.0; + 0 + } + }; + + let new_deg = rotation.to_degrees(); + let diff_deg = (new_deg - current_deg + 360) % 360; + + if diff_deg != 0 { + let rotation_to_apply = match diff_deg { + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => unreachable!("Invalid rotation diff: {}", diff_deg), + }; + self.document = Self::apply_rotation( + std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)), + rotation_to_apply, + ); + } + + // Set to standard rotation mode + self.transform.rotation = RotationMode::Standard(rotation); + self.handle = Self::create_image_handle_from_image(&self.document); + } + + fn flip(&mut self, direction: FlipDirection) { + self.document = Self::apply_flip( + std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)), + direction, + ); + match direction { + FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h, + FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v, + } + self.handle = Self::create_image_handle_from_image(&self.document); + } + + fn transform_state(&self) -> TransformState { + self.transform + } + + fn rotate_fine(&mut self, angle_degrees: f32) { + // TODO: Re-enable when imageproc dependency is added to Cargo.toml + // For now, round to nearest 90-degree rotation + log::warn!("Fine rotation not yet implemented, rounding to nearest 90 degrees"); + + let rounded = ((angle_degrees / 90.0).round() as i16 * 90) % 360; + let rotation = match rounded { + 0 => Rotation::None, + 90 => Rotation::Cw90, + 180 => Rotation::Cw180, + 270 => Rotation::Cw270, + _ => Rotation::None, + }; + + self.rotate(rotation); + self.transform.rotation = RotationMode::Standard(rotation); + } + + fn reset_fine_rotation(&mut self) { + self.fine_rotation_angle = 0.0; + self.transform.rotation = RotationMode::Standard(Rotation::None); + } + + fn set_interpolation_quality(&mut self, quality: InterpolationQuality) { + self.interpolation_quality = quality; + } +} diff --git a/src/domain/document/types/vector.rs b/src/domain/document/types/vector.rs new file mode 100644 index 0000000..2d0c11c --- /dev/null +++ b/src/domain/document/types/vector.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/document/types/vector.rs +// +// Vector documents (SVG, etc.). + +use std::path::Path; + +/// Minimum pixmap size for SVG rendering (prevents zero-size pixmaps). +const MIN_PIXMAP_SIZE: u32 = 1; + +use image::{DynamicImage, GenericImageView, RgbaImage}; +use resvg::tiny_skia::{self, Pixmap}; +use resvg::usvg::{Options, Tree}; + +use cosmic::widget::image::Handle as ImageHandle; + +use crate::domain::document::core::document::{ + DocResult, DocumentInfo, FlipDirection, Renderable, RenderOutput, Rotation, RotationMode, + TransformState, Transformable, +}; + +/// Represents a vector document such as SVG. +pub struct VectorDocument { + /// Parsed SVG document for re-rendering at different scales. + document: Tree, + /// Native width of the SVG (from viewBox or width attribute). + native_width: u32, + /// Native height of the SVG (from viewBox or height attribute). + native_height: u32, + /// Current render scale (1.0 = native size). + current_scale: f64, + /// Accumulated transformations. + transform: TransformState, + /// Rasterized image at the current scale. + pub rendered: DynamicImage, + /// Image handle for display. + pub handle: ImageHandle, + /// Current rendered width. + pub width: u32, + /// Current rendered height. + pub height: u32, +} + +impl VectorDocument { + /// Load a vector document from disk. + pub fn open(path: &Path) -> anyhow::Result { + let raw_data = std::fs::read_to_string(path)?; + + // Parse SVG with default options. + let options = Options::default(); + let document = Tree::from_str(&raw_data, &options)?; + + // Get native size from the parsed document. + let size = document.size(); + let native_width = size.width().ceil() as u32; + let native_height = size.height().ceil() as u32; + + let transform = TransformState::default(); + + // Render at native scale (1.0). + let (rendered, width, height) = + render_document(&document, native_width, native_height, 1.0, transform)?; + let handle = Self::create_image_handle_from_image(&rendered); + + Ok(Self { + document, + native_width, + native_height, + current_scale: 1.0, + transform, + rendered, + handle, + width, + height, + }) + } + + /// Returns the dimensions of the rasterized representation. + #[must_use] + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + + /// Get the current image handle. + #[must_use] + pub fn handle(&self) -> ImageHandle { + self.handle.clone() + } + + /// Get native dimensions (before transformations). + #[must_use] + pub fn native_dimensions(&self) -> (u32, u32) { + (self.native_width, self.native_height) + } + + /// Extract metadata for this vector document. + pub fn extract_meta( + &self, + path: &Path, + ) -> crate::domain::document::core::metadata::DocumentMeta { + use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta}; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file_path = path.to_string_lossy().to_string(); + let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + let basic = BasicMeta { + file_name, + file_path, + format: "SVG".to_string(), + width: self.native_width, + height: self.native_height, + file_size, + color_type: "Vector".to_string(), + }; + + DocumentMeta { basic, exif: None } + } + + /// Crop the document to the specified rectangle. + /// Works on rendered output (raster). + pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> { + let (img_width, img_height) = self.rendered.dimensions(); + + // Validate crop region + if x >= img_width || y >= img_height { + return Err(format!( + "Crop region ({}, {}) is outside rendered bounds ({}, {})", + x, y, img_width, img_height + )); + } + + // Clamp dimensions + let crop_width = width.min(img_width - x); + let crop_height = height.min(img_height - y); + + if crop_width == 0 || crop_height == 0 { + return Err("Crop region has zero width or height".to_string()); + } + + // Crop rendered image + self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height); + self.width = crop_width; + self.height = crop_height; + + // Update handle + self.handle = Self::create_image_handle_from_image(&self.rendered); + + Ok(()) + } + + /// Re-render the SVG at a new scale, preserving transformations. + /// Returns true if re-rendering occurred. + #[allow(dead_code)] + pub fn render_at_scale(&mut self, scale: f64) -> bool { + // Skip if scale hasn't changed + if (self.current_scale - scale).abs() < f64::EPSILON { + return false; + } + + match render_document( + &self.document, + self.native_width, + self.native_height, + scale, + self.transform, + ) { + Ok((rendered, width, height)) => { + self.current_scale = scale; + self.rendered = rendered; + self.width = width; + self.height = height; + self.handle = Self::create_image_handle_from_image(&self.rendered); + true + } + Err(e) => { + log::error!("Failed to re-render SVG at scale {scale}: {e}"); + false + } + } + } + + /// Re-render with current scale and transform. + fn rerender(&mut self) { + if let Ok((rendered, width, height)) = render_document( + &self.document, + self.native_width, + self.native_height, + self.current_scale, + self.transform, + ) { + self.rendered = rendered; + self.width = width; + self.height = height; + self.handle = Self::create_image_handle_from_image(&self.rendered); + } + } + + // Helper function + fn create_image_handle_from_image(img: &image::DynamicImage) -> ImageHandle { + let (width, height) = img.dimensions(); + let pixels = img.to_rgba8().into_raw(); + ImageHandle::from_rgba(width, height, pixels) + } +} + +// ============================================================================ +// Trait Implementations +// ============================================================================ + +impl Renderable for VectorDocument { + fn render(&mut self, scale: f64) -> DocResult { + self.render_at_scale(scale); + Ok(RenderOutput { + handle: self.handle.clone(), + width: self.width, + height: self.height, + }) + } + + fn info(&self) -> DocumentInfo { + DocumentInfo { + width: self.native_width, + height: self.native_height, + format: "SVG".to_string(), + } + } +} + +impl Transformable for VectorDocument { + fn rotate(&mut self, rotation: Rotation) { + self.transform.rotation = RotationMode::Standard(rotation); + self.rerender(); + } + + fn flip(&mut self, direction: FlipDirection) { + match direction { + FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h, + FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v, + } + self.rerender(); + } + + fn transform_state(&self) -> TransformState { + self.transform + } +} + +/// Render the SVG document at a given scale with transformations. +fn render_document( + document: &Tree, + native_width: u32, + native_height: u32, + scale: f64, + transform: TransformState, +) -> anyhow::Result<(DynamicImage, u32, u32)> { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let width = ((f64::from(native_width) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let height = ((f64::from(native_height) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE); + + let mut pixmap = + Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?; + + #[allow(clippy::cast_possible_truncation)] + let scale_f32 = scale as f32; + let ts = tiny_skia::Transform::from_scale(scale_f32, scale_f32); + resvg::render(document, ts, &mut pixmap.as_mut()); + + let mut image = pixmap_to_dynamic_image(&pixmap); + + // Apply flip transformations using shared utilities + if transform.flip_h { + image = crate::domain::document::operations::transform::apply_flip( + image, + FlipDirection::Horizontal, + ); + } + if transform.flip_v { + image = crate::domain::document::operations::transform::apply_flip( + image, + FlipDirection::Vertical, + ); + } + + // Apply rotation using shared utilities + image = match transform.rotation { + RotationMode::Standard(rotation) => { + crate::domain::document::operations::transform::apply_rotation(image, rotation) + } + RotationMode::Fine(_) => { + // For vector documents, fine rotation is handled differently + // For now, we just render without rotation + // TODO: Implement fine rotation support for vector documents + image + } + }; + + let final_width = image.width(); + let final_height = image.height(); + + Ok((image, final_width, final_height)) +} + +/// Convert a `tiny_skia` Pixmap to a `DynamicImage`. +fn pixmap_to_dynamic_image(pixmap: &Pixmap) -> DynamicImage { + let width = pixmap.width(); + let height = pixmap.height(); + + // tiny_skia uses premultiplied alpha, we need to unpremultiply for image crate + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for pixel in pixmap.pixels() { + let a = pixel.alpha(); + if a == 0 { + pixels.extend_from_slice(&[0, 0, 0, 0]); + } else { + // Unpremultiply: color = premultiplied_color * 255 / alpha + let r = (u16::from(pixel.red()) * 255 / u16::from(a)) as u8; + let g = (u16::from(pixel.green()) * 255 / u16::from(a)) as u8; + let b = (u16::from(pixel.blue()) * 255 / u16::from(a)) as u8; + pixels.extend_from_slice(&[r, g, b, a]); + } + } + + let rgba_image = RgbaImage::from_raw(width, height, pixels) + .expect("Failed to create RgbaImage from pixmap data"); + + DynamicImage::ImageRgba8(rgba_image) +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..c4ad94c --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/domain/mod.rs +// +// Domain layer: business logic, document abstractions, and viewport management. + +pub mod document; + +// Re-export core document types +#[allow(unused_imports)] +pub use document::core::content::DocumentContent; +#[allow(unused_imports)] +pub use document::core::metadata::DocumentMeta; + +// Note: Viewport and error handling were removed to reduce code bloat. +// - Viewport: Was 865 lines of unused code (planned feature) +// - Domain Errors: Not integrated, anyhow::Result is sufficient +// +// Low-level pixel operations (apply_rotation, apply_flip, crop_image) +// are internal helpers used only by document type implementations. +// Use high-level operations for all application and UI code. diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 402a70a..0000000 --- a/src/error.rs +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/error.rs -// -// Central error and result types for Noctua. - -use std::fmt; - -pub type Result = std::result::Result; - -#[derive(Debug)] -pub enum Error { - /// A requested file or resource was not found. - NotFound(String), - /// The file format or operation is not supported. - Unsupported(String), - /// A session could not be loaded or saved. - Session(String), - /// An underlying I/O error. - Io(std::io::Error), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::NotFound(msg) => write!(f, "Not found: {msg}"), - Error::Unsupported(msg) => write!(f, "Unsupported: {msg}"), - Error::Session(msg) => write!(f, "Session error: {msg}"), - Error::Io(err) => write!(f, "I/O error: {err}"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Io(err) => Some(err), - _ => None, - } - } -} - -impl From for Error { - fn from(err: std::io::Error) -> Self { - Error::Io(err) - } -} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..08c2829 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/i18n.rs +// +// Internationalization (i18n) support. + +use i18n_embed::{ + DefaultLocalizer, LanguageLoader, Localizer, + fluent::{FluentLanguageLoader, fluent_language_loader}, + unic_langid::LanguageIdentifier, +}; +use rust_embed::RustEmbed; +use std::sync::LazyLock; + +/// Applies the requested language(s) to requested translations from the `fl!()` macro. +pub fn init(requested_languages: &[LanguageIdentifier]) { + if let Err(why) = localizer().select(requested_languages) { + println!("error while loading fluent localizations: {why}"); + } +} + +// Get the `Localizer` to be used for localizing this library. +#[must_use] +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: LazyLock = LazyLock::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + +/// Request a localized string by ID from the i18n/ directory. +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id) + }}; + + ($message_id:literal, $($name:ident: $value:expr),*) => {{ + let mut args = std::collections::HashMap::new(); + $( + args.insert(stringify!($name), $value.to_string()); + )* + i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, args) + }}; +} diff --git a/src/infrastructure/cache/mod.rs b/src/infrastructure/cache/mod.rs new file mode 100644 index 0000000..e63c1db --- /dev/null +++ b/src/infrastructure/cache/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/cache/mod.rs +// +// Cache infrastructure: thumbnail and document caching. + +pub mod thumbnail_cache; + +// Re-export ThumbnailCache +pub use thumbnail_cache::ThumbnailCache; diff --git a/src/infrastructure/cache/thumbnail_cache.rs b/src/infrastructure/cache/thumbnail_cache.rs new file mode 100644 index 0000000..5102c54 --- /dev/null +++ b/src/infrastructure/cache/thumbnail_cache.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/cache/thumbnail_cache.rs +// +// Disk cache for document thumbnails stored in ~/.cache/noctua/ + +use std::fs; +use std::io::BufWriter; +use std::path::{Path, PathBuf}; + +use image::DynamicImage; +use sha2::{Digest, Sha256}; + +use cosmic::widget::image::Handle as ImageHandle; + +use crate::domain::document::operations::render::create_image_handle_from_image; + +/// Cache directory name under ~/.cache/ for thumbnail storage. +const CACHE_DIR: &str = "noctua"; + +/// File extension for cached thumbnails. +const THUMBNAIL_EXT: &str = "png"; + +/// Thumbnail cache manager for disk-based caching. +pub struct ThumbnailCache; + +impl ThumbnailCache { + /// Load a thumbnail from disk cache. + /// Returns None if not cached or cache is invalid. + pub fn load(file_path: &Path, page: usize) -> Option { + let cache_path = Self::thumbnail_path(file_path, page)?; + + log::debug!("Cache lookup: file={}, page={}", file_path.display(), page); + + if !cache_path.exists() { + log::debug!( + "Thumbnail not found in cache: file={} page={}", + file_path.display(), + page + ); + return None; + } + + let img = image::open(&cache_path).ok()?; + log::debug!( + "Thumbnail loaded from cache: file={} page={}", + file_path.display(), + page + ); + Some(create_image_handle_from_image(&img)) + } + + /// Save a thumbnail to disk cache. + pub fn save(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> { + let dir = Self::ensure_cache_dir()?; + let key = Self::cache_key(file_path, page)?; + let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}")); + + log::debug!( + "Saving thumbnail to cache: file={}, page={}, path={}", + file_path.display(), + page, + cache_path.display() + ); + + let file = fs::File::create(&cache_path).ok()?; + let writer = BufWriter::new(file); + + let res = image.write_to( + &mut std::io::BufWriter::new(writer), + image::ImageFormat::Png, + ); + match res { + Ok(()) => { + log::debug!( + "Thumbnail cached successfully: file={} page={}", + file_path.display(), + page + ); + Some(()) + } + Err(e) => { + log::warn!( + "Failed to cache thumbnail: file={} page={}: {}", + file_path.display(), + page, + e + ); + None + } + } + } + + /// Clear all cached thumbnails. + pub fn clear_cache() -> std::io::Result<()> { + if let Some(dir) = Self::cache_dir() + && dir.exists() + { + fs::remove_dir_all(&dir)?; + } + Ok(()) + } + + /// Check if a thumbnail exists in cache. + #[allow(dead_code)] + pub fn has(file_path: &Path, page: usize) -> bool { + Self::thumbnail_path(file_path, page).is_some_and(|p| p.exists()) + } + + // Private helper methods + + /// Get the cache directory path (~/.cache/noctua/). + fn cache_dir() -> Option { + dirs::cache_dir().map(|p| p.join(CACHE_DIR)) + } + + /// Ensure the cache directory exists. + fn ensure_cache_dir() -> Option { + let dir = Self::cache_dir()?; + fs::create_dir_all(&dir).ok()?; + Some(dir) + } + + /// Generate a cache key from file path, modification time, and page number. + /// Format: sha256(path + mtime + page) + fn cache_key(file_path: &Path, page: usize) -> Option { + let metadata = fs::metadata(file_path).ok()?; + let mtime = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + + let mut hasher = Sha256::new(); + hasher.update(file_path.to_string_lossy().as_bytes()); + hasher.update(mtime.to_le_bytes()); + hasher.update(page.to_le_bytes()); + + let hash = hasher.finalize(); + Some(format!("{hash:x}")) + } + + /// Get the full path for a cached thumbnail. + fn thumbnail_path(file_path: &Path, page: usize) -> Option { + let dir = Self::cache_dir()?; + let key = Self::cache_key(file_path, page)?; + Some(dir.join(format!("{key}.{THUMBNAIL_EXT}"))) + } +} diff --git a/src/infrastructure/filesystem/file_ops.rs b/src/infrastructure/filesystem/file_ops.rs new file mode 100644 index 0000000..4fce095 --- /dev/null +++ b/src/infrastructure/filesystem/file_ops.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/filesystem/file_ops.rs +// +// File system operations for document handling. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::anyhow; + +use crate::domain::document::core::content::{DocumentContent, DocumentKind}; + +use crate::domain::document::types::raster::RasterDocument; +#[cfg(feature = "vector")] +use crate::domain::document::types::vector::VectorDocument; +#[cfg(feature = "portable")] +use crate::domain::document::types::portable::PortableDocument; + +/// Open a document from a file path and dispatch to the correct type. +/// +/// Raster formats are delegated to the `image` crate, which decides +/// based on enabled codecs (e.g. default-formats). +pub fn open_document(path: &Path) -> anyhow::Result { + let kind = DocumentKind::from_path(path) + .ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?; + + let content = match kind { + DocumentKind::Raster => { + let raster = RasterDocument::open(path)?; + DocumentContent::Raster(raster) + } + #[cfg(feature = "vector")] + DocumentKind::Vector => { + let vector = VectorDocument::open(path)?; + DocumentContent::Vector(vector) + } + #[cfg(feature = "portable")] + DocumentKind::Portable => { + let portable = PortableDocument::open(path)?; + DocumentContent::Portable(portable) + } + #[cfg(not(any(feature = "vector", feature = "portable")))] + _ => return Err(anyhow!("No document features enabled")), + }; + + Ok(content) +} + +/// Collect all supported document files from a directory, sorted alphabetically. +/// +/// This scans the directory and returns a list of files that are recognized as +/// supported document types (images, PDFs, SVGs, etc.). +pub fn collect_supported_files(dir: &Path) -> Vec { + let mut entries: Vec = Vec::new(); + + if let Ok(read_dir) = fs::read_dir(dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + + // Only keep regular files that are recognized as supported documents. + if path.is_file() && DocumentKind::from_path(&path).is_some() { + entries.push(path); + } + } + } + + entries.sort(); + entries +} + +// --------------------------------------------------------------------------- +// File metadata helpers +// --------------------------------------------------------------------------- + +/// Retrieve the file size in bytes. Returns 0 if the file cannot be accessed. +pub fn file_size(path: &Path) -> u64 { + fs::metadata(path).map(|m| m.len()).unwrap_or(0) +} + +/// Read raw bytes from a file for metadata extraction (e.g., EXIF). +/// Returns None if the file cannot be read. +pub fn read_file_bytes(path: &Path) -> Option> { + fs::read(path).ok() +} + +// --------------------------------------------------------------------------- +// DEPRECATED FUNCTIONS +// --------------------------------------------------------------------------- +// The following functions have been replaced by DocumentManager and are +// commented out to avoid AppModel dependencies. +// +// Instead of using these functions directly, use: +// - DocumentManager::open_document() for opening files +// - DocumentManager::next_document() / previous_document() for navigation +// - Application commands for operations like crop, save, etc. +// --------------------------------------------------------------------------- + +/* +/// Open the initial path passed on the command line. +/// +/// DEPRECATED: Use DocumentManager::open_document() instead. +pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) { + if path.is_dir() { + open_from_directory(model, path); + } else { + open_single_file(model, path); + } +} + +/// Open the first supported document from the given directory. +/// +/// DEPRECATED: Use DocumentManager::open_document() instead. +pub fn open_from_directory(model: &mut AppModel, dir: &Path) { + let entries = collect_supported_files(dir); + + if entries.is_empty() { + model.set_error(format!( + "No supported documents found in directory: {}", + dir.display() + )); + return; + } + + let first = entries[0].clone(); + model.folder_entries = entries; + model.current_index = Some(0); + + load_document_into_model(model, &first); +} + +/// Open a single file. +/// +/// DEPRECATED: Use DocumentManager::open_document() instead. +pub fn open_single_file(model: &mut AppModel, path: &Path) { + load_document_into_model(model, path); + + if model.document.is_some() + && let Some(parent) = path.parent() + { + refresh_folder_entries(model, parent, path); + } +} + +/// Load a document into the model. +/// +/// DEPRECATED: Use DocumentManager methods instead. +fn load_document_into_model(model: &mut AppModel, path: &Path) { + // Implementation omitted - use DocumentManager instead +} + +/// Refresh folder entries. +/// +/// DEPRECATED: DocumentManager handles this automatically. +pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) { + // Implementation omitted - use DocumentManager instead +} + +/// Navigate to the next document. +/// +/// DEPRECATED: Use DocumentManager::next_document() instead. +pub fn navigate_next(model: &mut AppModel) { + // Implementation omitted - use DocumentManager instead +} + +/// Navigate to the previous document. +/// +/// DEPRECATED: Use DocumentManager::previous_document() instead. +pub fn navigate_prev(model: &mut AppModel) { + // Implementation omitted - use DocumentManager instead +} + +/// Apply crop operation. +/// +/// DEPRECATED: Use CropDocumentCommand instead. +pub fn apply_crop( + crop_selection: &CropSelection, + doc: &DocumentContent, + current_path: &Path, + canvas_size: cosmic::iced::Size, + image_size: cosmic::iced::Size, + scale: f32, + pan_x: f32, + pan_y: f32, + view_mode: &ViewMode, +) -> Result { + // Implementation omitted - use CropDocumentCommand instead + Err("Deprecated function - use CropDocumentCommand".to_string()) +} +*/ diff --git a/src/infrastructure/filesystem/mod.rs b/src/infrastructure/filesystem/mod.rs new file mode 100644 index 0000000..b8a138c --- /dev/null +++ b/src/infrastructure/filesystem/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/filesystem/mod.rs +// +// Filesystem operations: file I/O, folder scanning, and file watching. + +pub mod file_ops; + +// TODO: Re-implement these helpers without UI dependencies +// pub use file_ops::{file_size, read_file_bytes}; diff --git a/src/infrastructure/loaders/document_loader.rs b/src/infrastructure/loaders/document_loader.rs new file mode 100644 index 0000000..fb845f5 --- /dev/null +++ b/src/infrastructure/loaders/document_loader.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/loaders/document_loader.rs +// +// Document loader trait and factory for loading documents from files. + +use std::path::Path; + +use crate::domain::document::core::content::{DocumentContent, DocumentKind}; +use crate::domain::document::core::document::DocResult; + +use super::raster_loader::RasterLoader; +#[cfg(feature = "vector")] +use super::svg_loader::SvgLoader; +#[cfg(feature = "portable")] +use super::pdf_loader::PdfLoader; + +/// Trait for loading documents from files. +/// +/// Implementations handle specific document formats (raster, vector, portable). +pub trait DocumentLoader { + /// Load a document from a file path. + fn load(&self, path: &Path) -> DocResult; + + /// Check if this loader supports the given file. + fn supports(&self, path: &Path) -> bool; +} + +/// Document loader factory. +/// +/// Detects the document format and delegates to the appropriate loader. +pub struct DocumentLoaderFactory; + +impl DocumentLoaderFactory { + /// Create a new document loader factory. + #[must_use] + pub fn new() -> Self { + Self + } + + /// Load a document from a file, automatically detecting the format. + /// + /// # Errors + /// + /// Returns an error if: + /// - The file format is not supported + /// - The file cannot be read + /// - The document is malformed + pub fn load(&self, path: &Path) -> DocResult { + let kind = DocumentKind::from_path(path).ok_or_else(|| { + anyhow::anyhow!( + "Unsupported file format: {}", + path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("unknown") + ) + })?; + + match kind { + DocumentKind::Raster => { + let loader = RasterLoader; + loader.load(path) + } + #[cfg(feature = "vector")] + DocumentKind::Vector => { + let loader = SvgLoader; + loader.load(path) + } + #[cfg(feature = "portable")] + DocumentKind::Portable => { + let loader = PdfLoader; + loader.load(path) + } + #[cfg(not(any(feature = "vector", feature = "portable")))] + _ => Err(anyhow::anyhow!( + "No document loaders available (check feature flags)" + )), + } + } + + /// Detect the document kind from a file path. + #[must_use] + pub fn detect_kind(&self, path: &Path) -> Option { + DocumentKind::from_path(path) + } + + /// Check if a file is supported by any loader. + #[must_use] + pub fn is_supported(&self, path: &Path) -> bool { + DocumentKind::from_path(path).is_some() + } +} + +impl Default for DocumentLoaderFactory { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_factory_creation() { + let factory = DocumentLoaderFactory::new(); + assert!(std::ptr::eq(&factory, &factory)); // Just a dummy test + } + + #[test] + fn test_detect_kind() { + let factory = DocumentLoaderFactory::new(); + + assert_eq!( + factory.detect_kind(Path::new("test.png")), + Some(DocumentKind::Raster) + ); + assert_eq!( + factory.detect_kind(Path::new("test.jpg")), + Some(DocumentKind::Raster) + ); + + #[cfg(feature = "vector")] + { + assert_eq!( + factory.detect_kind(Path::new("test.svg")), + Some(DocumentKind::Vector) + ); + } + + #[cfg(feature = "portable")] + { + assert_eq!( + factory.detect_kind(Path::new("test.pdf")), + Some(DocumentKind::Portable) + ); + } + + assert_eq!(factory.detect_kind(Path::new("test.txt")), None); + } + + #[test] + fn test_is_supported() { + let factory = DocumentLoaderFactory::new(); + + assert!(factory.is_supported(Path::new("test.png"))); + assert!(!factory.is_supported(Path::new("test.txt"))); + } +} diff --git a/src/infrastructure/loaders/mod.rs b/src/infrastructure/loaders/mod.rs new file mode 100644 index 0000000..7ebc923 --- /dev/null +++ b/src/infrastructure/loaders/mod.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/loaders/mod.rs +// +// Document loaders for various formats. + +pub mod document_loader; + +pub mod raster_loader; +#[cfg(feature = "vector")] +pub mod svg_loader; +#[cfg(feature = "portable")] +pub mod pdf_loader; + +// Re-export main types +pub use document_loader::DocumentLoaderFactory; diff --git a/src/infrastructure/loaders/pdf_loader.rs b/src/infrastructure/loaders/pdf_loader.rs new file mode 100644 index 0000000..7589820 --- /dev/null +++ b/src/infrastructure/loaders/pdf_loader.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/loaders/pdf_loader.rs +// +// Loader for PDF portable documents. + +use std::path::Path; + +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::types::portable::PortableDocument; +use crate::infrastructure::loaders::document_loader::DocumentLoader; + +/// Loader for PDF portable documents. +pub struct PdfLoader; + +impl DocumentLoader for PdfLoader { + fn load(&self, path: &Path) -> DocResult { + let document = PortableDocument::open(path) + .map_err(|e| anyhow::anyhow!("Failed to load PDF document: {e}"))?; + + Ok(DocumentContent::Portable(document)) + } + + fn supports(&self, path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + ext_str == "pdf" + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports() { + let loader = PdfLoader; + + assert!(loader.supports(Path::new("test.pdf"))); + assert!(loader.supports(Path::new("test.PDF"))); + assert!(loader.supports(Path::new("document.pdf"))); + assert!(!loader.supports(Path::new("test.png"))); + assert!(!loader.supports(Path::new("test.svg"))); + assert!(!loader.supports(Path::new("test.jpg"))); + assert!(!loader.supports(Path::new("test.txt"))); + } +} diff --git a/src/infrastructure/loaders/raster_loader.rs b/src/infrastructure/loaders/raster_loader.rs new file mode 100644 index 0000000..07af388 --- /dev/null +++ b/src/infrastructure/loaders/raster_loader.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/loaders/raster_loader.rs +// +// Loader for raster image documents (PNG, JPEG, WebP, etc.). + +use std::path::Path; + +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::types::raster::RasterDocument; +use crate::infrastructure::loaders::document_loader::DocumentLoader; + +/// Loader for raster image documents. +pub struct RasterLoader; + +impl DocumentLoader for RasterLoader { + fn load(&self, path: &Path) -> DocResult { + let document = RasterDocument::open(path) + .map_err(|e| anyhow::anyhow!("Failed to load raster document: {e}"))?; + + Ok(DocumentContent::Raster(document)) + } + + fn supports(&self, path: &Path) -> bool { + use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat; + + ImageFormat::from_path(path).is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports() { + let loader = RasterLoader; + + assert!(loader.supports(Path::new("test.png"))); + assert!(loader.supports(Path::new("test.jpg"))); + assert!(loader.supports(Path::new("test.jpeg"))); + assert!(loader.supports(Path::new("test.webp"))); + assert!(!loader.supports(Path::new("test.pdf"))); + assert!(!loader.supports(Path::new("test.svg"))); + } +} diff --git a/src/infrastructure/loaders/svg_loader.rs b/src/infrastructure/loaders/svg_loader.rs new file mode 100644 index 0000000..042887b --- /dev/null +++ b/src/infrastructure/loaders/svg_loader.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/loaders/svg_loader.rs +// +// Loader for SVG vector documents. + +use std::path::Path; + +use crate::domain::document::core::content::DocumentContent; +use crate::domain::document::core::document::DocResult; +use crate::domain::document::types::vector::VectorDocument; +use crate::infrastructure::loaders::document_loader::DocumentLoader; + +/// Loader for SVG vector documents. +pub struct SvgLoader; + +impl DocumentLoader for SvgLoader { + fn load(&self, path: &Path) -> DocResult { + let document = VectorDocument::open(path) + .map_err(|e| anyhow::anyhow!("Failed to load SVG document: {e}"))?; + + Ok(DocumentContent::Vector(document)) + } + + fn supports(&self, path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + ext_str == "svg" || ext_str == "svgz" + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports() { + let loader = SvgLoader; + + assert!(loader.supports(Path::new("test.svg"))); + assert!(loader.supports(Path::new("test.SVG"))); + assert!(loader.supports(Path::new("test.svgz"))); + assert!(!loader.supports(Path::new("test.png"))); + assert!(!loader.supports(Path::new("test.pdf"))); + assert!(!loader.supports(Path::new("test.jpg"))); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..478c9c3 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/mod.rs +// +// Infrastructure layer: external dependencies, loaders, cache, and filesystem. + +pub mod cache; +pub mod filesystem; +pub mod loaders; +pub mod system; + +// Re-export loader factory +#[allow(unused_imports)] +pub use loaders::DocumentLoaderFactory; diff --git a/src/infrastructure/system/mod.rs b/src/infrastructure/system/mod.rs new file mode 100644 index 0000000..7a1068a --- /dev/null +++ b/src/infrastructure/system/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/system/mod.rs +// +// System integration: wallpaper, desktop environment utilities. + +pub mod wallpaper; + +// Re-export wallpaper function +pub use wallpaper::set_as_wallpaper; diff --git a/src/infrastructure/system/wallpaper.rs b/src/infrastructure/system/wallpaper.rs new file mode 100644 index 0000000..6ac377f --- /dev/null +++ b/src/infrastructure/system/wallpaper.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/infrastructure/system/wallpaper.rs +// +// Set desktop wallpaper across different desktop environments. + +use std::path::Path; + +/// Set an image as desktop wallpaper using multiple fallback methods. +/// +/// Attempts the following methods in order: +/// 1. COSMIC Desktop (direct config file modification) +/// 2. wallpaper crate (KDE, XFCE, Windows, macOS) +/// 3. gsettings (GNOME) +/// 4. feh (tiling window managers) +pub fn set_as_wallpaper(path: &Path) { + // Canonicalize to absolute path. + let abs_path = match path.canonicalize() { + Ok(p) => p, + Err(e) => { + log::error!("Failed to canonicalize path {}: {}", path.display(), e); + return; + } + }; + + let Some(path_str) = abs_path.to_str() else { + log::error!("Invalid UTF-8 in path: {}", abs_path.display()); + return; + }; + + log::info!("Attempting to set wallpaper: {path_str}"); + + // Method 1: Try COSMIC Desktop (direct config file modification). + if try_cosmic_wallpaper(path_str) { + return; + } + + // Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS). + if try_wallpaper_crate(path_str) { + return; + } + + // Method 3: Try GNOME via gsettings. + if try_gsettings_wallpaper(path_str) { + return; + } + + // Method 4: Try feh (common on tiling WMs like i3, sway). + if try_feh_wallpaper(path_str) { + return; + } + + log::error!("All methods failed to set wallpaper"); +} + +/// Try setting wallpaper via COSMIC config file. +fn try_cosmic_wallpaper(path_str: &str) -> bool { + let Some(home) = dirs::home_dir() else { + return false; + }; + + let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all"); + if !cosmic_config.exists() { + return false; + } + + let config_content = format!( + r#"( + output: "all", + source: Path("{path_str}"), + filter_by_theme: true, + rotation_frequency: 300, + filter_method: Lanczos, + scaling_mode: Zoom, + sampling_method: Alphanumeric, +)"# + ); + + match std::fs::write(&cosmic_config, config_content) { + Ok(()) => { + log::info!("Wallpaper set via COSMIC config"); + true + } + Err(e) => { + log::warn!("Failed to write COSMIC config: {e}"); + false + } + } +} + +/// Try setting wallpaper via wallpaper crate. +fn try_wallpaper_crate(path_str: &str) -> bool { + match wallpaper::set_from_path(path_str) { + Ok(()) => { + log::info!("Wallpaper set via wallpaper crate"); + true + } + Err(e) => { + log::warn!("wallpaper crate failed: {e}"); + false + } + } +} + +/// Try setting wallpaper via GNOME gsettings. +fn try_gsettings_wallpaper(path_str: &str) -> bool { + let uri = format!("file://{path_str}"); + + let output = match std::process::Command::new("gsettings") + .args(["set", "org.gnome.desktop.background", "picture-uri", &uri]) + .output() + { + Ok(o) => o, + Err(e) => { + log::warn!("gsettings command failed: {e}"); + return false; + } + }; + + if !output.status.success() { + log::warn!( + "gsettings failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return false; + } + + log::info!("Wallpaper set via gsettings"); + + // Also set dark mode wallpaper. + let _ = std::process::Command::new("gsettings") + .args([ + "set", + "org.gnome.desktop.background", + "picture-uri-dark", + &uri, + ]) + .output(); + + true +} + +/// Try setting wallpaper via feh. +fn try_feh_wallpaper(path_str: &str) -> bool { + let Ok(output) = std::process::Command::new("feh") + .args(["--bg-scale", path_str]) + .output() + else { + log::warn!("feh not available"); + return false; + }; + + if output.status.success() { + log::info!("Wallpaper set via feh"); + true + } else { + log::warn!("feh failed"); + false + } +} diff --git a/src/main.rs b/src/main.rs index 0478017..aa1164a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,734 +1,43 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/main.rs // -// Minimal COSMIC viewer entry point for images and PDFs. +// Application entry point. +mod ui; +mod application; +mod domain; +mod infrastructure; + +mod config; +mod i18n; + +use anyhow::Result; use clap::Parser; -use cosmic::app::{Core, Settings, Task}; -use cosmic::iced::{ - event, - keyboard::{self, key::Named, Key}, - mouse, Alignment, ContentFit, Event, Length, Limits, Subscription, -}; -use cosmic::{executor, theme, widget, Application, Element}; -use std::collections::hash_map::DefaultHasher; -use std::ffi::OsStr; -use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; -use std::process::Command; +use cosmic::app::Settings; +use crate::ui::NoctuaApp; -#[derive(Parser, Debug)] -#[command( - name = "noctua", - version, - about = "A document and image viewer for COSMIC" -)] -struct Args { - /// Files to open. +#[derive(Parser, Debug, Clone)] +#[command(version, about)] +pub struct Args { + /// File to open on startup #[arg(value_name = "FILE")] - files: Vec, + pub file: Option, - /// Compatibility with the previous CLI harness. - #[arg(long = "open-document", value_name = "PATH")] - document: Option, - - /// Open all supported documents in a directory. - #[arg(long = "open-directory", value_name = "PATH")] - directory: Option, + /// UI language (e.g. "en", "de") + #[arg(short, long, default_value = "en")] + pub language: String, } -#[derive(Clone, Debug, Eq, PartialEq)] -enum DocumentKind { - Raster, - Svg, - Pdf, -} +fn main() -> Result<()> { + // Get the system's preferred languages. + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); -#[derive(Clone, Debug)] -struct Document { - path: PathBuf, - kind: DocumentKind, - page: usize, - page_count: Option, - rendered_page: Option, - rendering_page: Option, - prefetching_pages: Vec, - error: Option, -} - -impl Document { - fn new(path: PathBuf, kind: DocumentKind) -> Self { - let page_count = (kind == DocumentKind::Pdf) - .then(|| pdf_page_count(&path)) - .flatten(); - - Self { - path, - kind, - page: 0, - page_count, - rendered_page: None, - rendering_page: None, - prefetching_pages: Vec::new(), - error: None, - } - } - - fn name(&self) -> String { - self.path - .file_name() - .and_then(OsStr::to_str) - .unwrap_or("Noctua") - .to_string() - } -} - -#[derive(Clone, Debug)] -struct Flags { - documents: Vec, - initial_index: usize, -} - -#[derive(Debug, Clone)] -enum Message { - PreviousDocument, - NextDocument, - PreviousPage, - NextPage, - ZoomIn, - ZoomOut, - ResetZoom, - ModifiersChanged(keyboard::Modifiers), - Scroll(f32), - PdfRendered { - path: PathBuf, - page: usize, - result: Result, - }, - PdfPrefetched { - path: PathBuf, - page: usize, - result: Result, - }, -} - -struct NoctuaApp { - core: Core, - documents: Vec, - current: usize, - zoom: f32, - modifiers: keyboard::Modifiers, -} - -fn main() -> cosmic::iced::Result { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + // Enable localizations to be applied. + i18n::init(&requested_languages); + env_logger::init(); let args = Args::parse(); - let flags = Flags::from_args(args); - let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0)); - - cosmic::app::run::(settings, flags) -} - -impl Flags { - fn from_args(args: Args) -> Self { - let mut requested = args.files; - - if let Some(path) = args.document { - requested.push(path); - } - - if let Some(path) = args.directory { - requested.push(path); - } - - let (documents, initial_index) = collect_documents(&requested); - - Self { - documents, - initial_index, - } - } -} - -impl Application for NoctuaApp { - type Executor = executor::Default; - type Flags = Flags; - type Message = Message; - - const APP_ID: &'static str = "org.codeberg.wfx.Noctua"; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - fn init(mut core: Core, flags: Self::Flags) -> (Self, Task) { - core.window.content_container = false; - - let current = flags - .initial_index - .min(flags.documents.len().saturating_sub(1)); - - let mut app = Self { - core, - documents: flags.documents, - current, - zoom: 1.0, - modifiers: keyboard::Modifiers::default(), - }; - - app.refresh_title(); - let task = app.ensure_current_rendered(); - - (app, task) - } - - fn update(&mut self, message: Self::Message) -> Task { - match message { - Message::PreviousDocument => { - if self.current > 0 { - self.current -= 1; - self.zoom = 1.0; - } - } - Message::NextDocument => { - if self.current + 1 < self.documents.len() { - self.current += 1; - self.zoom = 1.0; - } - } - Message::PreviousPage => { - self.previous_pdf_page(); - } - Message::NextPage => { - self.next_pdf_page(); - } - Message::ZoomIn => { - self.zoom_in(); - } - Message::ZoomOut => { - self.zoom_out(); - } - Message::ResetZoom => { - self.zoom = 1.0; - } - Message::ModifiersChanged(modifiers) => { - self.modifiers = modifiers; - } - Message::Scroll(delta) => { - if self.modifiers.control() { - if delta > 0.0 { - self.zoom_in(); - } else if delta < 0.0 { - self.zoom_out(); - } - } else if delta > 0.0 { - self.previous_pdf_page(); - } else if delta < 0.0 { - self.next_pdf_page(); - } - } - Message::PdfRendered { path, page, result } => { - if let Some(document) = self - .documents - .iter_mut() - .find(|document| document.path == path) - { - if document.rendering_page == Some(page) { - document.rendering_page = None; - } - - if document.page == page { - match result { - Ok(rendered) => { - document.rendered_page = Some(rendered); - document.error = None; - } - Err(err) => { - document.rendered_page = None; - document.error = Some(err); - } - } - } - } - } - Message::PdfPrefetched { path, page, result } => { - if let Some(document) = self - .documents - .iter_mut() - .find(|document| document.path == path) - { - document - .prefetching_pages - .retain(|pending| *pending != page); - - if document.page == page && document.rendered_page.is_none() { - if let Ok(rendered) = result { - document.rendered_page = Some(rendered); - document.error = None; - } - } - } - } - } - - self.refresh_title(); - - self.ensure_current_rendered() - } - - fn subscription(&self) -> Subscription { - event::listen_raw(|event, status, _| match event { - Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - Some(Message::ModifiersChanged(modifiers)) - } - Event::Keyboard(keyboard::Event::KeyPressed { - key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft), - .. - }) if status == event::Status::Ignored => Some(Message::PreviousPage), - Event::Keyboard(keyboard::Event::KeyPressed { - key: Key::Named(Named::PageDown | Named::ArrowDown | Named::ArrowRight), - .. - }) if status == event::Status::Ignored => Some(Message::NextPage), - Event::Mouse(mouse::Event::WheelScrolled { delta }) - if status == event::Status::Ignored => - { - scroll_delta_y(delta).map(Message::Scroll) - } - _ => None, - }) - } - - fn view(&self) -> Element<'_, Self::Message> { - let content = match self.current_document() { - Some(document) => self.document_view(document), - None => empty_view("No document selected"), - }; - - widget::container(content) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .class(theme::Container::WindowBackground) - .into() - } - - fn footer(&self) -> Option> { - let Some(document) = self.current_document() else { - return None; - }; - - let has_previous_document = self.current > 0; - let has_next_document = self.current + 1 < self.documents.len(); - let has_previous_page = document.kind == DocumentKind::Pdf && document.page > 0; - let has_next_page = document.kind == DocumentKind::Pdf - && document - .page_count - .is_none_or(|page_count| document.page + 1 < page_count); - - let page_label = if document.kind == DocumentKind::Pdf { - match document.page_count { - Some(page_count) => format!("Page {} / {}", document.page + 1, page_count), - None => format!("Page {}", document.page + 1), - } - } else { - format!("{} / {}", self.current + 1, self.documents.len()) - }; - - let row = widget::row::with_capacity(9) - .align_y(Alignment::Center) - .spacing(8) - .padding(8) - .push( - widget::button::standard("Previous") - .on_press_maybe(has_previous_document.then_some(Message::PreviousDocument)), - ) - .push( - widget::button::standard("Next") - .on_press_maybe(has_next_document.then_some(Message::NextDocument)), - ) - .push(widget::space::horizontal().width(Length::Fill)) - .push( - widget::button::standard("-") - .on_press(Message::ZoomOut) - .width(Length::Fixed(36.0)), - ) - .push(widget::button::standard("100%").on_press(Message::ResetZoom)) - .push( - widget::button::standard("+") - .on_press(Message::ZoomIn) - .width(Length::Fixed(36.0)), - ) - .push(widget::space::horizontal().width(Length::Fill)) - .push(widget::text::body(page_label)) - .push( - widget::button::standard("Prev page") - .on_press_maybe(has_previous_page.then_some(Message::PreviousPage)), - ) - .push( - widget::button::standard("Next page") - .on_press_maybe(has_next_page.then_some(Message::NextPage)), - ); - - Some(row.into()) - } -} - -impl NoctuaApp { - fn zoom_in(&mut self) { - self.zoom = (self.zoom * 1.2).min(5.0); - } - - fn zoom_out(&mut self) { - self.zoom = (self.zoom / 1.2).max(0.2); - } - - fn previous_pdf_page(&mut self) { - if let Some(document) = self.current_document_mut() { - if document.kind == DocumentKind::Pdf && document.page > 0 { - document.page -= 1; - document.rendered_page = None; - document.error = None; - } - } - } - - fn next_pdf_page(&mut self) { - if let Some(document) = self.current_document_mut() { - if document.kind == DocumentKind::Pdf { - let page_count = document.page_count.unwrap_or(document.page + 2); - if document.page + 1 < page_count { - document.page += 1; - document.rendered_page = None; - document.error = None; - } - } - } - } - - fn current_document(&self) -> Option<&Document> { - self.documents.get(self.current) - } - - fn current_document_mut(&mut self) -> Option<&mut Document> { - self.documents.get_mut(self.current) - } - - fn refresh_title(&mut self) { - let title = self - .current_document() - .map_or_else(|| "Noctua".to_string(), Document::name); - - self.core.set_header_title(title); - } - - fn ensure_current_rendered(&mut self) -> Task { - let render_task = { - let Some(document) = self.current_document_mut() else { - return Task::none(); - }; - - if document.kind != DocumentKind::Pdf { - return Task::none(); - } - - if document.rendered_page.is_some() { - return self.ensure_adjacent_rendered(); - } - - if document.rendering_page == Some(document.page) { - return Task::none(); - } - - if let Some(cached) = cached_pdf_page(&document.path, document.page) { - document.rendered_page = Some(cached); - document.error = None; - document.rendering_page = None; - return self.ensure_adjacent_rendered(); - } - - document.rendering_page = Some(document.page); - document.error = None; - - render_pdf_task( - document.path.clone(), - document.page, - |path, page, result| Message::PdfRendered { path, page, result }, - ) - }; - - Task::batch([render_task, self.ensure_adjacent_rendered()]) - } - - fn ensure_adjacent_rendered(&mut self) -> Task { - let Some(document) = self.current_document_mut() else { - return Task::none(); - }; - - if document.kind != DocumentKind::Pdf || document.rendered_page.is_none() { - return Task::none(); - } - - let mut pages = Vec::with_capacity(2); - if document - .page_count - .is_none_or(|page_count| document.page + 1 < page_count) - { - pages.push(document.page + 1); - } - if document.page > 0 { - pages.push(document.page - 1); - } - - let path = document.path.clone(); - let pages = pages - .into_iter() - .filter(|page| document.rendering_page != Some(*page)) - .filter(|page| !document.prefetching_pages.contains(page)) - .filter(|page| cached_pdf_page(&path, *page).is_none()) - .collect::>(); - - document.prefetching_pages.extend(pages.iter().copied()); - - let tasks = pages - .into_iter() - .map(|page| { - render_pdf_task(path.clone(), page, |path, page, result| { - Message::PdfPrefetched { path, page, result } - }) - }) - .collect::>(); - - Task::batch(tasks) - } - - fn document_view(&self, document: &Document) -> Element<'_, Message> { - if let Some(error) = &document.error { - return empty_view(error); - } - - match document.kind { - DocumentKind::Raster => widget::image(widget::image::Handle::from_path(&document.path)) - .content_fit(ContentFit::Contain) - .width(Length::Fill) - .height(Length::Fill) - .scale(self.zoom) - .into(), - DocumentKind::Svg => widget::svg(widget::svg::Handle::from_path(&document.path)) - .content_fit(ContentFit::Contain) - .width(Length::Fill) - .height(Length::Fill) - .into(), - DocumentKind::Pdf => match &document.rendered_page { - Some(path) => widget::image(widget::image::Handle::from_path(path)) - .content_fit(ContentFit::Contain) - .width(Length::Fill) - .height(Length::Fill) - .scale(self.zoom) - .into(), - None => empty_view("Rendering PDF page..."), - }, - } - } -} - -fn empty_view<'a>(message: impl Into) -> Element<'a, Message> { - widget::column::with_capacity(3) - .align_x(Alignment::Center) - .spacing(8) - .push(widget::space::vertical()) - .push(widget::icon::from_name("image-x-generic-symbolic").size(64)) - .push(widget::text::body(message.into())) - .push(widget::space::vertical()) - .width(Length::Fill) - .height(Length::Fill) - .into() -} - -fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option { - let y = match delta { - mouse::ScrollDelta::Lines { y, .. } => y, - mouse::ScrollDelta::Pixels { y, .. } => y / 60.0, - }; - - (y.abs() > f32::EPSILON).then_some(y) -} - -fn collect_documents(requested: &[PathBuf]) -> (Vec, usize) { - if requested.is_empty() { - return (Vec::new(), 0); - } - - if requested.len() == 1 { - let path = &requested[0]; - - if path.is_dir() { - return (scan_directory(path), 0); - } - - if let Some(parent) = path.parent() { - let documents = scan_directory(parent); - if let Some(index) = documents - .iter() - .position(|document| same_path(&document.path, path)) - { - return (documents, index); - } - } - } - - let documents = requested - .iter() - .flat_map(|path| { - if path.is_dir() { - scan_directory(path) - } else { - detect_kind(path) - .map(|kind| vec![Document::new(path.clone(), kind)]) - .unwrap_or_default() - } - }) - .collect(); - - (documents, 0) -} - -fn scan_directory(dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - - let mut paths = entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_file()) - .filter_map(|path| detect_kind(&path).map(|kind| (path, kind))) - .collect::>(); - - paths.sort_by(|(left, _), (right, _)| left.cmp(right)); - - paths - .into_iter() - .map(|(path, kind)| Document::new(path, kind)) - .collect() -} - -fn detect_kind(path: &Path) -> Option { - match path - .extension() - .and_then(OsStr::to_str) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("pdf") => Some(DocumentKind::Pdf), - Some("svg") => Some(DocumentKind::Svg), - Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => { - Some(DocumentKind::Raster) - } - _ => None, - } -} - -fn same_path(left: &Path, right: &Path) -> bool { - left == right || left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref() -} - -fn pdf_page_count(path: &Path) -> Option { - let output = Command::new("pdfinfo").arg(path).output().ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.lines().find_map(|line| { - line.strip_prefix("Pages:") - .and_then(|value| value.trim().parse::().ok()) - }) -} - -fn render_pdf_task( - path: PathBuf, - page: usize, - on_done: impl FnOnce(PathBuf, usize, Result) -> Message + Send + 'static, -) -> Task { - Task::perform( - async move { - let result = render_pdf_page(&path, page); - (path, page, result) - }, - move |(path, page, result)| cosmic::Action::App(on_done(path, page, result)), - ) -} - -fn cached_pdf_page(path: &Path, page: usize) -> Option { - let output = pdf_page_output(path, page); - output.exists().then_some(output) -} - -fn pdf_page_output(path: &Path, page: usize) -> PathBuf { - std::env::temp_dir() - .join(format!( - "noctua-pdf-{}-{}", - path_hash(path), - page.saturating_add(1) - )) - .with_extension("png") -} - -fn render_pdf_page(path: &Path, page: usize) -> Result { - if let Some(cached) = cached_pdf_page(path, page) { - return Ok(cached); - } - - let output = pdf_page_output(path, page); - let prefix = output.with_extension(""); - - let status = Command::new("pdftocairo") - .arg("-png") - .arg("-singlefile") - .arg("-f") - .arg(page.saturating_add(1).to_string()) - .arg("-l") - .arg(page.saturating_add(1).to_string()) - .arg("-scale-to") - .arg("2400") - .arg(path) - .arg(&prefix) - .status() - .map_err(|err| format!("Failed to run pdftocairo: {err}"))?; - - if status.success() && output.exists() { - Ok(output) - } else { - Err(format!( - "Failed to render page {} of {}", - page.saturating_add(1), - path.display() - )) - } -} - -fn path_hash(path: &Path) -> u64 { - let mut hasher = DefaultHasher::new(); - - match path.canonicalize() { - Ok(path) => path.hash(&mut hasher), - Err(_) => path.hash(&mut hasher), - } - - if let Ok(metadata) = path.metadata() { - metadata.len().hash(&mut hasher); - if let Ok(modified) = metadata.modified() { - modified.hash(&mut hasher); - } - } - - hasher.finish() + cosmic::app::run::(Settings::default(), ui::app::Flags::Args(args)) + .map_err(|e| anyhow::anyhow!(e)) } diff --git a/src/patch.rs b/src/patch.rs deleted file mode 100644 index 2e44fcd..0000000 --- a/src/patch.rs +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/patch.rs -// -// Patch file executor for scripted workspace operations and testing. - -use crate::active_state::ActiveState; -use crate::workspace::model::{Item, ItemKind, Session, SessionType, Workspace}; -use anyhow::{bail, Context, Result}; -use serde::Deserialize; -use std::path::{Path, PathBuf}; - -/// A single step in a patch file. Each variant maps directly to a JSON key. -#[derive(Debug, Deserialize)] -pub enum PatchStep { - NewWorkspace(String), - LoadWorkspace(String), - SaveWorkspace(String), - - CreateSession { - name: String, - session_type: String, - }, - DeleteSession(String), - CopySession(String), - RenameSession { - session: String, - name: String, - }, - - AddItem { - session: String, - name: String, - kind: String, - path: String, - }, - RemoveItem { - session: String, - item: String, - }, - RenameItem { - session: String, - item: String, - name: String, - }, - CopyItem { - from: String, - item: String, - to: String, - }, - MoveItem { - from: String, - item: String, - to: String, - }, - - SelectSession(String), - SelectItem(usize), - NextItem, - PreviousItem, - MoveItemUp, - MoveItemDown, - MoveItemToIndex(usize), - - ViewWorkspace, - ViewSession(String), -} - -pub fn load(path: &Path) -> Result> { - let content = std::fs::read_to_string(path).context("Failed to read patch file")?; - serde_json::from_str(&content).context("Failed to parse patch file") -} - -pub fn run(path: &Path) { - let steps = match load(path) { - Ok(s) => s, - Err(e) => { - eprintln!("patch: {e}"); - return; - } - }; - - let total = steps.len(); - let width = total.to_string().len(); - - println!("patch: '{}' ({total} steps)\n", path.display()); - - let mut ws = Workspace::new("untitled"); - let mut active = ActiveState::new(); - - for (i, step) in steps.into_iter().enumerate() { - let n = i + 1; - print!("[{n:>width$}/{total}] "); - if let Err(e) = execute(step, &mut ws, &mut active) { - println!("[ERR] {e}"); - } - } - - println!("\npatch: done."); -} - -fn execute(step: PatchStep, ws: &mut Workspace, active: &mut ActiveState) -> Result<()> { - match step { - PatchStep::NewWorkspace(name) => { - println!("New Workspace {name:?}"); - *ws = Workspace::new(&name); - active.reset(); - } - PatchStep::LoadWorkspace(path) => { - *ws = Workspace::load(&path)?; - active.reset(); - println!("Load Workspace {path:?} ({} sessions)", ws.sessions.len()); - } - PatchStep::SaveWorkspace(path) => { - ws.save(&path)?; - println!("Save Workspace {path:?}"); - } - - PatchStep::CreateSession { name, session_type } => { - let st = parse_session_type(&session_type)?; - let session = Session::new(&name, st); - let id = session.id; - ws.add_session(session); - active.select_session_by_id(ws, id)?; - println!("Create Session {name:?}"); - } - PatchStep::DeleteSession(name) => { - let id = ws.session_by_name(&name)?.id; - ws.remove_session(id)?; - if active.active_session_id() == Some(id) { - active.clear_session(); - } - println!("Delete Session {name:?}"); - } - PatchStep::CopySession(name) => { - let id = ws.session_by_name(&name)?.id; - let new_id = ws.copy_session(id)?; - let new_name = ws.session(new_id)?.name.clone(); - active.select_session_by_id(ws, new_id)?; - println!("Copy Session {name:?} -> {new_name:?}"); - } - PatchStep::RenameSession { session, name } => { - let id = ws.session_by_name(&session)?.id; - ws.rename_session(id, &name)?; - println!("Rename Session {session:?} -> {name:?}"); - } - - PatchStep::AddItem { - session, - name, - kind, - path, - } => { - let sid = ws.session_by_name(&session)?.id; - let item = Item::new(&name, parse_item_kind(&kind, &path)?); - ws.add_item(sid, item)?; - if active.active_session_id() == Some(sid) { - active.select_last_item(ws); - } - println!("Add Item {name:?} to {session:?}"); - } - PatchStep::RemoveItem { session, item } => { - let sid = ws.session_by_name(&session)?.id; - let iid = ws.item_by_name(sid, &item)?.id; - ws.remove_item(sid, iid)?; - if active.active_session_id() == Some(sid) { - active.adjust_after_remove(ws); - } - println!("Remove Item {item:?} from {session:?}"); - } - PatchStep::RenameItem { - session, - item, - name, - } => { - let sid = ws.session_by_name(&session)?.id; - let iid = ws.item_by_name(sid, &item)?.id; - ws.rename_item(sid, iid, &name)?; - println!("Rename Item {item:?} -> {name:?}"); - } - PatchStep::CopyItem { from, item, to } => { - let from_id = ws.session_by_name(&from)?.id; - let iid = ws.item_by_name(from_id, &item)?.id; - let to_id = ws.session_by_name(&to)?.id; - ws.copy_item(from_id, iid, to_id, usize::MAX)?; - println!("Copy Item {item:?}: {from:?} -> {to:?}"); - } - PatchStep::MoveItem { from, item, to } => { - let from_id = ws.session_by_name(&from)?.id; - let iid = ws.item_by_name(from_id, &item)?.id; - let to_id = ws.session_by_name(&to)?.id; - ws.move_item(from_id, iid, to_id, usize::MAX)?; - if active.active_session_id() == Some(from_id) { - active.adjust_after_remove(ws); - } - println!("Move Item {item:?}: {from:?} -> {to:?}"); - } - - PatchStep::SelectSession(name) => { - active.select_session(ws, &name)?; - println!("Select Session {name:?}"); - print_context(ws, active); - } - PatchStep::SelectItem(index) => { - active.select_item(ws, index)?; - println!("Select Item [{index}]"); - print_context(ws, active); - } - PatchStep::NextItem => { - active.next_item(ws); - println!("Next Item"); - print_context(ws, active); - } - PatchStep::PreviousItem => { - active.previous_item(); - println!("Previous Item"); - print_context(ws, active); - } - PatchStep::MoveItemUp => { - let sid = active.active_session_id().context("No active session")?; - let idx = active.active_item_index().context("No active item")?; - if idx == 0 { - bail!("Already at top") - } - let iid = active.active_item_id(ws).context("No active item")?; - ws.move_item(sid, iid, sid, idx - 1)?; - active.select_item(ws, idx - 1)?; - println!("Move Item Up"); - print_context(ws, active); - } - PatchStep::MoveItemDown => { - let sid = active.active_session_id().context("No active session")?; - let idx = active.active_item_index().context("No active item")?; - let count = active.active_session(ws).map_or(0, |s| s.items.len()); - if idx + 1 >= count { - bail!("Already at bottom") - } - let iid = active.active_item_id(ws).context("No active item")?; - ws.move_item(sid, iid, sid, idx + 1)?; - active.select_item(ws, idx + 1)?; - println!("Move Item Down"); - print_context(ws, active); - } - PatchStep::MoveItemToIndex(target) => { - let sid = active.active_session_id().context("No active session")?; - let iid = active.active_item_id(ws).context("No active item")?; - ws.move_item(sid, iid, sid, target)?; - let count = active.active_session(ws).map_or(0, |s| s.items.len()); - active.select_item(ws, target.min(count.saturating_sub(1)))?; - println!("Move Item To [{target}]"); - print_context(ws, active); - } - - PatchStep::ViewWorkspace => { - println!( - "View Workspace {:?} {} sessions", - ws.name, - ws.sessions.len() - ); - for s in &ws.sessions { - let m = if active.active_session_id() == Some(s.id) { - "=>" - } else { - " " - }; - println!( - " {m} {:?} ({:?}) {} items", - s.name, - s.session_type, - s.items.len() - ); - } - } - PatchStep::ViewSession(name) => { - let session = ws.session_by_name(&name)?; - println!( - "View Session {:?} ({:?}) {} items", - session.name, - session.session_type, - session.items.len() - ); - for (i, item) in session.items.iter().enumerate() { - let m = if active.active_session_id() == Some(session.id) - && active.active_item_index() == Some(i) - { - "=>" - } else { - " " - }; - let kind = match &item.kind { - ItemKind::Portable { .. } => "portable", - ItemKind::Raster { .. } => "raster", - ItemKind::Vector { .. } => "vector", - ItemKind::Document { .. } => "document", - }; - println!(" {m} [{i}] {:?} ({kind})", item.name); - } - } - } - Ok(()) -} - -fn print_context(ws: &Workspace, active: &ActiveState) { - let Some(session) = active.active_session(ws) else { - return; - }; - let item_info = match active.active_item(ws) { - Some(item) => format!( - "[{}] {:?}", - active.active_item_index().unwrap_or(0), - item.name - ), - None => "-".into(), - }; - println!( - " {:?} {} items active: {item_info}", - session.name, - session.items.len() - ); -} - -fn parse_session_type(s: &str) -> Result { - match s.to_lowercase().as_str() { - "browser" => Ok(SessionType::Browser), - "collection" => Ok(SessionType::Collection), - _ => bail!("Unknown session type: {s:?}"), - } -} - -fn parse_item_kind(kind: &str, path: &str) -> Result { - let path = PathBuf::from(path); - match kind.to_lowercase().as_str() { - "portable" | "pdf" => Ok(ItemKind::Portable { path }), - "raster" | "image" => Ok(ItemKind::Raster { path }), - "vector" | "svg" => Ok(ItemKind::Vector { path }), - "document" => Ok(ItemKind::Document { path }), - _ => bail!("Unknown item kind: {kind:?}"), - } -} diff --git a/src/render/converter.rs b/src/render/converter.rs deleted file mode 100644 index a4d02e6..0000000 --- a/src/render/converter.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/render/converter.rs -// -// Converts raw document data into renderable page output. diff --git a/src/render/mod.rs b/src/render/mod.rs deleted file mode 100644 index c13e7ba..0000000 --- a/src/render/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/render/mod.rs -// -// Rendering subsystem: converts document content into displayable output. - -pub mod converter; -pub mod thumbnail; diff --git a/src/render/thumbnail.rs b/src/render/thumbnail.rs deleted file mode 100644 index 294a985..0000000 --- a/src/render/thumbnail.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/render/thumbnail.rs -// -// Thumbnail cache following the freedesktop.org thumbnail spec. diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..2093176 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/app/app.rs +// +// COSMIC application wiring and main app struct. + +use super::message::AppMessage; +use super::model::{AppModel, ViewMode}; +use super::update; +use crate::ui::views; + +use std::time::Duration; + +use cosmic::app::{context_drawer, Core}; +use cosmic::cosmic_config::{self, CosmicConfigEntry}; +use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers}; +use cosmic::iced::time; +use cosmic::iced::window; +use cosmic::iced::Subscription; +use cosmic::widget::nav_bar; +use cosmic::{Action, Element, Task}; + +use crate::application::DocumentManager; +use crate::config::AppConfig; +use crate::Args; + +/// Flags passed from `main` into the application. +#[derive(Debug, Clone)] +pub enum Flags { + Args(Args), +} + +/// Context page displayed in right drawer. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ContextPage { + #[default] + Properties, +} + +/// Main application type. +pub struct NoctuaApp { + core: Core, + pub model: AppModel, + nav: nav_bar::Model, + context_page: ContextPage, + pub config: AppConfig, + config_handler: Option, + pub document_manager: DocumentManager, +} + +impl cosmic::Application for NoctuaApp { + type Executor = cosmic::SingleThreadExecutor; + type Flags = Flags; + type Message = AppMessage; + + const APP_ID: &'static str = "org.codeberg.wfx.Noctua"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(mut core: Core, flags: Self::Flags) -> (Self, Task>) { + // Load persisted config. + let (config, config_handler) = + match cosmic_config::Config::new(Self::APP_ID, AppConfig::VERSION) { + Ok(handler) => { + let config = AppConfig::get_entry(&handler).unwrap_or_default(); + (config, Some(handler)) + } + Err(_) => (AppConfig::default(), None), + }; + + let Flags::Args(args) = flags; + + // Determine initial path: CLI argument takes priority. + // Fall back to configured default directory only if it exists. + let initial_path = args.file.or_else(|| { + config + .default_image_dir + .as_ref() + .filter(|p| p.exists()) + .cloned() + }); + + // Initialize document manager + let mut document_manager = DocumentManager::new(); + + // Initialize model + let mut model = AppModel::new(config.clone()); + + // Load initial document if provided + if let Some(path) = initial_path { + if let Err(e) = document_manager.open_document(&path) { + log::error!("Failed to open initial path {}: {}", path.display(), e); + } else { + // Set initial view mode to Fit + model.viewport.fit_mode = ViewMode::Fit; + model.viewport.scale = 1.0; + model.reset_pan(); + + // Cache initial render so image is displayed immediately + if let Some(doc) = document_manager.current_document_mut() { + use crate::domain::document::core::document::Renderable; + match doc.render(model.viewport.scale as f64) { + Ok(output) => { + model.viewport.cached_image_handle = Some(output.handle); + } + Err(e) => { + log::error!("Failed to render initial document: {}", e); + } + } + } + } + } + + // Initialize nav bar model (required for COSMIC to show toggle icon). + let nav = nav_bar::Model::default(); + + // Apply persisted panel states. + core.window.show_context = config.context_drawer_visible; + + // Auto-open nav bar for multi-page documents + let should_show_nav = if let Some(doc) = document_manager.current_document() { + doc.is_multi_page() + } else { + false + }; + + if should_show_nav { + core.nav_bar_set_toggled(true); + model.panels.left = Some(crate::ui::model::LeftPanel::Thumbnails); + } else { + core.nav_bar_set_toggled(config.nav_bar_visible); + } + + // Start thumbnail generation for initial document if applicable. + let init_task = start_thumbnail_generation(&model); + + ( + Self { + core, + model, + nav, + context_page: ContextPage::default(), + config, + config_handler, + document_manager, + }, + init_task, + ) + } + + fn on_close_requested(&self, _id: window::Id) -> Option { + None + } + + fn update(&mut self, message: Self::Message) -> Task> { + match &message { + AppMessage::ToggleNavBar => { + use crate::ui::model::LeftPanel; + + self.core.nav_bar_toggle(); + let is_visible = self.core.nav_bar_active(); + self.config.nav_bar_visible = is_visible; + self.save_config(); + + if is_visible { + // Opening nav bar - show thumbnails for multi-page docs + if let Some(doc) = self.document_manager.current_document() + && doc.is_multi_page() + { + self.model.panels.left = Some(LeftPanel::Thumbnails); + } + } else { + // Closing nav bar - hide left panel + self.model.panels.left = None; + } + return Task::none(); + } + + AppMessage::OpenFormatPanel => { + // Format panel is now part of Transform mode + // Switch to Transform mode which shows format tools in right panel + self.model.mode = crate::ui::model::AppMode::Transform { + paper_format: None, + orientation: crate::ui::model::Orientation::default(), + }; + + return Task::none(); + } + + AppMessage::ToggleContextPage(page) => { + if self.context_page == *page { + self.core.window.show_context = !self.core.window.show_context; + } else { + self.context_page = *page; + self.core.window.show_context = true; + } + self.config.context_drawer_visible = self.core.window.show_context; + self.save_config(); + return Task::none(); + } + + AppMessage::OpenPath(_) | AppMessage::NextDocument | AppMessage::PrevDocument => { + let result = update::update(self, &message); + let thumb_task = start_thumbnail_generation_task(&self.model); + return match result { + update::UpdateResult::None => thumb_task, + update::UpdateResult::Task(task) => Task::batch([task, thumb_task]), + }; + } + + _ => {} + } + + match update::update(self, &message) { + update::UpdateResult::None => Task::none(), + update::UpdateResult::Task(task) => task, + } + } + + fn header_start(&self) -> Vec> { + views::header::start(&self.model, &self.document_manager) + } + + fn header_end(&self) -> Vec> { + views::header::end(&self.model, &self.document_manager) + } + + fn view(&self) -> Element<'_, Self::Message> { + views::view(&self.model, &self.document_manager, &self.config) + } + + fn context_drawer(&self) -> Option> { + if !self.core.window.show_context { + return None; + } + Some(context_drawer::context_drawer( + views::panels::view(&self.model, &self.document_manager), + AppMessage::ToggleContextPage(ContextPage::Properties), + )) + } + + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav) + } + + fn nav_bar(&self) -> Option>> { + if !self.core.nav_bar_active() { + return None; + } + views::nav_bar(&self.model, &self.document_manager) + } + + fn footer(&self) -> Option> { + Some(views::footer::view(&self.model, &self.document_manager)) + } + + fn subscription(&self) -> Subscription { + Subscription::batch([ + keyboard::on_key_press(handle_key_press), + thumbnail_refresh_subscription(self), + ]) + } +} + +impl NoctuaApp { + /// Save current config to disk. + fn save_config(&self) { + if let Some(ref handler) = self.config_handler { + let _ = self.config.write_entry(handler); + } + } + + /// Update nav bar visibility based on current document type. + pub fn update_nav_bar_for_document(&mut self) { + use crate::ui::model::LeftPanel; + + if let Some(doc) = self.document_manager.current_document() { + if doc.is_multi_page() { + // Multi-page document: open nav bar and show thumbnails + self.core.nav_bar_set_toggled(true); + self.model.panels.left = Some(LeftPanel::Thumbnails); + } else { + // Single-page document: close nav bar + self.core.nav_bar_set_toggled(false); + self.model.panels.left = None; + } + } + } +} + +/// Map raw key presses + modifiers into high-level application messages. +fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { + use AppMessage::{ + PanLeft, PanRight, PanUp, PanDown, OpenFormatPanel, NextDocument, PrevDocument, + FlipHorizontal, FlipVertical, RotateCCW, RotateCW, ZoomIn, ZoomOut, ZoomReset, ZoomFit, + ToggleCropMode, ToggleScaleMode, PanReset, ToggleContextPage, ToggleNavBar, SetAsWallpaper, + }; + + // Handle Ctrl + arrow keys for panning. + if modifiers.control() && !modifiers.shift() && !modifiers.alt() && !modifiers.logo() { + return match key.as_ref() { + Key::Named(Named::ArrowLeft) => Some(PanLeft), + Key::Named(Named::ArrowRight) => Some(PanRight), + Key::Named(Named::ArrowUp) => Some(PanUp), + Key::Named(Named::ArrowDown) => Some(PanDown), + Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(OpenFormatPanel), + _ => None, + }; + } + + // Ignore key presses when command-style modifiers are pressed. + if modifiers.command() || modifiers.alt() || modifiers.logo() || modifiers.control() { + return None; + } + + match key.as_ref() { + // Navigation with arrow keys (no modifiers). + Key::Named(Named::ArrowRight) => Some(NextDocument), + Key::Named(Named::ArrowLeft) => Some(PrevDocument), + + // Transformations. + Key::Character(ch) if ch.eq_ignore_ascii_case("h") => Some(FlipHorizontal), + Key::Character(ch) if ch.eq_ignore_ascii_case("v") => Some(FlipVertical), + Key::Character(ch) if ch.eq_ignore_ascii_case("r") => { + if modifiers.shift() { + Some(RotateCCW) + } else { + Some(RotateCW) + } + } + + // Zoom. + Key::Character("+" | "=") => Some(ZoomIn), + Key::Character("-") => Some(ZoomOut), + Key::Character("1") => Some(ZoomReset), + Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit), + + // Tool modes. + Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode), + Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode), + + // Crop mode actions (Enter/Escape handled via key press, validated in update). + Key::Named(Named::Enter) => Some(AppMessage::ApplyCrop), + Key::Named(Named::Escape) => Some(AppMessage::CancelCrop), + + // Reset pan. + Key::Character("0") => Some(PanReset), + + // Toggle panels. + Key::Character(ch) if ch.eq_ignore_ascii_case("i") => { + Some(ToggleContextPage(ContextPage::Properties)) + } + Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar), + + // Wallpaper. + Key::Character(ch) if ch.eq_ignore_ascii_case("w") => Some(SetAsWallpaper), + + _ => None, + } +} + +// ============================================================================= +// Thumbnail Helpers +// ============================================================================= + +fn start_thumbnail_generation(model: &AppModel) -> Task> { + start_thumbnail_generation_task(model) +} + +fn start_thumbnail_generation_task(_model: &AppModel) -> Task> { + // TODO: Re-enable when document is synced from DocumentManager + // if let Some(doc) = &model.document { + // let page_count = doc.page_count(); + // if page_count > 0 && !doc.thumbnails_ready() { + // return Task::batch([ + // Task::done(Action::App(AppMessage::GenerateThumbnailPage(0))), + // Task::done(Action::App(AppMessage::RefreshView)), + // ]); + // } + // } + Task::none() +} + +fn thumbnail_refresh_subscription(_app: &NoctuaApp) -> Subscription { + // TODO: Re-enable when document is synced from DocumentManager + let needs_refresh = false; + // let needs_refresh = app + // .model + // .document + // .as_ref() + // .is_some_and(|doc| doc.is_multi_page() && !doc.thumbnails_ready()); + + if needs_refresh { + time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView) + } else { + Subscription::none() + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 0000000..debd9c5 --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/components/mod.rs +// +// UI components: reusable widgets and controls. + +// Crop functionality moved to ui/widgets diff --git a/src/ui/message.rs b/src/ui/message.rs new file mode 100644 index 0000000..18826e3 --- /dev/null +++ b/src/ui/message.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/message.rs +// +// Application messages: events, user actions, and internal signals. + +use std::path::PathBuf; + +use crate::ui::widgets::DragHandle; + +#[derive(Debug, Clone)] +pub enum AppMessage { + // File / navigation. + #[allow(dead_code)] + OpenPath(PathBuf), + NextDocument, + PrevDocument, + GotoPage(usize), + GenerateThumbnailPage(usize), + + // Transformations. + RotateCW, + RotateCCW, + FlipHorizontal, + FlipVertical, + + // View / zoom. + ZoomIn, + ZoomOut, + ZoomReset, + ZoomFit, + ViewerStateChanged { + scale: f32, + offset_x: f32, + offset_y: f32, + canvas_size: cosmic::iced::Size, + image_size: cosmic::iced::Size, + }, + + // Pan control. + PanLeft, + PanRight, + PanUp, + PanDown, + PanReset, + + // Tool modes. + ToggleCropMode, + ToggleScaleMode, + + // Crop operations. + StartCrop, + CancelCrop, + ApplyCrop, + CropDragStart { + x: f32, + y: f32, + handle: DragHandle, + }, + CropDragMove { + x: f32, + y: f32, + max_x: f32, + max_y: f32, + }, + + CropDragEnd, + + // Panels. + ToggleContextPage(crate::ui::app::ContextPage), + ToggleNavBar, + OpenFormatPanel, + + // Menu. + ToggleMainMenu, + + // Format operations. + SetPaperFormat(super::model::PaperFormat), + SetOrientation(super::model::Orientation), + + // Metadata. + #[allow(dead_code)] + RefreshMetadata, + + // Save operations. + SaveAs, + + // Wallpaper. + SetAsWallpaper, + + // Errors. + #[allow(dead_code)] + ShowError(String), + #[allow(dead_code)] + ClearError, + + // UI refresh. + RefreshView, + + // Fallback. + #[allow(dead_code)] + NoOp, +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..96a0990 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/mod.rs +// +// UI layer: COSMIC application, views, and widgets. + +pub mod app; +pub mod message; +pub mod model; +pub mod update; +pub mod components; +pub mod views; +pub mod widgets; + +// Re-export main types +pub use app::NoctuaApp; +pub use message::AppMessage; +pub use model::AppModel; diff --git a/src/ui/model.rs b/src/ui/model.rs new file mode 100644 index 0000000..ef22573 --- /dev/null +++ b/src/ui/model.rs @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/model.rs +// +// UI state (view, tools, panels). +// +// AppModel contains ONLY UI-specific state. +// Document state lives in DocumentManager (application layer). + +use cosmic::iced::Size; + +use crate::ui::widgets::CropSelection; +use crate::config::AppConfig; + +// ============================================================================= +// View Mode +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + #[default] + Fit, + ActualSize, + Custom, +} + +// ============================================================================= +// Paper Format (for export/transform) +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaperFormat { + UsLetter, + IsoA0, + IsoA1, + IsoA2, + IsoA3, + IsoA4, + IsoA5, + IsoA6, +} + +impl PaperFormat { + /// Returns (width, height) in millimeters + pub fn dimensions_mm(self) -> (u32, u32) { + match self { + Self::UsLetter => (216, 279), // 8.5 x 11 inches + Self::IsoA0 => (841, 1189), + Self::IsoA1 => (594, 841), + Self::IsoA2 => (420, 594), + Self::IsoA3 => (297, 420), + Self::IsoA4 => (210, 297), + Self::IsoA5 => (148, 210), + Self::IsoA6 => (105, 148), + } + } + + /// Returns display name + pub fn display_name(self) -> &'static str { + match self { + Self::UsLetter => "US Letter", + Self::IsoA0 => "A0 (841 × 1189 mm)", + Self::IsoA1 => "A1", + Self::IsoA2 => "A2", + Self::IsoA3 => "A3", + Self::IsoA4 => "A4", + Self::IsoA5 => "A5 (148 × 210 mm)", + Self::IsoA6 => "A6", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Orientation { + Horizontal, + #[default] + Vertical, +} + +// ============================================================================= +// Application Mode (combines tool + panel state) +// ============================================================================= + +/// Application mode - unified tool and panel state. +/// +/// Each mode determines: +/// - Active tool behavior +/// - Right panel content +/// - Available shortcuts +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum AppMode { + /// Normal viewing mode - no active tool + View, + + /// Crop mode with selection + Crop { selection: CropSelection }, + + /// Transform/export mode + Transform { + paper_format: Option, + orientation: Orientation, + }, + + /// Fullscreen mode (all panels hidden) + Fullscreen, +} + +impl Default for AppMode { + fn default() -> Self { + Self::View + } +} + +impl AppMode { + /// Get the right panel that should be shown for this mode + pub fn right_panel(&self) -> Option { + match self { + Self::View => Some(RightPanel::Properties), + Self::Crop { .. } => Some(RightPanel::CropTools), + Self::Transform { .. } => Some(RightPanel::TransformTools), + Self::Fullscreen => None, + } + } + + /// Check if mode is an active tool (not View/Fullscreen) + pub fn is_tool_active(&self) -> bool { + matches!(self, Self::Crop { .. } | Self::Transform { .. }) + } +} + +// ============================================================================= +// Viewport (zoom, pan, canvas) +// ============================================================================= + +/// Viewport state - zoom, pan, canvas dimensions. +#[derive(Debug, Clone)] +pub struct Viewport { + /// Current scale factor + pub scale: f32, + + /// Pan offset X + pub pan_x: f32, + + /// Pan offset Y + pub pan_y: f32, + + /// Canvas size (container) + pub canvas_size: Size, + + /// Image size (after scaling) + pub image_size: Size, + + /// Fit mode + pub fit_mode: ViewMode, + + /// Scroll container ID + pub scroll_id: cosmic::widget::Id, + + /// Cached image handle for rendering (updated when document or scale changes) + pub cached_image_handle: Option, +} + +impl Default for Viewport { + fn default() -> Self { + Self { + scale: 1.0, + pan_x: 0.0, + pan_y: 0.0, + canvas_size: Size::ZERO, + image_size: Size::ZERO, + fit_mode: ViewMode::Fit, + scroll_id: cosmic::widget::Id::new("canvas-scroll"), + cached_image_handle: None, + } + } +} + +impl Viewport { + /// Reset pan to center + pub fn reset_pan(&mut self) { + self.pan_x = 0.0; + self.pan_y = 0.0; + } +} + +// ============================================================================= +// Panel State +// ============================================================================= + +/// Panel visibility state. +#[derive(Debug, Clone, Default)] +pub struct PanelState { + /// Left panel (thumbnails for multi-page) + pub left: Option, + + /// Right panel (context-dependent tools/properties) + pub right: Option, +} + +/// Left panel types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftPanel { + /// Thumbnail navigation for multi-page documents + Thumbnails, +} + +/// Right panel types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum RightPanel { + /// Document properties and metadata + Properties, + + /// Crop mode tools + CropTools, + + /// Transform/export tools + TransformTools, +} + +// ============================================================================= +// AppModel (UI State Only) +// ============================================================================= + +/// UI state for the application. +/// +/// Contains ONLY UI-specific state: +/// - Current mode (view/tool) +/// - Viewport (zoom/pan) +/// - Panel visibility +/// - Transient UI state (errors, menu) +/// +/// Document state (current file, metadata, etc.) lives in DocumentManager! +pub struct AppModel { + /// Current application mode + pub mode: AppMode, + + /// Viewport state + pub viewport: Viewport, + + /// Panel visibility + pub panels: PanelState, + + /// Error message to display + pub error: Option, + + /// Is main menu open? + pub menu_open: bool, + + /// Tick counter for animations + pub tick: u64, +} + +impl AppModel { + pub fn new(_config: AppConfig) -> Self { + Self { + mode: AppMode::default(), + viewport: Viewport::default(), + panels: PanelState::default(), + error: None, + menu_open: false, + tick: 0, + } + } + + /// Set error message + pub fn set_error>(&mut self, msg: S) { + self.error = Some(msg.into()); + } + + /// Clear error message + pub fn clear_error(&mut self) { + self.error = None; + } + + /// Reset viewport pan to center + pub fn reset_pan(&mut self) { + self.viewport.reset_pan(); + } +} diff --git a/src/ui/update.rs b/src/ui/update.rs new file mode 100644 index 0000000..a511072 --- /dev/null +++ b/src/ui/update.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/update.rs +// +// Application update loop: applies messages to the global model state. + +use cosmic::{Action, Task}; + +use super::NoctuaApp; +use super::message::AppMessage; +use super::model::{AppMode, ViewMode}; +use crate::application::commands::transform_document::{TransformDocumentCommand, TransformOperation}; +use crate::application::commands::crop_document::CropDocumentCommand; +use crate::domain::document::core::document::Renderable; +use crate::ui::widgets::{CropSelection, DragHandle}; + +// ============================================================================= +// Update Result +// ============================================================================= + +#[allow(dead_code)] +pub enum UpdateResult { + None, + Task(Task>), +} + +// ============================================================================= +// Main Update Function +// ============================================================================= + +pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult { + match msg { + // ---- File / navigation ---------------------------------------------------- + AppMessage::OpenPath(path) => { + if let Err(e) = app.document_manager.open_document(path) { + app.model.set_error(format!("Failed to open document: {e}")); + } else { + app.model.reset_pan(); + app.model.viewport.fit_mode = ViewMode::Fit; + app.model.viewport.scale = 1.0; + cache_render(&mut app.model, &mut app.document_manager); + + // Auto-toggle nav bar for multi-page documents + app.update_nav_bar_for_document(); + } + } + + AppMessage::NextDocument => { + // Ignore navigation in Crop mode + if !matches!(app.model.mode, AppMode::Crop { .. }) + && let Some(_path) = app.document_manager.next_document() + { + // Reset zoom when navigating to new document + app.model.viewport.scale = 1.0; + app.model.viewport.fit_mode = ViewMode::Fit; + app.model.reset_pan(); + cache_render(&mut app.model, &mut app.document_manager); + + // Auto-toggle nav bar for multi-page documents + app.update_nav_bar_for_document(); + } + } + + AppMessage::PrevDocument => { + // Ignore navigation in Crop mode + if !matches!(app.model.mode, AppMode::Crop { .. }) + && let Some(_path) = app.document_manager.previous_document() + { + // Reset zoom when navigating to new document + app.model.viewport.scale = 1.0; + app.model.viewport.fit_mode = ViewMode::Fit; + app.model.reset_pan(); + cache_render(&mut app.model, &mut app.document_manager); + + // Auto-toggle nav bar for multi-page documents + app.update_nav_bar_for_document(); + } + } + + AppMessage::GotoPage(page) => { + if let Some(doc) = app.document_manager.current_document_mut() { + if let Err(e) = doc.go_to_page(*page) { + log::error!("Failed to navigate to page {page}: {e}"); + } else { + cache_render(&mut app.model, &mut app.document_manager); + } + } + } + + // ---- Thumbnail generation ------------------------------------------------- + AppMessage::GenerateThumbnailPage(_page) => { + // TODO: Thumbnail generation via DocumentManager + // Currently handled by DocumentManager.open_document() + } + + AppMessage::RefreshView => { + app.model.tick += 1; + } + + // ---- View / zoom --------------------------------------------------------- + AppMessage::ZoomIn => { + app.model.viewport.scale = (app.model.viewport.scale * 1.2).min(10.0); + app.model.viewport.fit_mode = ViewMode::Custom; + } + + AppMessage::ZoomOut => { + app.model.viewport.scale = (app.model.viewport.scale / 1.2).max(0.1); + app.model.viewport.fit_mode = ViewMode::Custom; + } + + AppMessage::ZoomReset => { + app.model.viewport.scale = 1.0; + app.model.viewport.fit_mode = ViewMode::ActualSize; + app.model.reset_pan(); + } + + AppMessage::ZoomFit => { + app.model.viewport.fit_mode = ViewMode::Fit; + app.model.reset_pan(); + } + + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + canvas_size, + image_size, + } => { + // Detect scale changes (zoom vs just pan) + let old_scale = app.model.viewport.scale; + + // Update model from viewer state + app.model.viewport.scale = *scale; + app.model.viewport.pan_x = *offset_x; + app.model.viewport.pan_y = *offset_y; + app.model.viewport.canvas_size = *canvas_size; + app.model.viewport.image_size = *image_size; + + // If scale changed, user zoomed -> switch to Custom mode and re-render + // (Fit mode is only maintained when explicitly set via ZoomFit button) + if (old_scale - *scale).abs() > 0.001 { + app.model.viewport.fit_mode = ViewMode::Custom; + cache_render(&mut app.model, &mut app.document_manager); + } + } + + // ---- Pan control --------------------------------------------------------- + AppMessage::PanLeft => { + app.model.viewport.pan_x -= 50.0; + } + AppMessage::PanRight => { + app.model.viewport.pan_x += 50.0; + } + AppMessage::PanUp => { + app.model.viewport.pan_y -= 50.0; + } + AppMessage::PanDown => { + app.model.viewport.pan_y += 50.0; + } + AppMessage::PanReset => { + app.model.reset_pan(); + } + + // ---- Tool modes ---------------------------------------------------------- + AppMessage::ToggleCropMode => { + app.model.mode = match &app.model.mode { + AppMode::Crop { .. } => AppMode::View, + _ => AppMode::Crop { + selection: CropSelection::default(), + }, + }; + } + + AppMessage::ToggleScaleMode => { + // Scale mode -> Transform mode + app.model.mode = match &app.model.mode { + AppMode::Transform { .. } => AppMode::View, + _ => AppMode::Transform { + paper_format: None, + orientation: Default::default(), + }, + }; + } + + // ---- Crop operations ----------------------------------------------------- + AppMessage::StartCrop => { + if app.document_manager.current_document().is_some() { + app.model.mode = AppMode::Crop { + selection: CropSelection::default(), + }; + } + } + + AppMessage::CancelCrop => { + // Only cancel if actually in Crop mode + if matches!(app.model.mode, AppMode::Crop { .. }) { + app.model.mode = AppMode::View; + } + } + + AppMessage::ApplyCrop => { + if let AppMode::Crop { selection } = &app.model.mode { + // Get crop selection region + if let Some(crop_region) = selection.to_crop_region() { + // Create crop command from canvas selection + let pan_offset = cosmic::iced::Vector::new( + app.model.viewport.pan_x, + app.model.viewport.pan_y, + ); + + match CropDocumentCommand::from_canvas_selection( + &crop_region, + app.model.viewport.canvas_size, + app.model.viewport.image_size, + app.model.viewport.scale, + pan_offset, + ) { + Ok(cmd) => { + // Execute crop command + if let Err(e) = cmd.execute(&mut app.document_manager) { + app.model.set_error(format!("Crop failed: {e}")); + } else { + // Success - exit crop mode + app.model.mode = AppMode::View; + // Reset view to fit the cropped image + app.model.viewport.scale = 1.0; + app.model.viewport.fit_mode = ViewMode::Fit; + app.model.reset_pan(); + cache_render(&mut app.model, &mut app.document_manager); + } + } + Err(e) => { + app.model.set_error(format!("Invalid crop region: {e}")); + } + } + } else { + app.model.set_error("No crop region selected".to_string()); + } + } + } + + AppMessage::CropDragStart { x, y, handle } => { + if let AppMode::Crop { selection } = &mut app.model.mode { + if *handle == DragHandle::None { + selection.start_new_selection(*x, *y); + } else { + selection.start_handle_drag(*handle, *x, *y); + } + } + } + + AppMessage::CropDragMove { x, y, max_x, max_y } => { + if let AppMode::Crop { selection } = &mut app.model.mode { + selection.update_drag(*x, *y, *max_x, *max_y); + } + } + + AppMessage::CropDragEnd => { + if let AppMode::Crop { selection } = &mut app.model.mode { + selection.end_drag(); + } + } + + // ---- Save operations ----------------------------------------------------- + AppMessage::SaveAs => { + save_as(&mut app.model); + } + + // ---- Document transformations -------------------------------------------- + AppMessage::FlipHorizontal => { + // Ignore transformations in Crop mode (would invalidate selection) + if !matches!(app.model.mode, AppMode::Crop { .. }) { + let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal); + if let Err(e) = cmd.execute(&mut app.document_manager) { + app.model.set_error(format!("Flip horizontal failed: {e}")); + } else { + cache_render(&mut app.model, &mut app.document_manager); + } + } + } + + AppMessage::FlipVertical => { + // Ignore transformations in Crop mode (would invalidate selection) + if !matches!(app.model.mode, AppMode::Crop { .. }) { + let cmd = TransformDocumentCommand::new(TransformOperation::FlipVertical); + if let Err(e) = cmd.execute(&mut app.document_manager) { + app.model.set_error(format!("Flip vertical failed: {e}")); + } else { + cache_render(&mut app.model, &mut app.document_manager); + } + } + } + + AppMessage::RotateCW => { + // Ignore transformations in Crop mode (would invalidate selection) + if !matches!(app.model.mode, AppMode::Crop { .. }) { + let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw); + if let Err(e) = cmd.execute(&mut app.document_manager) { + app.model.set_error(format!("Rotate clockwise failed: {e}")); + } else { + cache_render(&mut app.model, &mut app.document_manager); + } + } + } + + AppMessage::RotateCCW => { + // Ignore transformations in Crop mode (would invalidate selection) + if !matches!(app.model.mode, AppMode::Crop { .. }) { + let cmd = TransformDocumentCommand::new(TransformOperation::RotateCcw); + if let Err(e) = cmd.execute(&mut app.document_manager) { + app.model.set_error(format!("Rotate CCW failed: {e}")); + } else { + cache_render(&mut app.model, &mut app.document_manager); + } + } + } + + // ---- Metadata ------------------------------------------------------------ + AppMessage::RefreshMetadata => { + // Metadata is managed by DocumentManager + // Nothing to do here - views access it directly + } + + // ---- Format operations --------------------------------------------------- + AppMessage::SetPaperFormat(format) => { + if let AppMode::Transform { paper_format, .. } = &mut app.model.mode { + *paper_format = Some(*format); + } + } + + AppMessage::SetOrientation(orientation) => { + if let AppMode::Transform { + orientation: ori, .. + } = &mut app.model.mode + { + *ori = *orientation; + } + } + + // ---- Menu ---------------------------------------------------------------- + AppMessage::ToggleMainMenu => { + app.model.menu_open = !app.model.menu_open; + } + + // ---- Wallpaper ----------------------------------------------------------- + AppMessage::SetAsWallpaper => { + if let Some(path) = app.document_manager.current_path() { + log::info!("Setting wallpaper to: {}", path.display()); + crate::infrastructure::system::set_as_wallpaper(path); + } else { + app.model.set_error("No image loaded".to_string()); + } + } + + // ---- Error handling ------------------------------------------------------ + AppMessage::ShowError(msg) => { + app.model.set_error(msg.clone()); + } + + AppMessage::ClearError => { + app.model.clear_error(); + } + + // ---- Handled elsewhere --------------------------------------------------- + AppMessage::ToggleContextPage(_) + | AppMessage::ToggleNavBar + | AppMessage::OpenFormatPanel => { + // These are handled in app.rs + } + + AppMessage::NoOp => {} + } + + UpdateResult::None +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Cache rendered image handle in viewport for view performance. +fn cache_render( + model: &mut super::model::AppModel, + manager: &mut crate::application::DocumentManager, +) { + if let Some(doc) = manager.current_document_mut() { + match doc.render(model.viewport.scale as f64) { + Ok(output) => { + model.viewport.cached_image_handle = Some(output.handle); + } + Err(e) => { + log::error!("Failed to cache render: {e}"); + model.viewport.cached_image_handle = None; + } + } + } else { + model.viewport.cached_image_handle = None; + } +} + +fn save_as(model: &mut super::model::AppModel) { + // TODO: Implement file dialog for save path + // For now, show error that this needs UI integration + model.set_error("Save As: File dialog not yet implemented".to_string()); +} diff --git a/src/ui/views/canvas.rs b/src/ui/views/canvas.rs new file mode 100644 index 0000000..68d4729 --- /dev/null +++ b/src/ui/views/canvas.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/views/canvas.rs +// +// Render the center canvas area with the current document. + +use cosmic::iced::widget::image::FilterMethod; +use cosmic::iced::{ContentFit, Length}; +use cosmic::iced_widget::stack; +use cosmic::widget::{container, text}; +use cosmic::Element; + +use crate::ui::widgets::{crop_overlay, Viewer}; +use crate::ui::model::{AppMode, ViewMode}; +use crate::ui::{AppMessage, AppModel}; +use crate::application::DocumentManager; +use crate::config::AppConfig; +use crate::fl; + +/// Render the center canvas area with the current document. +pub fn view<'a>( + model: &'a AppModel, + _manager: &'a DocumentManager, + config: &'a AppConfig, +) -> Element<'a, AppMessage> { + // Use cached image handle from viewport + if let Some(handle) = &model.viewport.cached_image_handle { + // Determine content fit mode + let content_fit = match model.viewport.fit_mode { + ViewMode::Fit => ContentFit::Contain, + ViewMode::ActualSize | ViewMode::Custom => ContentFit::None, + }; + + // Check if we're in crop mode (to disable pan) + let disable_pan = matches!(model.mode, AppMode::Crop { .. }); + + // Create image viewer + let img_viewer = Viewer::new(handle.clone()) + .with_state( + model.viewport.scale, + model.viewport.pan_x, + model.viewport.pan_y, + ) + .on_state_change(|scale, offset_x, offset_y, canvas_size, image_size| { + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + canvas_size, + image_size, + } + }) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit) + .filter_method(FilterMethod::Linear) + .min_scale(config.min_scale) + .max_scale(config.max_scale) + .scale_step(config.scale_step - 1.0) + .disable_pan(disable_pan); + + // Overlay crop UI when in crop mode + if let AppMode::Crop { selection } = &model.mode { + let overlay = crop_overlay(selection, config.crop_show_grid); + stack![img_viewer, overlay].into() + } else { + container(img_viewer) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + } else { + // No document loaded + container(text(fl!("no-document"))) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill) + .into() + } +} diff --git a/src/ui/views/footer.rs b/src/ui/views/footer.rs new file mode 100644 index 0000000..b9770ea --- /dev/null +++ b/src/ui/views/footer.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/views/footer.rs +// +// Footer bar with zoom controls and document info. + +use cosmic::iced::Alignment; +use cosmic::widget::{button, icon, row, text}; +use cosmic::Element; + +use crate::ui::model::{AppModel, ViewMode}; +use crate::ui::AppMessage; +use crate::application::DocumentManager; +use crate::domain::document::core::document::Renderable; +use crate::fl; + +/// Build the footer element with zoom controls and document info. +pub fn view<'a>(model: &'a AppModel, manager: &'a DocumentManager) -> Element<'a, AppMessage> { + // Zoom level display + let zoom_text = if model.viewport.fit_mode == ViewMode::Fit { + fl!("status-zoom-fit") + } else { + let percent = (model.viewport.scale * 100.0).round() as i32; + fl!("status-zoom-percent", percent: percent) + }; + + // Document dimensions (from DocumentManager) + let doc_info = if let Some(doc) = manager.current_document() { + let info = doc.info(); + fl!("status-doc-dimensions", width: info.width, height: info.height) + } else { + String::new() + }; + + // Navigation position (from DocumentManager) + let folder_count = manager.folder_entries().len(); + let nav_info = if folder_count == 0 { + String::new() + } else { + let current = manager.current_index().map_or(0, |i| i + 1); + let total = folder_count; + fl!("status-nav-position", current: current, total: total) + }; + + row() + .spacing(8) + .align_y(Alignment::Center) + .padding([4, 12]) + // Zoom out button + .push( + button::icon(icon::from_name("zoom-out-symbolic")) + .on_press(AppMessage::ZoomOut) + .padding(4), + ) + // Zoom level text + .push(text(zoom_text)) + // Zoom in button + .push( + button::icon(icon::from_name("zoom-in-symbolic")) + .on_press(AppMessage::ZoomIn) + .padding(4), + ) + // Zoom reset button + .push( + button::icon(icon::from_name("zoom-original-symbolic")) + .on_press(AppMessage::ZoomReset) + .padding(4), + ) + // Zoom fit button + .push( + button::icon(icon::from_name("zoom-fit-best-symbolic")) + .on_press(AppMessage::ZoomFit) + .padding(4), + ) + // Document dimensions + .push_maybe(if !doc_info.is_empty() { + Some(text(doc_info)) + } else { + None + }) + // Navigation info + .push_maybe(if folder_count == 0 { + None + } else { + Some(text(nav_info)) + }) + .into() +} diff --git a/src/ui/views/format_panel.rs b/src/ui/views/format_panel.rs new file mode 100644 index 0000000..7863204 --- /dev/null +++ b/src/ui/views/format_panel.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/format_panel.rs +// +// Format panel for paper format and orientation selection. + +use cosmic::widget::{column, radio, text}; +use cosmic::Element; + +use crate::ui::model::{AppMode, AppModel, Orientation, PaperFormat}; +use crate::ui::AppMessage; +use crate::fl; + +/// Build the format panel view for the navigation bar. +pub fn view(model: &AppModel) -> Element<'static, AppMessage> { + // Extract values from Transform mode + let (paper_format, orientation) = match &model.mode { + AppMode::Transform { + paper_format, + orientation, + } => (*paper_format, *orientation), + _ => (None, Orientation::default()), + }; + + let mut content = column::with_capacity(20).spacing(12).padding(16); + + // --- Format Section --- + content = content + .push(text::heading(fl!("format-section-title"))) + .push(text::caption(fl!("format-section-subtitle"))); + + // US Letter + content = content.push( + radio( + "US Letter (216 × 279 mm)", + PaperFormat::UsLetter, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ); + + // ISO A formats + content = content + .push(text::body("ISO A")) + .push( + radio( + PaperFormat::IsoA0.display_name(), + PaperFormat::IsoA0, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA1.display_name(), + PaperFormat::IsoA1, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA2.display_name(), + PaperFormat::IsoA2, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA3.display_name(), + PaperFormat::IsoA3, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA4.display_name(), + PaperFormat::IsoA4, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA5.display_name(), + PaperFormat::IsoA5, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ) + .push( + radio( + PaperFormat::IsoA6.display_name(), + PaperFormat::IsoA6, + paper_format, + AppMessage::SetPaperFormat, + ) + .size(16), + ); + + // --- Orientation Section --- + content = content + .push(cosmic::widget::vertical_space().height(16)) + .push(text::heading(fl!("orientation-section-title"))); + + // Horizontal + content = content.push( + radio( + "Horizontal", + Orientation::Horizontal, + Some(orientation), + AppMessage::SetOrientation, + ) + .size(16), + ); + + // Vertical + content = content.push( + radio( + "Vertical", + Orientation::Vertical, + Some(orientation), + AppMessage::SetOrientation, + ) + .size(16), + ); + + content.into() +} diff --git a/src/ui/views/header.rs b/src/ui/views/header.rs new file mode 100644 index 0000000..9f53cbe --- /dev/null +++ b/src/ui/views/header.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/header.rs +// +// Header bar content (navigation, rotation, flip). + +use cosmic::iced::Length; +use cosmic::widget::{button, horizontal_space, icon, row}; +use cosmic::Element; + +use crate::ui::message::AppMessage; +use crate::ui::model::AppModel; +use crate::ui::app::ContextPage; +use crate::application::DocumentManager; +use crate::fl; + +/// Build the start (left) side of the header bar. +pub fn start<'a>( + _model: &'a AppModel, + manager: &'a DocumentManager, +) -> Vec> { + let has_doc = manager.current_document().is_some(); + + // Left section: Panel toggle + Menu + Navigation + let left_controls = row() + .spacing(4) + .push( + button::icon(icon::from_name("view-sidebar-start-symbolic")) + .on_press(AppMessage::ToggleNavBar), + //.tooltip(fl!("tooltip-nav-toggle")), + ) + // .push( + // button::icon(icon::from_name("open-menu-symbolic")) + // .on_press(AppMessage::ToggleMainMenu), + // //.tooltip(fl!("menu-main")), + // ) + .push( + button::icon(icon::from_name("go-previous-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)), + //.tooltip(fl!("tooltip-nav-previous")), + ) + .push( + button::icon(icon::from_name("go-next-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::NextDocument)), + //.tooltip(fl!("tooltip-nav-next")), + ); + + // Center section: Transformations + let center_controls = row() + .spacing(4) + .push( + button::icon(icon::from_name("object-rotate-left-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)), + //.tooltip(fl!("tooltip-rotate-ccw")), + ) + .push( + button::icon(icon::from_name("object-rotate-right-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCW)), + //.tooltip(fl!("tooltip-rotate-cw")), + ) + .push(horizontal_space().width(Length::Fixed(12.0))) + .push( + button::icon(icon::from_name("object-flip-horizontal-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)), + //.tooltip(fl!("tooltip-flip-horizontal")), + ) + .push( + button::icon(icon::from_name("object-flip-vertical-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)), + //.tooltip(fl!("tooltip-flip-vertical")), + ); + + vec![ + left_controls.into(), + center_controls.into(), + horizontal_space().width(Length::Fill).into(), + ] +} + +/// Build the end (right) side of the header bar. +pub fn end<'a>( + _model: &'a AppModel, + _manager: &'a DocumentManager, +) -> Vec> { + vec![ + // Info panel toggle + button::icon(icon::from_name("dialog-information-symbolic")) + .on_press(AppMessage::ToggleContextPage(ContextPage::Properties)) + //.tooltip(fl!("tooltip-info-panel")) + .into(), + ] +} diff --git a/src/ui/views/meta_panel.rs b/src/ui/views/meta_panel.rs new file mode 100644 index 0000000..025d4fa --- /dev/null +++ b/src/ui/views/meta_panel.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/views/meta_panel.rs +// +// Metadata and properties panel for document information. + +use cosmic::iced::{Alignment, Length}; +use cosmic::widget::{button, column, divider, horizontal_space, icon, row, text}; +use cosmic::Element; + +use crate::application::DocumentManager; +use crate::domain::document::core::document::Renderable; +use crate::ui::{AppMessage, AppModel}; +use crate::fl; + +/// Build the metadata/properties panel view. +pub fn view(_model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> { + let mut content = column::with_capacity(16).spacing(8).padding(12); + + // Header with action icons + content = content.push(panel_header(manager)); + + // Display document metadata if available + if let Some(meta) = manager.current_metadata() { + // --- Basic Information Section --- + content = content + .push(section_header(fl!("meta-section-file"))) + .push(meta_row(fl!("meta-filename"), meta.basic.file_name.clone())) + .push(meta_row(fl!("meta-format"), meta.basic.format.clone())); + + // Show dimensions - original from metadata, current if transformed + let original_dims = (meta.basic.width, meta.basic.height); + let current_dims = if let Some(doc) = manager.current_document() { + let info = doc.info(); + (info.width, info.height) + } else { + (0, 0) + }; + + if original_dims != current_dims && current_dims != (0, 0) { + // Dimensions changed (e.g., rotation) - show both + content = content.push(meta_row( + fl!("meta-dimensions"), + format!( + "{} × {} (original: {} × {})", + current_dims.0, current_dims.1, original_dims.0, original_dims.1 + ), + )); + } else { + // No transformation or no document loaded yet + content = content.push(meta_row( + fl!("meta-dimensions"), + meta.basic.resolution_display(), + )); + } + + content = content + .push(meta_row( + fl!("meta-filesize"), + meta.basic.file_size_display(), + )) + .push(meta_row( + fl!("meta-colortype"), + meta.basic.color_type.clone(), + )); + + // --- EXIF Section (if available) --- + if let Some(ref exif) = meta.exif { + let has_exif_data = exif.camera_display().is_some() + || exif.date_time.is_some() + || exif.exposure_time.is_some() + || exif.f_number.is_some() + || exif.iso.is_some() + || exif.focal_length.is_some() + || exif.gps_display().is_some(); + + if has_exif_data { + content = content + .push(divider::horizontal::light()) + .push(section_header(fl!("meta-section-exif"))); + + if let Some(camera) = exif.camera_display() { + content = content.push(meta_row(fl!("meta-camera"), camera)); + } + + if let Some(ref date) = exif.date_time { + content = content.push(meta_row(fl!("meta-datetime"), date.clone())); + } + + if let Some(ref exposure) = exif.exposure_time { + content = content.push(meta_row(fl!("meta-exposure"), exposure.clone())); + } + + if let Some(ref fnumber) = exif.f_number { + content = content.push(meta_row(fl!("meta-aperture"), fnumber.clone())); + } + + if let Some(iso) = exif.iso { + content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso))); + } + + if let Some(ref focal) = exif.focal_length { + content = content.push(meta_row(fl!("meta-focal"), focal.clone())); + } + + if let Some(gps) = exif.gps_display() { + content = content.push(meta_row(fl!("meta-gps"), gps)); + } + } + } + + // --- File Path (at the bottom, less prominent) --- + content = content + .push(divider::horizontal::light()) + .push(meta_row_small( + fl!("meta-path"), + meta.basic.file_path.clone(), + )); + } else { + // No document loaded + content = content + .push(vertical_space()) + .push(text::body(fl!("no-document"))) + .push(vertical_space()); + } + + content.into() +} + +// ============================================================================= +// Helper Components +// ============================================================================= + +/// Panel header with title and action buttons. +fn panel_header(manager: &DocumentManager) -> Element<'static, AppMessage> { + let has_doc = manager.current_document().is_some(); + + row::with_capacity(5) + .spacing(4) + .align_y(Alignment::Center) + .padding([0, 0, 8, 0]) + .push(text::title4(fl!("panel-properties"))) + .push(horizontal_space().width(Length::Fill)) + .push( + button::icon(icon::from_name("image-x-generic-symbolic")) + .tooltip(fl!("action-set-wallpaper")) + .padding(4) + .on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)), + ) + .into() +} + +/// Section header for grouping metadata. +fn section_header(label: String) -> Element<'static, AppMessage> { + text::heading(label).size(14).into() +} + +/// Key-value metadata row. +fn meta_row(label: String, value: String) -> Element<'static, AppMessage> { + column::with_capacity(2) + .spacing(2) + .push(text::caption(format!("{}:", label))) + .push(text::body(value)) + .into() +} + +/// Less prominent metadata row (smaller text). +fn meta_row_small(label: String, value: String) -> Element<'static, AppMessage> { + column::with_capacity(2) + .spacing(2) + .push(text::caption(format!("{}:", label))) + .push(text::caption(value)) + .into() +} + +/// Vertical spacer helper. +fn vertical_space() -> Element<'static, AppMessage> { + cosmic::widget::vertical_space() + .height(Length::Fixed(32.0)) + .into() +} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs new file mode 100644 index 0000000..439ae5d --- /dev/null +++ b/src/ui/views/mod.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/mod.rs +// +// View module exports. + +pub mod canvas; +pub mod footer; +pub mod format_panel; +pub mod header; +pub mod meta_panel; +pub mod pages_panel; +pub mod panels; + +use cosmic::iced::Length; +use cosmic::widget::container; +use cosmic::{Action, Element}; + +use crate::ui::model::LeftPanel; +use crate::ui::{AppMessage, AppModel}; +use crate::application::DocumentManager; +use crate::config::AppConfig; + +/// Main application view (canvas area). +pub fn view<'a>( + model: &'a AppModel, + manager: &'a DocumentManager, + config: &'a AppConfig, +) -> Element<'a, AppMessage> { + canvas::view(model, manager, config) +} + +/// Navigation bar content (left panel). +/// +/// Shows different panels based on panel state: +/// - `LeftPanel::Thumbnails`: Page thumbnails (multi-page documents) +/// - `None`: Hidden +pub fn nav_bar<'a>( + model: &'a AppModel, + manager: &'a DocumentManager, +) -> Option>> { + match model.panels.left { + None => None, + Some(LeftPanel::Thumbnails) => pages_panel::view(model, manager).map(|panel| { + container(panel.map(Action::App)) + .width(Length::Shrink) + .height(Length::Fill) + .max_width(250) + .into() + }), + } +} diff --git a/src/ui/views/pages_panel.rs b/src/ui/views/pages_panel.rs new file mode 100644 index 0000000..c2ab024 --- /dev/null +++ b/src/ui/views/pages_panel.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/pages_panel.rs +// +// Page navigation panel for multi-page documents (PDF, multi-page TIFF, etc.). + +/// Maximum width in pixels for page navigation thumbnails. +const THUMBNAIL_MAX_WIDTH: f32 = 100.0; + +use cosmic::iced::{Alignment, Length}; +use cosmic::widget::{button, column, container, scrollable, text}; +use cosmic::widget::image as cosmic_image; + +use cosmic::Element; + +use crate::application::DocumentManager; +use crate::ui::{AppMessage, AppModel}; +use crate::fl; + +/// Build the page navigation panel view. +/// Returns None if the current document doesn't support multiple pages. +pub fn view<'a>( + _model: &'a AppModel, + manager: &'a DocumentManager, +) -> Option> { + // Get document and check if it's multi-page + let doc = manager.current_document()?; + let page_count = doc.page_count(); + + if page_count <= 1 { + return None; + } + + let current_page = doc.current_page(); + let loaded = doc.thumbnails_loaded(); + + let mut content = column::with_capacity(page_count + 1) + .spacing(12) + .padding([12, 8]) + .align_x(Alignment::Center) + .width(Length::Fill); + + // Show loading progress if not all thumbnails are ready. + if !doc.thumbnails_ready() { + let loading_msg = fl!("loading-thumbnails", current: loaded, total: page_count); + content = content.push(text::caption(loading_msg)); + } + + // Build thumbnail list for pages that are already loaded. + for page_index in 0..loaded { + let is_current = page_index == current_page; + + // Get cached thumbnail handle (read-only access). + let thumbnail_element: Element<'static, AppMessage> = + if let Some(handle) = manager.get_thumbnail_handle(page_index) { + // Display the thumbnail image. + cosmic_image::Image::new(handle) + .width(Length::Fixed(THUMBNAIL_MAX_WIDTH)) + .into() + } else { + // Fallback: show page number if thumbnail not yet loaded. + container(text(format!("Page {}", page_index + 1))) + .width(Length::Fixed(THUMBNAIL_MAX_WIDTH)) + .height(Length::Fixed(THUMBNAIL_MAX_WIDTH * 1.4)) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + }; + + // Page number label. + let page_label = text::caption(format!("{}", page_index + 1)); + + // Combine thumbnail and label in a column. + let page_content = column::with_capacity(2) + .spacing(4) + .align_x(Alignment::Center) + .push(thumbnail_element) + .push(page_label); + + // Wrap in button for navigation. + let page_button = if is_current { + // Current page: highlighted style. + button::custom(page_content) + .class(cosmic::theme::Button::Suggested) + .padding(4) + } else { + // Other pages: clickable with standard style. + button::custom(page_content) + .class(cosmic::theme::Button::Standard) + .padding(4) + .on_press(AppMessage::GotoPage(page_index)) + }; + + content = content.push(page_button); + } + + // Wrap in scrollable container. + Some( + scrollable(content) + .width(Length::Shrink) + .height(Length::Fill) + .into(), + ) +} diff --git a/src/ui/views/panels.rs b/src/ui/views/panels.rs new file mode 100644 index 0000000..5879d8e --- /dev/null +++ b/src/ui/views/panels.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/views/panels.rs +// +// Panel router - delegates to specific panel views. + +use cosmic::Element; + +use crate::application::DocumentManager; +use crate::ui::model::{AppModel, RightPanel}; +use crate::ui::AppMessage; + +use super::{format_panel, meta_panel}; + +/// Build the right panel view based on current panel state. +/// +/// Returns the appropriate panel content: +/// - `RightPanel::Properties`: Metadata and document properties (default) +/// - `RightPanel::CropTools`: Crop tool controls (TODO) +/// - `RightPanel::TransformTools`: Transform/export controls +/// +/// Defaults to Properties panel if no panel is explicitly set. +pub fn view(model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> { + match model.panels.right.as_ref() { + Some(RightPanel::Properties) | None => meta_panel::view(model, manager), + Some(RightPanel::CropTools) => crop_tools_panel(model, manager), + Some(RightPanel::TransformTools) => format_panel::view(model), + } +} + +/// Crop tools panel (TODO: implement dedicated crop controls). +fn crop_tools_panel(_model: &AppModel, _manager: &DocumentManager) -> Element<'static, AppMessage> { + use cosmic::widget::{column, text}; + + column::with_capacity(4) + .spacing(12) + .padding(12) + .push(text::title4("Crop Tools")) + .push(text::body("Crop controls will be implemented here.")) + .push(text::caption( + "For now, use the crop overlay on the canvas.", + )) + .into() +} diff --git a/src/ui/widgets/crop_model.rs b/src/ui/widgets/crop_model.rs new file mode 100644 index 0000000..465e471 --- /dev/null +++ b/src/ui/widgets/crop_model.rs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/widgets/crop_model.rs +// +// Crop UI model (drag state and logic). + +use crate::domain::document::operations::CropRegion; + +/// Drag handle for crop selection. +/// +/// Identifies which part of the selection is being dragged. +/// Pure UI concern - not part of domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DragHandle { + #[default] + None, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Top, + Bottom, + Left, + Right, + Move, +} + +/// Crop selection UI model. +/// +/// Manages the interactive state of crop selection: +/// - Current selection region (in screen/canvas coordinates) +/// - Drag state (what's being dragged, where it started) +/// - Drag logic (how to update region based on handle type) +/// +/// This is UI-specific logic, not domain logic! +#[derive(Debug, Clone, Default)] +pub struct CropSelection { + /// Current selection region as (x, y, width, height) in screen coordinates + pub region: Option<(f32, f32, f32, f32)>, + + /// Is user currently dragging? + pub is_dragging: bool, + + /// Which handle/part is being dragged? + pub drag_handle: DragHandle, + + /// Where did the drag start? (for delta calculation) + drag_start: Option<(f32, f32)>, + + /// What was the region when drag started? (for resize calculation) + drag_start_region: Option<(f32, f32, f32, f32)>, +} + +impl CropSelection { + pub fn new() -> Self { + Self::default() + } + + /// Start a new selection (user clicks on empty area). + pub fn start_new_selection(&mut self, x: f32, y: f32) { + self.region = Some((x, y, 0.0, 0.0)); + self.is_dragging = true; + self.drag_handle = DragHandle::None; + self.drag_start = Some((x, y)); + self.drag_start_region = None; + } + + /// Start dragging a handle (user clicks on existing selection). + pub fn start_handle_drag(&mut self, handle: DragHandle, x: f32, y: f32) { + self.is_dragging = true; + self.drag_handle = handle; + self.drag_start = Some((x, y)); + self.drag_start_region = self.region; + } + + /// Update selection during drag. + pub fn update_drag(&mut self, x: f32, y: f32, img_width: f32, img_height: f32) { + if !self.is_dragging { + return; + } + + match self.drag_handle { + DragHandle::None => { + // Creating new selection - expand from start point + if let Some((start_x, start_y)) = self.drag_start { + let min_x = start_x.min(x).max(0.0); + let min_y = start_y.min(y).max(0.0); + let max_x = start_x.max(x).min(img_width); + let max_y = start_y.max(y).min(img_height); + + self.region = Some((min_x, min_y, max_x - min_x, max_y - min_y)); + } + } + DragHandle::Move => { + // Moving entire selection + if let (Some((start_x, start_y)), Some((rx, ry, rw, rh))) = + (self.drag_start, self.drag_start_region) + { + let dx = x - start_x; + let dy = y - start_y; + let new_x = (rx + dx).max(0.0).min(img_width - rw); + let new_y = (ry + dy).max(0.0).min(img_height - rh); + self.region = Some((new_x, new_y, rw, rh)); + } + } + _ => { + // Resizing from edge/corner + if let Some((rx, ry, rw, rh)) = self.drag_start_region { + let (new_x, new_y, new_w, new_h) = + self.resize_region(rx, ry, rw, rh, x, y, img_width, img_height); + self.region = Some((new_x, new_y, new_w, new_h)); + } + } + } + } + + /// Resize region based on which handle is being dragged. + fn resize_region( + &self, + rx: f32, + ry: f32, + rw: f32, + rh: f32, + x: f32, + y: f32, + img_width: f32, + img_height: f32, + ) -> (f32, f32, f32, f32) { + const MIN_SIZE: f32 = 10.0; + + let right = rx + rw; + let bottom = ry + rh; + let x = x.max(0.0).min(img_width); + let y = y.max(0.0).min(img_height); + + match self.drag_handle { + DragHandle::TopLeft => { + let new_x = x.min(right - MIN_SIZE); + let new_y = y.min(bottom - MIN_SIZE); + (new_x, new_y, right - new_x, bottom - new_y) + } + DragHandle::TopRight => { + let new_right = x.max(rx + MIN_SIZE); + let new_y = y.min(bottom - MIN_SIZE); + (rx, new_y, new_right - rx, bottom - new_y) + } + DragHandle::BottomLeft => { + let new_x = x.min(right - MIN_SIZE); + let new_bottom = y.max(ry + MIN_SIZE); + (new_x, ry, right - new_x, new_bottom - ry) + } + DragHandle::BottomRight => { + let new_right = x.max(rx + MIN_SIZE); + let new_bottom = y.max(ry + MIN_SIZE); + (rx, ry, new_right - rx, new_bottom - ry) + } + DragHandle::Top => { + let new_y = y.min(bottom - MIN_SIZE); + (rx, new_y, rw, bottom - new_y) + } + DragHandle::Bottom => { + let new_bottom = y.max(ry + MIN_SIZE); + (rx, ry, rw, new_bottom - ry) + } + DragHandle::Left => { + let new_x = x.min(right - MIN_SIZE); + (new_x, ry, right - new_x, rh) + } + DragHandle::Right => { + let new_right = x.max(rx + MIN_SIZE); + (rx, ry, new_right - rx, rh) + } + _ => (rx, ry, rw, rh), + } + } + + /// End drag operation. + pub fn end_drag(&mut self) { + self.is_dragging = false; + self.drag_start = None; + self.drag_start_region = None; + } + + /// Reset selection (cancel). + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Check if there's a valid selection. + pub fn has_selection(&self) -> bool { + self.region + .map(|(_, _, w, h)| w > 1.0 && h > 1.0) + .unwrap_or(false) + } + + /// Convert to domain CropRegion for actual crop operation. + pub fn to_crop_region(&self) -> Option { + self.region.and_then(|(x, y, w, h)| { + if w > 1.0 && h > 1.0 { + Some(CropRegion { + x: x as u32, + y: y as u32, + width: w as u32, + height: h as u32, + }) + } else { + None + } + }) + } +} diff --git a/src/ui/widgets/crop_overlay.rs b/src/ui/widgets/crop_overlay.rs new file mode 100644 index 0000000..457d2ca --- /dev/null +++ b/src/ui/widgets/crop_overlay.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/widgets/crop_overlay.rs +// +// Simple crop overlay. + +use cosmic::{ + Element, Renderer, + iced::{ + Color, Length, Point, Rectangle, Size, + advanced::{ + Clipboard, Layout, Shell, Widget, + layout::{Limits, Node}, + renderer::{Quad, Renderer as QuadRenderer}, + widget::Tree, + }, + event::{Event, Status}, + mouse::{self, Button, Cursor}, + }, +}; + +use crate::ui::widgets::crop_model::{CropSelection, DragHandle}; +use crate::ui::AppMessage; + +const HANDLE_SIZE: f32 = 12.0; +const HANDLE_HIT_SIZE: f32 = 24.0; +const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5); +const HANDLE_COLOR: Color = Color::WHITE; +const BORDER_COLOR: Color = Color::WHITE; +const BORDER_WIDTH: f32 = 2.0; + +pub struct CropOverlay { + selection: CropSelection, + show_grid: bool, + last_click: Option, +} + +impl CropOverlay { + pub fn new(selection: &CropSelection, show_grid: bool) -> Self { + Self { + selection: selection.clone(), + show_grid, + last_click: None, + } + } + + fn hit_test_handle(&self, point: Point) -> DragHandle { + let Some((x, y, w, h)) = self.selection.region else { + return DragHandle::None; + }; + + let handles = [ + (Point::new(x, y), DragHandle::TopLeft), + (Point::new(x + w, y), DragHandle::TopRight), + (Point::new(x, y + h), DragHandle::BottomLeft), + (Point::new(x + w, y + h), DragHandle::BottomRight), + (Point::new(x + w / 2.0, y), DragHandle::Top), + (Point::new(x + w / 2.0, y + h), DragHandle::Bottom), + (Point::new(x, y + h / 2.0), DragHandle::Left), + (Point::new(x + w, y + h / 2.0), DragHandle::Right), + ]; + + for (pos, handle) in handles { + if point_in_handle(point, pos) { + return handle; + } + } + + if point.x >= x && point.x <= x + w && point.y >= y && point.y <= y + h { + return DragHandle::Move; + } + + DragHandle::None + } + + fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + draw_quad(renderer, bounds, OVERLAY_COLOR); + return; + }; + + // Convert relative coords to absolute screen coords + let abs_x = bounds.x + x; + let abs_y = bounds.y + y; + let abs_right = abs_x + w; + let abs_bottom = abs_y + h; + + // Top + if abs_y > bounds.y { + draw_quad( + renderer, + Rectangle::new( + Point::new(bounds.x, bounds.y), + Size::new(bounds.width, abs_y - bounds.y), + ), + OVERLAY_COLOR, + ); + } + + // Bottom + if abs_bottom < bounds.y + bounds.height { + draw_quad( + renderer, + Rectangle::new( + Point::new(bounds.x, abs_bottom), + Size::new(bounds.width, bounds.y + bounds.height - abs_bottom), + ), + OVERLAY_COLOR, + ); + } + + // Left + if abs_x > bounds.x { + draw_quad( + renderer, + Rectangle::new( + Point::new(bounds.x, abs_y), + Size::new(abs_x - bounds.x, h), + ), + OVERLAY_COLOR, + ); + } + + // Right + if abs_right < bounds.x + bounds.width { + draw_quad( + renderer, + Rectangle::new( + Point::new(abs_right, abs_y), + Size::new(bounds.x + bounds.width - abs_right, h), + ), + OVERLAY_COLOR, + ); + } + } + + fn draw_border(&self, renderer: &mut Renderer, bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + // Add bounds offset + let abs_x = bounds.x + x; + let abs_y = bounds.y + y; + + // Top + draw_quad( + renderer, + Rectangle::new(Point::new(abs_x, abs_y), Size::new(w, BORDER_WIDTH)), + BORDER_COLOR, + ); + + // Bottom + draw_quad( + renderer, + Rectangle::new( + Point::new(abs_x, abs_y + h - BORDER_WIDTH), + Size::new(w, BORDER_WIDTH), + ), + BORDER_COLOR, + ); + + // Left + draw_quad( + renderer, + Rectangle::new(Point::new(abs_x, abs_y), Size::new(BORDER_WIDTH, h)), + BORDER_COLOR, + ); + + // Right + draw_quad( + renderer, + Rectangle::new( + Point::new(abs_x + w - BORDER_WIDTH, abs_y), + Size::new(BORDER_WIDTH, h), + ), + BORDER_COLOR, + ); + } + + fn draw_handles(&self, renderer: &mut Renderer, bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + let half = HANDLE_SIZE / 2.0; + + // Add bounds offset + let abs_x = bounds.x + x; + let abs_y = bounds.y + y; + + let handles = [ + Point::new(abs_x, abs_y), + Point::new(abs_x + w, abs_y), + Point::new(abs_x, abs_y + h), + Point::new(abs_x + w, abs_y + h), + Point::new(abs_x + w / 2.0, abs_y), + Point::new(abs_x + w / 2.0, abs_y + h), + Point::new(abs_x, abs_y + h / 2.0), + Point::new(abs_x + w, abs_y + h / 2.0), + ]; + + for pos in handles { + draw_quad( + renderer, + Rectangle::new( + Point::new(pos.x - half, pos.y - half), + Size::new(HANDLE_SIZE, HANDLE_SIZE), + ), + HANDLE_COLOR, + ); + } + } + + fn draw_grid(&self, renderer: &mut Renderer, bounds: Rectangle) { + if !self.show_grid { + return; + } + + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + if w <= 10.0 || h <= 10.0 { + return; + } + + // Add bounds offset + let abs_x = bounds.x + x; + let abs_y = bounds.y + y; + + let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.3); + let third_w = w / 3.0; + let third_h = h / 3.0; + + // 2 vertical + for i in 1..3 { + let line_x = abs_x + third_w * i as f32; + draw_quad( + renderer, + Rectangle::new(Point::new(line_x, abs_y), Size::new(1.0, h)), + grid_color, + ); + } + + // 2 horizontal + for i in 1..3 { + let line_y = abs_y + third_h * i as f32; + draw_quad( + renderer, + Rectangle::new(Point::new(abs_x, line_y), Size::new(w, 1.0)), + grid_color, + ); + } + } +} + +impl Widget for CropOverlay { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node { + Node::new(limits.max()) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _theme: &cosmic::Theme, + _style: &cosmic::iced::advanced::renderer::Style, + layout: Layout<'_>, + _cursor: Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + self.draw_overlay(renderer, bounds); + self.draw_border(renderer, bounds); + self.draw_handles(renderer, bounds); + self.draw_grid(renderer, bounds); + } + + fn on_event( + &mut self, + _tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, AppMessage>, + _viewport: &Rectangle, + ) -> Status { + let bounds = layout.bounds(); + + match event { + Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => { + if let Some(pos) = cursor.position_in(bounds) { + let handle = self.hit_test_handle(pos); + + if handle == DragHandle::Move { + use std::time::{Duration, Instant}; + let now = Instant::now(); + if let Some(last) = self.last_click { + if now.duration_since(last) < Duration::from_millis(400) { + shell.publish(AppMessage::ApplyCrop); + self.last_click = None; + return Status::Captured; + } + } + self.last_click = Some(now); + } + + shell.publish(AppMessage::CropDragStart { + x: pos.x, + y: pos.y, + handle, + }); + return Status::Captured; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if self.selection.is_dragging { + if let Some(pos) = cursor.position_in(bounds) { + shell.publish(AppMessage::CropDragMove { + x: pos.x, + y: pos.y, + max_x: bounds.width, + max_y: bounds.height, + }); + return Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => { + if self.selection.is_dragging { + shell.publish(AppMessage::CropDragEnd); + return Status::Captured; + } + } + _ => {} + } + + Status::Ignored + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let bounds = layout.bounds(); + + if let Some(pos) = cursor.position_in(bounds) { + let handle = self.hit_test_handle(pos); + return match handle { + DragHandle::TopLeft | DragHandle::BottomRight => { + mouse::Interaction::ResizingDiagonallyDown + } + DragHandle::TopRight | DragHandle::BottomLeft => { + mouse::Interaction::ResizingDiagonallyUp + } + DragHandle::Top | DragHandle::Bottom => mouse::Interaction::ResizingVertically, + DragHandle::Left | DragHandle::Right => mouse::Interaction::ResizingHorizontally, + DragHandle::Move => mouse::Interaction::Grabbing, + DragHandle::None => mouse::Interaction::Crosshair, + }; + } + + mouse::Interaction::None + } +} + +impl<'a> From for Element<'a, AppMessage> { + fn from(widget: CropOverlay) -> Self { + Element::new(widget) + } +} + +fn point_in_handle(point: Point, handle_center: Point) -> bool { + let half = HANDLE_HIT_SIZE / 2.0; + point.x >= handle_center.x - half + && point.x <= handle_center.x + half + && point.y >= handle_center.y - half + && point.y <= handle_center.y + half +} + +fn draw_quad(renderer: &mut Renderer, bounds: Rectangle, color: Color) { + renderer.fill_quad( + Quad { + bounds, + ..Quad::default() + }, + color, + ); +} + +pub fn crop_overlay<'a>(selection: &CropSelection, show_grid: bool) -> Element<'a, AppMessage> { + CropOverlay::new(selection, show_grid).into() +} diff --git a/src/ui/widgets/image_viewer.rs b/src/ui/widgets/image_viewer.rs new file mode 100644 index 0000000..01a30d9 --- /dev/null +++ b/src/ui/widgets/image_viewer.rs @@ -0,0 +1,559 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/image_viewer.rs +// +// Zoom and pan image viewer widget with external state control. +// Forked from cosmic::iced to support external state control. + +use cosmic::iced::advanced::image as img_renderer; +use cosmic::iced::advanced::layout; +use cosmic::iced::advanced::renderer; +use cosmic::iced::advanced::widget::tree::{self, Tree}; +use cosmic::iced::advanced::widget::Widget; +use cosmic::iced::advanced::{Clipboard, Layout, Shell}; +use cosmic::iced::event::{self, Event}; +use cosmic::iced::mouse; +use cosmic::iced::widget::image::FilterMethod; +use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector}; + +/// Tolerance for scale comparisons in widget state synchronization. +const SCALE_EPSILON: f32 = 0.0001; + +/// Tolerance for offset comparisons in widget state synchronization. +const OFFSET_EPSILON: f32 = 0.01; + +/// Callback type for notifying viewer state changes (scale, `offset_x`, `offset_y`, `canvas_size`, `image_size`). +type StateChangeCallback = Box Message>; + +/// A frame that displays an image with the ability to zoom in/out and pan. +#[allow(missing_debug_implementations)] +pub struct Viewer { + padding: f32, + width: Length, + height: Length, + min_scale: f32, + max_scale: f32, + scale_step: f32, + handle: Handle, + filter_method: FilterMethod, + content_fit: ContentFit, + /// Optional external state to override internal state (scale, offset) + external_state: Option<(f32, Vector)>, + /// Optional callback to notify state changes + on_state_change: Option>, + /// Disable pan interaction (for crop mode) + disable_pan: bool, +} + +impl Viewer { + /// Creates a new [`Viewer`] with the given handle. + pub fn new>(handle: T) -> Self { + Viewer { + handle: handle.into(), + padding: 0.0, + width: Length::Shrink, + height: Length::Shrink, + min_scale: 0.25, + max_scale: 10.0, + scale_step: 0.10, + filter_method: FilterMethod::default(), + content_fit: ContentFit::default(), + external_state: None, + on_state_change: None, + disable_pan: false, + } + } + + /// Set external state to control zoom and pan from outside. + /// This allows keyboard/button controls to override the internal state. + pub fn with_state(mut self, scale: f32, offset_x: f32, offset_y: f32) -> Self { + self.external_state = Some((scale, Vector::new(offset_x, offset_y))); + self + } + + /// Set a callback to be notified when the state changes (for mouse interaction). + pub fn on_state_change(mut self, f: F) -> Self + where + F: 'static + Fn(f32, f32, f32, Size, Size) -> Message, + { + self.on_state_change = Some(Box::new(f)); + self + } + + /// Disable pan interaction (useful when overlaying crop tools). + pub fn disable_pan(mut self, disable: bool) -> Self { + self.disable_pan = disable; + self + } + + /// Sets the [`FilterMethod`] of the [`Viewer`]. + pub fn filter_method(mut self, filter_method: FilterMethod) -> Self { + self.filter_method = filter_method; + self + } + + /// Sets the [`ContentFit`] of the [`Viewer`]. + pub fn content_fit(mut self, content_fit: ContentFit) -> Self { + self.content_fit = content_fit; + self + } + + /// Sets the padding of the [`Viewer`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into().0; + self + } + + /// Sets the width of the [`Viewer`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Viewer`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the max scale applied to the image of the [`Viewer`]. + /// + /// Default is `10.0` + pub fn max_scale(mut self, max_scale: f32) -> Self { + self.max_scale = max_scale; + self + } + + /// Sets the min scale applied to the image of the [`Viewer`]. + /// + /// Default is `0.25` + pub fn min_scale(mut self, min_scale: f32) -> Self { + self.min_scale = min_scale; + self + } + + /// Sets the percentage the image of the [`Viewer`] will be scaled by + /// when zoomed in / out. + /// + /// Default is `0.10` + pub fn scale_step(mut self, scale_step: f32) -> Self { + self.scale_step = scale_step; + self + } +} + +impl Widget for Viewer +where + Renderer: img_renderer::Renderer, + Handle: Clone, + Message: Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + let mut state = State::new(); + // Apply external state if provided at creation + if let Some((scale, offset)) = self.external_state { + state.scale = scale; + state.current_offset = offset; + state.starting_offset = offset; + } + tree::State::new(state) + } + + fn diff(&mut self, tree: &mut Tree) { + // Sync external state into internal state when user is not dragging + if let Some((ext_scale, ext_offset)) = self.external_state { + let state = tree.state.downcast_mut::(); + + // Only apply external state if user is not currently dragging + if !state.is_cursor_grabbed() { + // Check if external state differs significantly from current state + let scale_changed = (state.scale - ext_scale).abs() > SCALE_EPSILON; + let offset_changed = (state.current_offset.x - ext_offset.x).abs() > OFFSET_EPSILON + || (state.current_offset.y - ext_offset.y).abs() > OFFSET_EPSILON; + + if scale_changed || offset_changed { + state.scale = ext_scale; + state.current_offset = ext_offset; + state.starting_offset = ext_offset; + } + } + } + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &self, + _tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let image_size = renderer.measure_image(&self.handle); + let image_size = Size::new(image_size.width as f32, image_size.height as f32); + + let raw_size = limits.resolve(self.width, self.height, image_size); + let full_size = self.content_fit.fit(image_size, raw_size); + + let final_size = Size { + width: match self.width { + Length::Shrink => f32::min(raw_size.width, full_size.width), + _ => raw_size.width, + }, + height: match self.height { + Length::Shrink => f32::min(raw_size.height, full_size.height), + _ => raw_size.height, + }, + }; + + layout::Node::new(final_size) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let bounds = layout.bounds(); + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let Some(cursor_position) = cursor.position_over(bounds) else { + return event::Status::Ignored; + }; + + match delta { + mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { + let state = tree.state.downcast_mut::(); + let previous_scale = state.scale; + + if y < 0.0 && previous_scale > self.min_scale + || y > 0.0 && previous_scale < self.max_scale + { + state.scale = (if y > 0.0 { + state.scale * (1.0 + self.scale_step) + } else { + state.scale / (1.0 + self.scale_step) + }) + .clamp(self.min_scale, self.max_scale); + + let scale_factor = state.scale / previous_scale; + + // Cursor position relative to the image center (not bounds center) + // The image is centered in bounds, so bounds.center() is correct + let cursor_to_center = cursor_position - bounds.center(); + + // Transform offset so the point under cursor stays stationary + // Formula: new_offset = old_offset * scale_factor + cursor_to_center * (scale_factor - 1) + let new_offset = Vector::new( + state.current_offset.x * scale_factor + + cursor_to_center.x * (scale_factor - 1.0), + state.current_offset.y * scale_factor + + cursor_to_center.y * (scale_factor - 1.0), + ); + + // Clamp offset to valid range + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + state.current_offset = + clamp_offset(new_offset, bounds.size(), scaled_size); + + // Notify state change + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + } + } + } + + event::Status::Captured + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if self.disable_pan { + return event::Status::Ignored; + } + + let Some(cursor_position) = cursor.position_over(bounds) else { + return event::Status::Ignored; + }; + + let state = tree.state.downcast_mut::(); + state.cursor_grabbed_at = Some(cursor_position); + state.starting_offset = state.current_offset; + + event::Status::Captured + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + if self.disable_pan { + return event::Status::Ignored; + } + + let state = tree.state.downcast_mut::(); + + if state.cursor_grabbed_at.is_some() { + state.cursor_grabbed_at = None; + + // Notify final state after drag ends + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + + event::Status::Captured + } else { + event::Status::Ignored + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if self.disable_pan { + return event::Status::Ignored; + } + + let state = tree.state.downcast_mut::(); + + if let Some(origin) = state.cursor_grabbed_at { + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + let delta = position - origin; + + // Pan: subtract delta from starting offset + let new_offset = Vector::new( + state.starting_offset.x - delta.x, + state.starting_offset.y - delta.y, + ); + + state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size); + + // Notify state change during pan + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + + event::Status::Captured + } else { + event::Status::Ignored + } + } + _ => event::Status::Ignored, + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + let is_mouse_over = cursor.is_over(bounds); + + if state.is_cursor_grabbed() { + mouse::Interaction::Grabbing + } else if is_mouse_over { + mouse::Interaction::Grab + } else { + mouse::Interaction::None + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + // Calculate translation to center the image and apply offset + let translation = { + // How much space is left after placing the scaled image + let diff_w = bounds.width - scaled_size.width; + let diff_h = bounds.height - scaled_size.height; + + // Base position: center the image in the viewport + // For images smaller than viewport: center them (diff > 0) + // For images larger than viewport: they extend beyond bounds (diff < 0) + let center_offset = Vector::new(diff_w / 2.0, diff_h / 2.0); + + // Apply pan offset (offset moves the "camera", so subtract it) + // Positive offset = looking at right/bottom part = image moves left/up + center_offset - state.current_offset + }; + + let drawing_bounds = Rectangle::new(bounds.position(), scaled_size); + + let render = |renderer: &mut Renderer| { + renderer.with_translation(translation, |renderer| { + renderer.draw_image( + self.handle.clone(), + self.filter_method, + drawing_bounds, + Radians(0.0), + 1.0, + [0.0; 4], + ); + }); + }; + + renderer.with_layer(bounds, render); + } +} + +/// The local state of a [`Viewer`]. +#[derive(Debug, Clone, Copy)] +pub struct State { + scale: f32, + starting_offset: Vector, + current_offset: Vector, + cursor_grabbed_at: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + scale: 1.0, + starting_offset: Vector::default(), + current_offset: Vector::default(), + cursor_grabbed_at: None, + } + } +} + +impl State { + /// Creates a new [`State`]. + pub fn new() -> Self { + State::default() + } + + /// Returns if the cursor is currently grabbed by the [`Viewer`]. + pub fn is_cursor_grabbed(&self) -> bool { + self.cursor_grabbed_at.is_some() + } +} + +/// Clamps the offset to keep the image within reasonable bounds. +/// +/// The offset represents how far the viewport's center is displaced from the image's center. +/// - offset (0, 0) = image centered +/// - positive offset = viewing right/bottom part of image +/// - negative offset = viewing left/top part of image +fn clamp_offset(offset: Vector, viewport_size: Size, image_size: Size) -> Vector { + // Maximum allowed offset in each direction + // When image is larger than viewport, allow panning up to image edge + // When image is smaller than viewport, no panning needed (clamp to 0) + let max_offset_x = ((image_size.width - viewport_size.width) / 2.0).max(0.0); + let max_offset_y = ((image_size.height - viewport_size.height) / 2.0).max(0.0); + + Vector::new( + offset.x.clamp(-max_offset_x, max_offset_x), + offset.y.clamp(-max_offset_y, max_offset_y), + ) +} + +impl<'a, Message, Theme, Renderer, Handle> From> + for Element<'a, Message, Theme, Renderer> +where + Renderer: 'a + img_renderer::Renderer, + Message: 'a + Clone, + Handle: Clone + 'a, +{ + fn from(viewer: Viewer) -> Element<'a, Message, Theme, Renderer> { + Element::new(viewer) + } +} + +/// Returns the scaled size of the image given current state. +/// Calculate the scaled image size after applying content fit and zoom. +/// +/// This is the canonical implementation used by the viewer widget. +/// A simplified version exists in `document::utils::scaled_image_size`. +pub fn scaled_image_size( + renderer: &Renderer, + handle: &::Handle, + state: &State, + bounds: Size, + content_fit: ContentFit, +) -> Size +where + Renderer: img_renderer::Renderer, +{ + let Size { width, height } = renderer.measure_image(handle); + let image_size = Size::new(width as f32, height as f32); + + let adjusted_fit = match content_fit { + ContentFit::None => image_size, + _ => content_fit.fit(image_size, bounds), + }; + + Size::new( + adjusted_fit.width * state.scale, + adjusted_fit.height * state.scale, + ) +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..fc0a669 --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/widgets/mod.rs +// +// Custom widgets module. + +pub mod crop_model; +pub mod crop_overlay; +pub mod image_viewer; + +// Re-exports for convenience +pub use crop_model::{CropSelection, DragHandle}; +pub use crop_overlay::crop_overlay; +pub use image_viewer::Viewer; diff --git a/src/workspace/manager.rs b/src/workspace/manager.rs deleted file mode 100644 index 3b17348..0000000 --- a/src/workspace/manager.rs +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/workspace/manager.rs -// -// Workspace operations: session and item CRUD, reordering, and RON persistence. - -use crate::workspace::model::{Item, Session, Workspace}; -use anyhow::{Context, Result}; -use std::path::Path; -use uuid::Uuid; - -impl Workspace { - /// Serialize the workspace to a RON file. - pub fn save(&self, path: impl AsRef) -> Result<()> { - let config = ron::ser::PrettyConfig::default(); - let data = - ron::ser::to_string_pretty(self, config).context("Failed to serialize workspace")?; - std::fs::write(&path, data).context("Failed to write workspace file")?; - Ok(()) - } - - /// Deserialize a workspace from a RON file. - pub fn load(path: impl AsRef) -> Result { - let data = std::fs::read_to_string(&path).context("Failed to read workspace file")?; - let workspace: Workspace = - ron::from_str(&data).context("Failed to deserialize workspace")?; - Ok(workspace) - } - - pub fn add_session(&mut self, session: Session) { - self.sessions.push(session); - } - - pub fn remove_session(&mut self, session_id: Uuid) -> Result { - let pos = self.session_pos(session_id)?; - Ok(self.sessions.remove(pos)) - } - - pub fn copy_session(&mut self, session_id: Uuid) -> Result { - let pos = self.session_pos(session_id)?; - let mut clone = self.sessions[pos].clone(); - clone.id = Uuid::new_v4(); - clone.name = format!("{} (copy)", clone.name); - for item in &mut clone.items { - item.id = Uuid::new_v4(); - } - let new_id = clone.id; - self.sessions.push(clone); - Ok(new_id) - } - - pub fn move_session(&mut self, session_id: Uuid, new_index: usize) -> Result<()> { - let pos = self.session_pos(session_id)?; - let session = self.sessions.remove(pos); - let insert_at = new_index.min(self.sessions.len()); - self.sessions.insert(insert_at, session); - Ok(()) - } - - pub fn rename_session(&mut self, session_id: Uuid, new_name: impl Into) -> Result<()> { - let session = self.session_mut(session_id)?; - session.name = new_name.into(); - Ok(()) - } - - pub fn add_item(&mut self, session_id: Uuid, item: Item) -> Result<()> { - let session = self.session_mut(session_id)?; - session.items.push(item); - Ok(()) - } - - pub fn remove_item(&mut self, session_id: Uuid, item_id: Uuid) -> Result { - let session = self.session_mut(session_id)?; - let pos = item_pos(&session.items, item_id)?; - Ok(session.items.remove(pos)) - } - - /// Move an item within the same session or between two sessions. - pub fn move_item( - &mut self, - from_session: Uuid, - item_id: Uuid, - to_session: Uuid, - to_index: usize, - ) -> Result<()> { - let item = self.remove_item(from_session, item_id)?; - let target = self.session_mut(to_session)?; - let insert_at = to_index.min(target.items.len()); - target.items.insert(insert_at, item); - Ok(()) - } - - /// Copy an item within the same session or between two sessions. - pub fn copy_item( - &mut self, - from_session: Uuid, - item_id: Uuid, - to_session: Uuid, - to_index: usize, - ) -> Result { - let source = self.session(from_session)?; - let pos = item_pos(&source.items, item_id)?; - let mut clone = source.items[pos].clone(); - clone.id = Uuid::new_v4(); - clone.name = format!("{} (copy)", clone.name); - let new_id = clone.id; - - let target = self.session_mut(to_session)?; - let insert_at = to_index.min(target.items.len()); - target.items.insert(insert_at, clone); - Ok(new_id) - } - - pub fn rename_item( - &mut self, - session_id: Uuid, - item_id: Uuid, - new_name: impl Into, - ) -> Result<()> { - let session = self.session_mut(session_id)?; - let pos = item_pos(&session.items, item_id)?; - session.items[pos].name = new_name.into(); - Ok(()) - } - - /// Read-only access to a session by id. - pub fn session(&self, id: Uuid) -> Result<&Session> { - self.sessions - .iter() - .find(|s| s.id == id) - .context(format!("Session {id} not found")) - } - - /// Read-only access to a session by name. - pub fn session_by_name(&self, name: &str) -> Result<&Session> { - self.sessions - .iter() - .find(|s| s.name == name) - .context(format!("Session {name:?} not found")) - } - - /// Find an item by name within a session. - pub fn item_by_name(&self, session_id: Uuid, name: &str) -> Result<&Item> { - let session = self.session(session_id)?; - session - .items - .iter() - .find(|i| i.name == name) - .context(format!( - "Item {name:?} not found in session {:?}", - session.name - )) - } - - fn session_mut(&mut self, id: Uuid) -> Result<&mut Session> { - self.sessions - .iter_mut() - .find(|s| s.id == id) - .context(format!("Session {id} not found")) - } - - fn session_pos(&self, id: Uuid) -> Result { - self.sessions - .iter() - .position(|s| s.id == id) - .context(format!("Session {id} not found")) - } -} - -fn item_pos(items: &[Item], id: Uuid) -> Result { - items - .iter() - .position(|i| i.id == id) - .context(format!("Item {id} not found")) -} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs deleted file mode 100644 index 330ed70..0000000 --- a/src/workspace/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/workspace/mod.rs -// -// Workspace subsystem: data model, operations, and persistence. - -pub mod manager; -pub mod model; diff --git a/src/workspace/model.rs b/src/workspace/model.rs deleted file mode 100644 index 4d0e008..0000000 --- a/src/workspace/model.rs +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/workspace/model.rs -// -// Pure data structures for the workspace, sessions, and their items. - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use uuid::Uuid; - -/// The document kind an item references. -/// -/// Each variant carries the path to the source file. Format-specific fields -/// (e.g. page index for PDFs) live on the variant so they can differ per kind. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ItemKind { - Portable { path: PathBuf }, - Raster { path: PathBuf }, - Vector { path: PathBuf }, - Document { path: PathBuf }, -} - -impl ItemKind { - /// Returns the file path regardless of the variant. - pub fn path(&self) -> &PathBuf { - match self { - ItemKind::Portable { path } - | ItemKind::Raster { path } - | ItemKind::Vector { path } - | ItemKind::Document { path } => path, - } - } -} - -/// A single entry in a session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Item { - pub id: Uuid, - pub name: String, - pub kind: ItemKind, - pub tags: Vec, -} - -impl Item { - pub fn new(name: impl Into, kind: ItemKind) -> Self { - Self { - id: Uuid::new_v4(), - name: name.into(), - kind, - tags: Vec::new(), - } - } - - pub fn with_tags(mut self, tags: Vec) -> Self { - self.tags = tags; - self - } -} - -/// Determines how a session behaves in the UI. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SessionType { - Browser, - Collection, -} - -/// A named group of items inside a workspace. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Session { - pub id: Uuid, - pub name: String, - pub session_type: SessionType, - pub items: Vec, -} - -impl Session { - pub fn new(name: impl Into, session_type: SessionType) -> Self { - Self { - id: Uuid::new_v4(), - name: name.into(), - session_type, - items: Vec::new(), - } - } -} - -/// The top-level container that is persisted as a `.ws.ron` file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Workspace { - pub name: String, - pub sessions: Vec, -} - -impl Workspace { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - sessions: Vec::new(), - } - } -} diff --git a/test.json b/test.json deleted file mode 100644 index 4dd1247..0000000 --- a/test.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { "NewWorkspace": "Demo Projekt" }, - - { "CreateSession": { "name": "Referenzen", "session_type": "browser" } }, - { - "AddItem": { - "session": "Referenzen", - "name": "Logo", - "kind": "vector", - "path": "tmp/pictures/logo.svg" - } - }, - { - "AddItem": { - "session": "Referenzen", - "name": "Foto Hintergrund", - "kind": "raster", - "path": "tmp/pictures/hero.jpg" - } - }, - { - "AddItem": { - "session": "Referenzen", - "name": "Handbuch", - "kind": "portable", - "path": "tmp/pictures/handbook.pdf" - } - }, - { "ViewSession": "Referenzen" }, - - { "SelectSession": "Referenzen" }, - { "SelectItem": 0 }, - "NextItem", - "NextItem", - "PreviousItem", - { "SelectItem": 0 }, - "MoveItemDown", - { "ViewSession": "Referenzen" }, - "MoveItemUp", - { "ViewSession": "Referenzen" }, - { "MoveItemToIndex": 2 }, - { "ViewSession": "Referenzen" }, - { "MoveItemToIndex": 0 }, - - { "CreateSession": { "name": "Favoriten", "session_type": "collection" } }, - { "CreateSession": { "name": "Archiv", "session_type": "collection" } }, - "ViewWorkspace", - - { "CopyItem": { "from": "Referenzen", "item": "Logo", "to": "Favoriten" } }, - { - "CopyItem": { - "from": "Referenzen", - "item": "Foto Hintergrund", - "to": "Favoriten" - } - }, - { "ViewSession": "Favoriten" }, - - { - "RenameItem": { - "session": "Favoriten", - "item": "Logo (copy)", - "name": "Hauptlogo" - } - }, - { "ViewSession": "Favoriten" }, - - { - "RemoveItem": { "session": "Favoriten", "item": "Foto Hintergrund (copy)" } - }, - { "ViewSession": "Favoriten" }, - - { "MoveItem": { "from": "Referenzen", "item": "Handbuch", "to": "Archiv" } }, - { "ViewSession": "Referenzen" }, - { "ViewSession": "Archiv" }, - - { "CopySession": "Referenzen" }, - "ViewWorkspace", - - { - "RenameSession": { - "session": "Referenzen (copy)", - "name": "Backup Referenzen" - } - }, - "ViewWorkspace", - - { "DeleteSession": "Archiv" }, - "ViewWorkspace", - - { "SaveWorkspace": "tmp/demo.ws.ron" }, - { "LoadWorkspace": "tmp/demo.ws.ron" }, - "ViewWorkspace" -]