diff --git a/Cargo.lock b/Cargo.lock index 6c41395309..3e06681256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,121 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid 1.22.0", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.0" @@ -28,6 +143,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.1", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -68,6 +184,31 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -87,9 +228,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -156,6 +297,26 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -173,6 +334,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -233,7 +400,7 @@ dependencies = [ "tokens", "tokio", "tokio-util", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "url", "url_utils", "uuid_utils", @@ -290,6 +457,36 @@ dependencies = [ "url", ] +[[package]] +name = "artcraft_native" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "artcraft_api_defs", + "artcraft_client", + "chrono", + "cookie", + "directories", + "eframe", + "egui", + "egui_extras", + "egui_kittest", + "enums", + "env_logger", + "image", + "log", + "open", + "reqwest 0.12.23", + "resvg", + "rfd", + "serde", + "serde_json", + "tokens", + "tokio", + "uuid 1.22.0", +] + [[package]] name = "artcraft_router" version = "0.0.1" @@ -320,6 +517,34 @@ dependencies = [ "wreq", ] +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "assertor" version = "0.0.3" @@ -335,7 +560,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -343,12 +568,11 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f2776ead772134d55b62dd45e59a79e21612d85d0af729b8b7d3967d601a62a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", - "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -372,52 +596,74 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.11.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10202063978b3351199d68f8b22c4e47e4b1b822f8d43fd862d5ea8c006b29a" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", "fastrand", "futures-lite", + "pin-project-lite", "slab", ] [[package]] -name = "async-io" -version = "2.4.0" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", + "blocking", + "futures-lite", +] + +[[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", "parking", "polling", - "rustix 0.38.44", + "rustix 1.1.5", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] [[package]] -name = "async-process" -version = "2.3.0" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[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", @@ -426,10 +672,9 @@ dependencies = [ "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-lite", - "rustix 0.38.44", - "tracing", + "rustix 1.1.5", ] [[package]] @@ -445,9 +690,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.10" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -455,10 +700,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 0.38.44", + "rustix 1.1.5", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -516,6 +761,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -591,14 +873,14 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.117", ] @@ -609,7 +891,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -618,6 +909,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bit_field" version = "0.10.2" @@ -632,9 +929,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -686,18 +983,18 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ "objc2 0.6.4", ] [[package]] name = "blocking" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -724,7 +1021,7 @@ version = "5.0.0-alpha.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "boring-sys2", "foreign-types 0.5.0", "libc", @@ -913,9 +1210,23 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.16.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] [[package]] name = "byteorder" @@ -944,7 +1255,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -963,6 +1274,57 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.63", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "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.4", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "camino" version = "1.1.9" @@ -1061,9 +1423,18 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] [[package]] name = "chrono" @@ -1117,6 +1488,15 @@ dependencies = [ "libloading 0.8.6", ] +[[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 = "cloudflare_errors" version = "0.0.1" @@ -1138,6 +1518,24 @@ dependencies = [ "cc", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1231,7 +1629,7 @@ dependencies = [ "serde_derive", "serde_json", "time", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "url", "wreq", ] @@ -1248,9 +1646,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -1262,15 +1660,28 @@ 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 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.0", - "core-graphics-types", + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] @@ -1281,24 +1692,44 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.0", - "core-graphics-types", + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "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 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.0", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.12" @@ -1458,6 +1889,12 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.20.10" @@ -1639,13 +2076,19 @@ dependencies = [ ] [[package]] -name = "dispatch2" -version = "0.3.0" +name = "dispatch" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", + "bitflags 2.13.2", + "block2 0.6.2", "libc", "objc2 0.6.4", ] @@ -1661,6 +2104,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.6", +] + [[package]] name = "dlopen2" version = "0.8.0" @@ -1686,9 +2138,9 @@ dependencies = [ [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -1699,7 +2151,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2646c2bb4993c86006961293208b69d86a2b254356889ddad826e9df3f684f9" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.35.0", "foldhash 0.1.5", "html5ever 0.35.0", @@ -1714,7 +2166,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.36.0", "foldhash 0.2.0", "html5ever 0.38.0", @@ -1729,6 +2181,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[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" @@ -1780,12 +2238,167 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash 0.8.12", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + [[package]] name = "ego-tree" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash 0.8.12", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools 0.15.0", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash 0.8.12", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash 0.8.12", + "egui", + "enum-map", + "image", + "itertools 0.15.0", + "log", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "egui", + "kittest", + "log", + "serde", + "toml 1.1.8+spec-1.1.0", +] + [[package]] name = "either" version = "1.12.0" @@ -1795,6 +2408,16 @@ dependencies = [ "serde", ] +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "embed-resource" version = "3.0.2" @@ -1826,15 +2449,35 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "enumflags2" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", @@ -1842,9 +2485,20 @@ dependencies = [ [[package]] name = "enumflags2_derive" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", @@ -1864,7 +2518,7 @@ dependencies = [ "sqlx-core", "sqlx-mysql", "strum", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "utoipa", ] @@ -1910,6 +2564,38 @@ dependencies = [ "log", ] +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash 0.8.12", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + [[package]] name = "equivalent" version = "1.0.1" @@ -1928,14 +2614,20 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "errors" version = "0.0.1" @@ -1954,6 +2646,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -1962,11 +2663,10 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1977,7 +2677,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "pin-project-lite", ] @@ -2031,6 +2731,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "fern" version = "0.7.1" @@ -2093,6 +2799,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "flume" version = "0.11.0" @@ -2122,6 +2834,39 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", + "serde", +] + +[[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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2272,9 +3017,9 @@ checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -2441,6 +3186,16 @@ dependencies = [ "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.5", + "windows-link 0.2.1", +] + [[package]] name = "getopts" version = "0.2.24" @@ -2459,7 +3214,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2538,20 +3293,31 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "libc", "libgit2-sys", "log", "url", ] +[[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 = "glib" version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -2592,12 +3358,106 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + [[package]] name = "glob" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading 0.8.6", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[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 = "gmicloud_client" version = "0.0.1" @@ -2726,6 +3586,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.5" @@ -2747,12 +3616,26 @@ dependencies = [ [[package]] name = "half" -version = "2.4.1" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", ] [[package]] @@ -2794,6 +3677,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2827,9 +3722,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hermit-abi" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3056,16 +3951,17 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.58" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core 0.51.1", + "windows-core 0.62.2", ] [[package]] @@ -3282,6 +4178,12 @@ dependencies = [ "test_utils", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imgref" version = "1.10.1" @@ -3408,6 +4310,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -3470,7 +4381,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.63", "walkdir", @@ -3478,10 +4389,62 @@ dependencies = [ ] [[package]] -name = "jni-sys" -version = "0.3.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -3500,9 +4463,9 @@ checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -3555,11 +4518,17 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kinovi_web_client" version = "0.0.1" @@ -3582,6 +4551,39 @@ dependencies = [ "wreq-util", ] +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -3644,9 +4646,9 @@ dependencies = [ [[package]] name = "libfuzzer-sys" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -3692,12 +4694,14 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "libc", + "plain", + "redox_syscall 0.9.4", ] [[package]] @@ -3724,10 +4728,16 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.14" +name = "linebender_resource_handle" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[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" @@ -3743,9 +4753,9 @@ checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "litrs" -version = "0.4.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -3758,9 +4768,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", "value-bag", @@ -3881,6 +4891,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.0" @@ -3971,7 +4990,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -3992,9 +5011,9 @@ dependencies = [ "gtk", "keyboard-types", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -4002,6 +5021,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap 2.13.0", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap 2.13.0", + "rustc-hash 1.1.0", + "thiserror 2.0.18", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -4025,8 +5081,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", - "jni-sys", + "bitflags 2.13.2", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -4034,13 +5090,19 @@ dependencies = [ "thiserror 1.0.63", ] +[[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.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -4049,6 +5111,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -4208,56 +5276,109 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-core-graphics", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-cloud-kit" -version = "0.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.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.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", ] [[package]] name = "objc2-core-graphics" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4266,22 +5387,58 @@ dependencies = [ [[package]] name = "objc2-core-image" -version = "0.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-location" -version = "0.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", ] [[package]] @@ -4305,20 +5462,21 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", + "dispatch", "libc", "objc2 0.5.2", ] [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", + "bitflags 2.13.2", + "block2 0.6.2", "libc", "objc2 0.6.4", "objc2-core-foundation", @@ -4326,22 +5484,34 @@ dependencies = [ [[package]] name = "objc2-io-surface" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "objc2 0.6.4", "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.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4353,7 +5523,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4362,44 +5532,100 @@ dependencies = [ [[package]] name = "objc2-quartz-core" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", +] + +[[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.3.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", - "objc2-user-notifications", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[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.3.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] @@ -4408,12 +5634,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", + "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] @@ -4460,7 +5686,7 @@ dependencies = [ "test_utils", "thiserror 2.0.18", "tokio", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "url", "uuid_utils", "wreq", @@ -4473,7 +5699,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4533,6 +5759,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -4612,7 +5848,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.2", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -4638,6 +5874,19 @@ dependencies = [ "base64ct", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4749,6 +5998,32 @@ 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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -4757,9 +6032,9 @@ checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -4793,6 +6068,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.7.0" @@ -4801,7 +6082,7 @@ checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.32.0", "serde", "time", ] @@ -4825,7 +6106,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -4834,17 +6115,31 @@ dependencies = [ [[package]] name = "polling" -version = "3.7.4" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.4.0", + "hermit-abi 0.5.3", "pin-project-lite", - "rustix 0.38.44", - "tracing", - "windows-sys 0.59.0", + "rustix 1.1.5", + "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 = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", ] [[package]] @@ -4855,9 +6150,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -5070,6 +6365,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5081,7 +6385,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls 0.23.36", "socket2 0.6.2", "thiserror 2.0.18", @@ -5101,7 +6405,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls 0.23.36", "rustls-pki-types", "slab", @@ -5317,6 +6621,17 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "read-progress-stream" version = "1.0.0" @@ -5339,20 +6654,28 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.2" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.14", "libredox", "thiserror 2.0.18", ] @@ -5404,6 +6727,12 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.23" @@ -5452,7 +6781,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.0", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.2", ] @@ -5491,13 +6820,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + [[package]] name = "rfd" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "block2 0.6.1", + "ashpd", + "block2 0.6.2", "dispatch2", "glib-sys", "gobject-sys", @@ -5505,10 +6852,12 @@ dependencies = [ "js-sys", "log", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", + "pollster", "raw-window-handle", + "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5520,6 +6869,9 @@ name = "rgb" version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +dependencies = [ + "bytemuck", +] [[package]] name = "ring" @@ -5565,6 +6917,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.6" @@ -5601,6 +6973,12 @@ dependencies = [ "serde_json", ] +[[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" @@ -5622,20 +7000,20 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys 0.4.14", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5736,6 +7114,24 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.18" @@ -5807,6 +7203,12 @@ dependencies = [ "hashbrown 0.13.2", ] +[[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" @@ -5869,8 +7271,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.0", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5892,7 +7294,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df44ba8a7ca7a4d28c589e04f526266ed76b6cc556e33fe69fa25de31939a65" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cssparser 0.35.0", "derive_more", "fxhash", @@ -5911,7 +7313,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cssparser 0.35.0", "derive_more", "fxhash", @@ -5930,7 +7332,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cssparser 0.36.0", "derive_more", "log", @@ -5938,11 +7340,17 @@ dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.1", "servo_arc", "smallvec", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.23" @@ -6049,9 +7457,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -6218,10 +7626,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.1" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -6241,6 +7650,16 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -6256,12 +7675,31 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.9" @@ -6271,11 +7709,95 @@ dependencies = [ "autocfg", ] +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.63", + "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.13.2", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "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" @@ -6313,7 +7835,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", "raw-window-handle", - "redox_syscall 0.5.2", + "redox_syscall 0.5.18", "wasm-bindgen", "web-sys", "windows-sys 0.59.0", @@ -6503,7 +8025,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", "base64 0.21.7", - "bitflags 2.11.0", + "bitflags 2.13.2", "byteorder", "bytes", "chrono", @@ -6546,7 +8068,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", "base64 0.21.7", - "bitflags 2.11.0", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -6607,6 +8129,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -6700,6 +8237,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[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 = "swift-rs" version = "1.0.7" @@ -6733,6 +8280,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6759,7 +8317,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6793,9 +8351,9 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.0", - "block2 0.6.1", - "core-foundation 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "core-foundation 0.10.1", "core-graphics 0.25.0", "crossbeam-channel", "dbus", @@ -6805,15 +8363,15 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", "ndk-sys", "objc2 0.6.4", - "objc2-app-kit", - "objc2-foundation 0.3.1", - "objc2-ui-kit", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "once_cell", "parking_lot", "percent-encoding", @@ -6821,7 +8379,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -6868,15 +8426,15 @@ dependencies = [ "heck 0.5.0", "http", "http-range", - "jni", + "jni 0.21.1", "libc", "log", "mime", "muda", "objc2 0.6.4", - "objc2-app-kit", - "objc2-foundation 0.3.1", - "objc2-ui-kit", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "objc2-web-kit", "percent-encoding", "plist", @@ -6899,7 +8457,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -7008,7 +8566,7 @@ dependencies = [ "dunce", "glob", "log", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "percent-encoding", "schemars", "serde", @@ -7018,7 +8576,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "url", ] @@ -7057,7 +8615,7 @@ dependencies = [ "fern", "log", "objc2 0.6.4", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "serde", "serde_json", "serde_repr", @@ -7076,8 +8634,8 @@ checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", - "objc2-app-kit", - "objc2-foundation 0.3.1", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "open", "schemars", "serde", @@ -7086,7 +8644,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -7119,9 +8677,9 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2 0.6.4", - "objc2-ui-kit", + "objc2-ui-kit 0.3.2", "objc2-web-kit", "raw-window-handle", "serde", @@ -7131,7 +8689,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -7142,10 +8700,10 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -7156,7 +8714,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -7191,7 +8749,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "url", "urlpattern", "uuid 1.22.0", @@ -7227,7 +8785,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -7361,6 +8919,32 @@ dependencies = [ "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", + "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 = "tinystr" version = "0.7.6" @@ -7511,14 +9095,14 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 1.0.1+spec-1.1.0", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.0", @@ -7535,9 +9119,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -7585,16 +9169,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.0", ] @@ -7607,9 +9191,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -7633,7 +9217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -7704,10 +9288,10 @@ dependencies = [ "libappindicator", "muda", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -7721,6 +9305,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[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 = "tungstenite" version = "0.28.0" @@ -7738,6 +9331,15 @@ dependencies = [ "utf-8", ] +[[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 = "typed-builder" version = "0.23.2" @@ -7781,13 +9383,13 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -7843,6 +9445,24 @@ version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +[[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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -7859,10 +9479,28 @@ dependencies = [ ] [[package]] -name = "unicode-segmentation" -version = "1.12.0" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" @@ -7947,6 +9585,33 @@ dependencies = [ "url", ] +[[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", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -8053,6 +9718,33 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -8106,9 +9798,9 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" @@ -8121,11 +9813,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -8134,7 +9826,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -8145,9 +9837,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -8158,19 +9850,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8178,22 +9871,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -8222,9 +9915,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -8252,17 +9945,152 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "hashbrown 0.15.2", "indexmap 2.13.0", "semver", ] [[package]] -name = "web-sys" -version = "0.3.102" +name = "wayland-backend" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "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.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -8309,6 +10137,22 @@ dependencies = [ "base64 0.22.1", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -8394,7 +10238,7 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -8418,7 +10262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -8428,6 +10272,125 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "portable-atomic", + "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 = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap 2.13.0", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "hashbrown 0.17.1", + "libloading 0.8.6", + "log", + "naga", + "naga-types", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "static_assertions", + "thiserror 2.0.18", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + [[package]] name = "whoami" version = "1.5.1" @@ -8476,9 +10439,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -8490,11 +10453,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -8507,12 +10482,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.51.1" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-targets 0.48.5", + "windows-core 0.62.2", ] [[package]] @@ -8524,8 +10499,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -8536,14 +10524,25 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -8552,9 +10551,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -8583,6 +10582,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.2" @@ -8590,8 +10599,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" dependencies = [ "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -8603,6 +10612,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -8612,6 +10630,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -8719,13 +10746,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", + "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.0", ] @@ -8738,6 +10765,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.2" @@ -8767,9 +10803,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -8791,9 +10827,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -8815,9 +10851,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -8827,9 +10863,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -8851,9 +10887,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -8875,9 +10911,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -8899,9 +10935,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -8927,6 +10963,57 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "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" @@ -8973,6 +11060,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -8990,7 +11083,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -9031,7 +11124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.2", "indexmap 2.13.0", "log", "serde", @@ -9190,7 +11283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", - "block2 0.6.1", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs", @@ -9201,14 +11294,14 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2 0.6.4", - "objc2-app-kit", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", - "objc2-ui-kit", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "objc2-web-kit", "once_cell", "percent-encoding", @@ -9221,7 +11314,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -9257,6 +11350,64 @@ dependencies = [ "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 0.8.6", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yansi" version = "1.0.1" @@ -9289,9 +11440,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -9303,13 +11454,13 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-core", "futures-lite", "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_repr", "tracing", @@ -9323,15 +11474,39 @@ dependencies = [ ] [[package]] -name = "zbus_macros" -version = "5.16.0" +name = "zbus-lockstep" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", "zbus_names", "zvariant", "zvariant_utils", @@ -9339,15 +11514,36 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.0", "zvariant", ] +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.0", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -9477,40 +11673,42 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.0", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.7", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 3.0.7", "winnow 1.0.0", ] diff --git a/Cargo.toml b/Cargo.toml index 8a6f7b1cb8..0d33a2f3b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" # Desktop application and its local build/test dependencies. members = [ + "artcraft-native", # Pure Rust (egui) desktop app; no Tauri or webview "crates/api_clients/artcraft/artcraft_api_defs", "crates/api_clients/artcraft/artcraft_client", "crates/api_clients/artcraft/artcraft_router", diff --git a/NOTICE b/NOTICE index 1b4688ade3..c061efac17 100644 --- a/NOTICE +++ b/NOTICE @@ -24,6 +24,9 @@ Font Awesome Free icons (frontend/libs/icons/src/lib/brand-icons.tsx, Copyright Fonticons, Inc. https://fontawesome.com/license/free Carter One font (frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json) — SIL Open Font License 1.1. https://fonts.google.com/specimen/Carter+One +Inter, Archivo and Geist Mono fonts (artcraft-native/assets/fonts/) — SIL Open Font License 1.1; + each font's license is next to it. https://rsms.me/inter/ · + https://fonts.google.com/specimen/Archivo · https://vercel.com/font Third-party trademarks diff --git a/artcraft-native/Cargo.toml b/artcraft-native/Cargo.toml new file mode 100644 index 0000000000..d2def584f7 --- /dev/null +++ b/artcraft-native/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "artcraft_native" +description = "ArtCraft desktop app in pure Rust: an egui UI over the ArtCraft API client (no Tauri, no webview)" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "artcraft-native" +path = "src/main.rs" + +[dependencies] +# Internal (Tauri-free) API crates shared with the Tauri app. +artcraft_api_defs.workspace = true +artcraft_client.workspace = true +tokens.workspace = true + +# External +anyhow.workspace = true +cookie.workspace = true +chrono.workspace = true +directories = "6.0.0" +eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } +egui = "0.36" +enums.workspace = true +egui_extras = { version = "0.36", default-features = false, features = ["image", "svg"] } +env_logger.workspace = true +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } +log.workspace = true +open = "5" +reqwest = { workspace = true, features = ["multipart"] } +resvg = "0.45" +rfd = "0.16" +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time", "fs"] } +uuid = { workspace = true, features = ["v4"] } + +# Pasting images from the clipboard (egui's own clipboard support is text only). +arboard = { version = "3", default-features = false, features = ["image-data"] } + +[dev-dependencies] +egui_kittest = "0.36" diff --git a/artcraft-native/README.md b/artcraft-native/README.md new file mode 100644 index 0000000000..5a771a2650 --- /dev/null +++ b/artcraft-native/README.md @@ -0,0 +1,106 @@ +# ArtCraft Native + +ArtCraft's desktop app in pure Rust: an [egui](https://github.com/emilk/egui) UI over the same +ArtCraft API client crates the Tauri app uses. There is no Tauri, no webview and no JavaScript. + +The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft-webapp`). That +means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for +display, Inter for body text and Geist Mono for labels. The brand blue marks the primary actions. + +Create Image and Create Video are fully built. The other tools show a "coming soon" page that +links to the same tool in the web app. + +## Running + +```bash +cargo run -p artcraft_native --release +``` + +The workspace's default member is the Tauri app, so pass `-p artcraft_native`. + +- **Sign-in.** On first launch the app reuses an existing ArtCraft login. It reads the session + cookie from the Tauri app's jar (`/ai.artcraft.app/.cookies`) and keeps its own copy in + `~/Artcraft/credentials/native_session.json`. Otherwise use "Sign in with browser" (device code) + or a username and password. +- **API host.** `~/Artcraft/settings/env_configs.json` selects it, exactly as for the Tauri app. + +## Architecture + +| Module | What it does | +|--------------------|--------------------------------------------------------------------------------| +| `backend` | Tokio runtime and every API call; results come back to the UI as `Event`s | +| `backend::wire` | Lenient response shapes; new server enum values can't break decoding | +| `backend::session` | Credentials: own file, Tauri cookie import, env config | +| `backend::media_cache` | Thumbnails and previews: downloaded and decoded off-thread, evicted when unused | +| `models` | Model catalog from OmniGen listings, plus names, families and limits | +| `prompt_box` | The shared prompt box: editor, reference deck, keyframes, pickers, model selector | +| `pages` | Create Image, Create Video, Home, Library, and the shared page shell | +| `feed` | Generation feed: running, failed and finished jobs, library paging, grid and list | +| `overlays` | Lightbox, library picker, sign-in and settings dialogs | +| `shell` | Sidebar and top bar | +| `theme`, `ui` | Colour and type tokens, Lucide-style vector icons, widgets, toasts | + +How the backend works: + +- **Generation.** It goes through OmniGen: `/v1/omni_gen/{models,cost,generate}/{image,video}`. + Model capabilities come from the server, so new models and options appear without an app update. +- **Typed client functions.** Model lists, uploads, credits, login and deletes use + `artcraft_client`. +- **Lenient decoding.** Generate and cost requests are JSON maps, as in the Tauri app. Job polling + (`/v1/jobs/session`), the library list, batches and prompts are decoded with lenient structs. +- **Identity headers.** Every request sends the desktop identity headers: `Origin` and + `User-Agent`. + +## Prompt box parity + +| Feature | Status | +|-----------------------------------------------------------------|-----------------------------------------| +| Auto-growing prompt (~4 lines), expand/collapse, resize grip | Done | +| Focus mode (fullscreen editor with deck and toolbar) | Done | +| Length counter, red over the model's limit | Done | +| Enter to generate (setting; Shift+Enter for a new line) | Done | +| `@Image1` / `@Video1` / `@Audio1` highlight and autocomplete | Done | +| Reference deck: fan, +N badge, hover-expand, reorder, clear all | Done | +| Keyframe slots: first/last frame, tilt, swap | Done | +| Upload, pick from library, drag & drop, paste image | Done (video uploads must be MP4) | +| Model selector with families, logos, capability badges | Done | +| Aspect ratio, resolution, quality, count | Done | +| Bitrate, output format, duration slider, sound, input mode | Done | +| Live credit cost on the generate button | Done | +| Validation: starting frame, text-only banner, limits, uploads | Done | +| Clear all (confirms when references are attached) | Done | +| `@Character` mentions and the Characters modal (Seedance 2.0) | Not yet | +| Provider choice (Midjourney direct, fal) | Not yet; everything runs via ArtCraft | +| In-app video and audio playback | Opens in the system player instead | +| Animated video previews in the feed | Still frames only | + +Feed and lightbox: + +- **Feed.** Pending cards show progress, time left and a batch banner. Failed cards show the reason + and can be dismissed. The grid is masonry, with a list view alongside. Hover actions are Recreate, + Make Video, Share and Download. Select mode does batch downloads. The last viewed item is marked. +- **Lightbox.** Prev/next with the arrow keys, prompt copy and details. Actions are Recreate, Make + Video, Download, Share and Delete. + +## Diagnostics + +Set `ARTCRAFT_SCREENSHOT=` to save a screenshot once the window has settled. +`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video` or `library`) picks the page, and +`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards. + +## Tests + +```bash +cargo test -p artcraft_native +``` + +There are unit tests for the catalog, feed store, wire decoding and backend helpers. Headless UI +tests (`egui_kittest`) drive the prompt box: generate gating, Enter to generate, the pickers, +`@` mentions and clear-all. + +## Fonts and brand assets + +`assets/fonts` holds Inter, Archivo and Geist Mono, all under the SIL Open Font License; the +licences sit next to the files. The wordmark, app icon and creator logos are the Tauri app's own +(`frontend/apps/artcraft/app/public/resources`, `crates/desktop/artcraft/icons`); see `NOTICE` +for their terms. diff --git a/artcraft-native/assets/fonts/Archivo-Variable.ttf b/artcraft-native/assets/fonts/Archivo-Variable.ttf new file mode 100644 index 0000000000..cc64253d36 Binary files /dev/null and b/artcraft-native/assets/fonts/Archivo-Variable.ttf differ diff --git a/artcraft-native/assets/fonts/GeistMono-Variable.ttf b/artcraft-native/assets/fonts/GeistMono-Variable.ttf new file mode 100644 index 0000000000..173867dce0 Binary files /dev/null and b/artcraft-native/assets/fonts/GeistMono-Variable.ttf differ diff --git a/artcraft-native/assets/fonts/Inter-Variable.ttf b/artcraft-native/assets/fonts/Inter-Variable.ttf new file mode 100644 index 0000000000..047c92f6e2 Binary files /dev/null and b/artcraft-native/assets/fonts/Inter-Variable.ttf differ diff --git a/artcraft-native/assets/fonts/OFL-Archivo.txt b/artcraft-native/assets/fonts/OFL-Archivo.txt new file mode 100644 index 0000000000..8597481ebc --- /dev/null +++ b/artcraft-native/assets/fonts/OFL-Archivo.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/artcraft-native/assets/fonts/OFL-GeistMono.txt b/artcraft-native/assets/fonts/OFL-GeistMono.txt new file mode 100644 index 0000000000..61f6f4b785 --- /dev/null +++ b/artcraft-native/assets/fonts/OFL-GeistMono.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/artcraft-native/assets/fonts/OFL-Inter.txt b/artcraft-native/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000000..9b2ca37b3f --- /dev/null +++ b/artcraft-native/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/artcraft-native/src/app.rs b/artcraft-native/src/app.rs new file mode 100644 index 0000000000..c866df6598 --- /dev/null +++ b/artcraft-native/src/app.rs @@ -0,0 +1,729 @@ +//! The app: owns the backend, the pages and the overlays; routes backend events to whoever is +//! waiting for them and page requests to the services that fulfil them. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use egui::{Id, Ui}; +use serde::{Deserialize, Serialize}; + +use crate::backend::media_cache::MediaCache; +use crate::backend::wire::{Prompt, SessionUser}; +use crate::backend::{Backend, Event, Modality, SHARE_URL_BASE}; +use crate::feed::grid::{RatioCache, ViewMode}; +use crate::feed::store::{FeedNotice, FeedStore}; +use crate::feed::types::{FeedItem, MediaKind}; +use crate::models::Catalog; +use crate::overlays::account::{LoginAction, LoginDialog, SettingsAction, settings_dialog}; +use crate::overlays::library_picker::{LibraryPicker, PickerAction}; +use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside}; +use crate::pages::common::{self, AppRequest, Env, FeedKey}; +use crate::pages::create_image::{CreateImagePage, ImageSettings}; +use crate::pages::create_video::{CreateVideoPage, VideoSettings}; +use crate::pages::other; +use crate::prompt_box::refs::{RefKind, RefMedia}; +use crate::shell::{self, Account, Page, ShellAction}; +use crate::theme; +use crate::ui::toast::Toasts; +use crate::ui::widgets; + +/// Builds the page environment from the app's fields (disjoint borrows, so pages can be +/// borrowed mutably alongside it). +macro_rules! env { + ($app:ident, $ctx:expr) => { + Env { ctx: $ctx.clone(), backend: &$app.backend, cache: &mut $app.cache, toasts: &mut $app.toasts, catalog: &$app.catalog, signed_in: $app.user.is_some(), enter_to_generate: $app.enter_to_generate, view_mode: $app.view_mode, ratios: &mut $app.ratios, prompts: &$app.prompts, requests: &mut $app.requests } + }; +} + +const STORAGE_KEY: &str = "artcraft-native"; +const JOB_POLL_INTERVAL: f64 = 5.0; +const CREDITS_INTERVAL: f64 = 60.0; +const MODEL_RETRY_INTERVAL: f64 = 15.0; +/// List view: prompt records fetched per frame at most. +const PROMPT_FETCHES_PER_FRAME: usize = 8; + +/// Preferences that survive restarts. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +struct Saved { + page: Page, + enter_to_generate: bool, + view_mode: ViewMode, + sidebar_open: bool, + image: ImageSettings, + video: VideoSettings, +} + +impl Default for Saved { + fn default() -> Self { + Self { page: Page::CreateImage, enter_to_generate: false, view_mode: ViewMode::Grid, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default() } + } +} + +pub struct ArtcraftApp { + backend: Backend, + cache: MediaCache, + toasts: Toasts, + catalog: Catalog, + user: Option, + credits: Option, + page: Page, + enter_to_generate: bool, + view_mode: ViewMode, + sidebar_open: bool, + image: CreateImagePage, + video: CreateVideoPage, + library: FeedStore, + ratios: RatioCache, + /// Prompt texts by prompt token (list view, lightbox). + prompts: HashMap, + prompt_records: HashMap, + requested_prompts: HashSet, + /// A Recreate waiting for its prompt record. + pending_recreate: Option<(MediaKind, String)>, + /// Which feed a batch expansion belongs to. + batch_kinds: HashMap, + lightbox: Option, + picker: Option, + login: Option, + settings_open: bool, + preview: Option, + logo: egui::TextureHandle, + requests: Vec, + screenshot: Option, + next_poll: f64, + next_credits: f64, + models_retry_at: Option, +} + +impl ArtcraftApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + theme::apply(&cc.egui_ctx); + egui_extras::install_image_loaders(&cc.egui_ctx); + let mut saved: Saved = cc.storage.and_then(|s| eframe::get_value(s, STORAGE_KEY)).unwrap_or_default(); + if let Some(page) = std::env::var("ARTCRAFT_PAGE").ok().and_then(|p| Page::from_slug(&p)) { + saved.page = page; + } + let backend = Backend::new(cc.egui_ctx.clone()); + let cache = MediaCache::new(backend.runtime(), backend.http()); + backend.load_models(Modality::Image); + backend.load_models(Modality::Video); + backend.refresh_session(); + Self { cache, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, settings_open: false, preview: None, logo: load_logo(&cc.egui_ctx), requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend } + } + + // --- Events from the backend ------------------------------------------------------------- + + fn handle_event(&mut self, ctx: &egui::Context, event: Event) { + match event { + Event::Session(user) => { + let changed = self.user.as_ref().map(|u| &u.username) != user.as_ref().map(|u| &u.username); + self.user = user; + if changed { + self.image.feed.reset(); + self.video.feed.reset(); + self.library.reset(); + self.next_poll = 0.0; + } + if self.user.is_some() { + self.login = None; + } else { + self.credits = None; + if let Some(login) = &mut self.login { + login.busy = false; + } + } + }, + Event::LoginFailed(message) => { + if let Some(login) = &mut self.login { + login.busy = false; + login.device = None; + login.error = Some(message); + } + }, + Event::DeviceLogin { url, code } => { + if let Some(login) = &mut self.login { + login.device = Some((url, code)); + } + }, + Event::Credits(credits) => self.credits = Some(credits), + Event::Models(modality, Ok(models)) => match modality { + Modality::Image => self.catalog.set_image(models), + Modality::Video => self.catalog.set_video(models), + }, + Event::Models(_, Err(message)) => { + if self.models_retry_at.is_none() { + self.toasts.error(format!("Couldn't load models: {message}")); + } + self.models_retry_at = Some(ctx.input(|i| i.time) + MODEL_RETRY_INTERVAL); + }, + Event::Cost { key, credits } => { + self.image.cost.apply(&key, credits); + self.video.cost.apply(&key, credits); + }, + Event::Enqueued { modality, job_tokens, meta } => { + let mut env = env!(self, ctx); + match modality { + Modality::Image => self.image.on_enqueued(&mut env, &job_tokens, &meta), + Modality::Video => self.video.on_enqueued(&mut env, &job_tokens, &meta), + } + self.next_poll = ctx.input(|i| i.time) + 2.0; + }, + Event::EnqueueFailed { modality, message } => { + let mut env = env!(self, ctx); + match modality { + Modality::Image => self.image.on_enqueue_failed(&mut env, &message), + Modality::Video => self.video.on_enqueue_failed(&mut env, &message), + } + }, + Event::Uploaded { ref_id, result } => { + if self.image.refs.find_mut(ref_id).is_some() { + common::apply_upload(&mut self.toasts, &mut self.image.refs, ref_id, result); + } else if common::apply_upload(&mut self.toasts, &mut self.video.refs, ref_id, result) { + let mut env = env!(self, ctx); + self.video.check_durations(&mut env, ref_id); + } + }, + Event::Jobs(jobs) => { + for (kind, notices) in [(MediaKind::Image, self.image.feed.apply_jobs(MediaKind::Image, &jobs, &self.catalog)), (MediaKind::Video, self.video.feed.apply_jobs(MediaKind::Video, &jobs, &self.catalog))] { + for notice in notices { + match notice { + FeedNotice::Completed => { + self.toasts.success(format!("{} generation complete!", kind.label())); + self.backend.refresh_credits(); + }, + FeedNotice::Failed(reason) => self.toasts.error(reason), + FeedNotice::LoadBatch { job_token, batch_token } => { + self.batch_kinds.insert(job_token.clone(), kind); + self.backend.load_batch(job_token, batch_token); + }, + } + } + } + }, + Event::LibraryPage { key, page, items, total_pages } => match key.as_str() { + "picker" => { + if let Some(p) = &mut self.picker { + p.apply_page(page, items, total_pages); + } + }, + "image" => self.image.feed.apply_library_page(MediaKind::Image, page, &items, total_pages), + "video" => self.video.feed.apply_library_page(MediaKind::Video, page, &items, total_pages), + _ => self.library.apply_library_page(MediaKind::Image, page, &items, total_pages), + }, + Event::LibraryFailed { key, message } => { + match key.as_str() { + "picker" => { + if let Some(p) = &mut self.picker { + p.loading = false; + p.has_more = false; + } + }, + "image" => self.image.feed.library_failed(), + "video" => self.video.feed.library_failed(), + _ => self.library.library_failed(), + } + self.toasts.error(format!("Couldn't load your library: {message}")); + }, + Event::Batch { job_token, items } => match self.batch_kinds.remove(&job_token) { + Some(MediaKind::Video) => self.video.feed.apply_batch(MediaKind::Video, &items), + _ => self.image.feed.apply_batch(MediaKind::Image, &items), + }, + Event::Prompt(prompt) => { + if let Some(text) = &prompt.maybe_positive_prompt { + self.prompts.insert(prompt.token.clone(), text.clone()); + } + if self.pending_recreate.as_ref().is_some_and(|(_, t)| *t == prompt.token) { + if let Some((kind, _)) = self.pending_recreate.take() { + self.apply_recreate(kind, &prompt); + } + } + self.prompt_records.insert(prompt.token.clone(), prompt); + }, + Event::Deleted(token) => { + for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] { + feed.remove_item(&token); + } + if self.lightbox.as_ref().is_some_and(|l| l.token == token) { + self.lightbox = None; + } + }, + Event::Saved(path) => self.toasts.success(format!("Downloaded {}", common::file_name(&path))), + Event::Toast { error, message } => { + if error { + self.toasts.error(message); + } else { + self.toasts.success(message); + } + }, + } + } + + // --- Requests from the pages ------------------------------------------------------------- + + fn handle_request(&mut self, ctx: &egui::Context, request: AppRequest) { + match request { + AppRequest::SignIn => self.open_login(), + AppRequest::Open { kind, token } => { + if let Some(item) = self.find_item(&token) { + self.request_prompt(item.prompt_token.clone()); + } + self.lightbox = Some(Lightbox::new(kind, token)); + }, + AppRequest::PickFromLibrary { kind, slot, max, page } => { + let Some(user) = &self.user else { + self.open_login(); + return; + }; + let refs = if page == MediaKind::Image { &self.image.refs } else { &self.video.refs }; + let attached: HashSet = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).filter_map(|r| r.token.clone()).collect(); + let mut picker = LibraryPicker::new(kind, slot, page, max, attached); + picker.loading = true; + self.backend.load_library("picker".to_owned(), user.username.clone(), picker.media_class(), 0); + self.picker = Some(picker); + }, + AppRequest::MakeVideo { token } => { + let Some(item) = self.find_item(&token) else { + return; + }; + let image = RefMedia::from_library(RefKind::Image, item.token.clone(), item.thumbnail.clone(), Some(item.full_url.clone()), 0.0); + self.video.set_start_image(image); + self.lightbox = None; + self.page = Page::CreateVideo; + }, + AppRequest::Recreate { kind, token } => { + let Some(prompt_token) = self.find_item(&token).and_then(|i| i.prompt_token.clone()) else { + self.toasts.error("Could not load the original prompt."); + return; + }; + match self.prompt_records.get(&prompt_token).cloned() { + Some(prompt) => self.apply_recreate(kind, &prompt), + None => { + self.pending_recreate = Some((kind, prompt_token.clone())); + self.requested_prompts.insert(prompt_token.clone()); + self.backend.load_prompt(prompt_token); + }, + } + }, + AppRequest::Share(token) => { + ctx.copy_text(format!("{SHARE_URL_BASE}{token}")); + self.toasts.success("Share link copied"); + }, + AppRequest::CopyText(text) => { + ctx.copy_text(text); + self.toasts.success("Prompt copied"); + }, + AppRequest::Download(tokens) => self.download(&tokens), + AppRequest::Preview(url) => self.preview = Some(url), + AppRequest::LoadMoreLibrary(key) => { + let Some(user) = &self.user else { + return; + }; + let (feed, name) = match key { + FeedKey::Image => (&mut self.image.feed, "image"), + FeedKey::Video => (&mut self.video.feed, "video"), + FeedKey::Library => (&mut self.library, "library"), + }; + if let Some(page) = feed.next_library_page() { + self.backend.load_library(name.to_owned(), user.username.clone(), key.media_classes(), page); + } + }, + } + } + + fn apply_recreate(&mut self, kind: MediaKind, prompt: &Prompt) { + match kind { + MediaKind::Image => { + self.image.apply_prompt(prompt); + self.page = Page::CreateImage; + }, + MediaKind::Video => { + self.video.apply_prompt(prompt); + self.page = Page::CreateVideo; + }, + } + self.lightbox = None; + } + + fn download(&mut self, tokens: &[String]) { + let items: Vec = tokens.iter().filter_map(|t| self.find_item(t).cloned()).collect(); + match items.as_slice() { + [] => {}, + [item] => { + let name = file_name_for(item); + if let Some(path) = rfd::FileDialog::new().set_file_name(&name).save_file() { + self.backend.save_url(item.full_url.clone(), path); + } + }, + many => { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + for item in many { + self.backend.save_url(item.full_url.clone(), dir.join(file_name_for(item))); + } + for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] { + feed.selecting = false; + feed.selected.clear(); + } + } + }, + } + } + + fn find_item(&self, token: &str) -> Option<&FeedItem> { + self.image.feed.find(token).or_else(|| self.video.feed.find(token)).or_else(|| self.library.find(token)) + } + + fn request_prompt(&mut self, prompt_token: Option) { + if let Some(t) = prompt_token { + if self.requested_prompts.insert(t.clone()) { + self.backend.load_prompt(t); + } + } + } + + fn open_login(&mut self) { + if self.login.is_none() { + self.login = Some(LoginDialog::default()); + } + } + + /// The feed the top bar's toggles act on. + fn current_feed(&mut self) -> Option<&mut FeedStore> { + match self.page { + Page::CreateImage => Some(&mut self.image.feed), + Page::CreateVideo => Some(&mut self.video.feed), + Page::Library => Some(&mut self.library), + _ => None, + } + } + + // --- Frame ------------------------------------------------------------------------------- + + fn timers(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if self.user.is_some() && now >= self.next_poll { + self.next_poll = now + JOB_POLL_INTERVAL; + self.backend.poll_jobs(); + } + if self.user.is_some() && now >= self.next_credits { + self.next_credits = now + CREDITS_INTERVAL; + self.backend.refresh_credits(); + } + if self.models_retry_at.is_some_and(|t| now >= t) { + self.models_retry_at = None; + if self.catalog.image.is_empty() { + self.backend.load_models(Modality::Image); + } + if self.catalog.video.is_empty() { + self.backend.load_models(Modality::Video); + } + } + ctx.request_repaint_after(Duration::from_secs(1)); + } + + fn shell(&mut self, ui: &mut Ui) { + let account = Account { username: self.user.as_ref().map(|u| u.username.as_str()), display_name: self.user.as_ref().map(|u| u.display_name.as_str()).filter(|n| !n.is_empty()), credits: self.credits }; + let mut actions = Vec::new(); + if self.sidebar_open { + egui::Panel::left("sidebar").exact_size(shell::sidebar_width()).resizable(false).frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| { + actions.extend(shell::sidebar(ui, self.page, &account, &self.logo)); + }); + } + let toggles = match self.page { + Page::CreateImage => Some((self.view_mode, self.image.feed.selecting)), + Page::CreateVideo => Some((self.view_mode, self.video.feed.selecting)), + Page::Library => Some((self.view_mode, self.library.selecting)), + _ => None, + }; + egui::Panel::top("topbar").exact_size(shell::topbar_height()).resizable(false).frame(egui::Frame::NONE).show(ui, |ui| { + actions.extend(shell::topbar(ui, self.page, &account, toggles, self.sidebar_open)); + }); + for action in actions { + match action { + ShellAction::Navigate(page) => { + self.page = page; + self.lightbox = None; + }, + ShellAction::ToggleSidebar => self.sidebar_open = !self.sidebar_open, + ShellAction::SignIn => self.open_login(), + ShellAction::SignOut => { + self.backend.logout(); + self.settings_open = false; + }, + ShellAction::OpenSettings => self.settings_open = true, + ShellAction::SetViewMode(mode) => self.view_mode = mode, + ShellAction::ToggleSelect => { + if let Some(feed) = self.current_feed() { + feed.selecting = !feed.selecting; + feed.selected.clear(); + } + }, + } + } + } + + fn page_ui(&mut self, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| { + let mut env = env!(self, ctx); + match self.page { + Page::Home => { + if let Some(page) = other::home(ui, env.signed_in, env.requests) { + self.page = page; + } + }, + Page::CreateImage => self.image.ui(ui, &mut env), + Page::CreateVideo => self.video.ui(ui, &mut env), + Page::Library => other::library(ui, &mut env, &mut self.library), + page => other::coming_soon(ui, page), + } + }); + self.prefetch_list_prompts(); + } + + /// The list view shows prompt texts: fetch the records for the visible feed, a few at a time. + fn prefetch_list_prompts(&mut self) { + if self.view_mode != ViewMode::List { + return; + } + let feed = match self.page { + Page::CreateImage => &self.image.feed, + Page::CreateVideo => &self.video.feed, + Page::Library => &self.library, + _ => return, + }; + let wanted: Vec = feed.items.iter().filter_map(|i| i.prompt_token.clone()).filter(|t| !self.requested_prompts.contains(t)).take(PROMPT_FETCHES_PER_FRAME).collect(); + for token in wanted { + self.request_prompt(Some(token)); + } + } + + fn overlays(&mut self, ctx: &egui::Context) { + if let Some(lb) = &mut self.lightbox { + let feed = match lb.kind { + _ if self.page == Page::Library => &self.library, + MediaKind::Image => &self.image.feed, + MediaKind::Video => &self.video.feed, + }; + let order = feed.ordered_tokens(); + let item = self.image.feed.find(&lb.token).or_else(|| self.video.feed.find(&lb.token)).or_else(|| self.library.find(&lb.token)).cloned(); + match item { + Some(item) => { + let prompt = item.prompt_token.as_ref().and_then(|t| self.prompt_records.get(t)); + let actions = lb.show(ctx, &item, &order, prompt, &self.catalog, &mut self.cache); + for action in actions { + self.lightbox_action(ctx, action, &item); + } + }, + None => self.lightbox = None, + } + } + if let Some(picker) = &mut self.picker { + for action in picker.show(ctx, &mut self.cache) { + match action { + PickerAction::Close => self.picker = None, + PickerAction::LoadMore => { + if let (Some(p), Some(user)) = (self.picker.as_mut(), self.user.as_ref()) { + if !p.loading { + p.loading = true; + self.backend.load_library("picker".to_owned(), user.username.clone(), p.media_class(), p.next_page); + } + } + }, + PickerAction::Confirm(picks) => { + if let Some(p) = self.picker.take() { + let refs = if p.page == MediaKind::Image { &mut self.image.refs } else { &mut self.video.refs }; + common::attach_from_library(refs, p.kind, p.slot, picks); + } + }, + } + if self.picker.is_none() { + break; + } + } + } + if let Some(login) = &mut self.login { + match login.show(ctx) { + Some(LoginAction::Close) => self.login = None, + Some(LoginAction::Password) => { + login.busy = true; + login.error = None; + self.backend.login_with_password(login.username.trim().to_owned(), std::mem::take(&mut login.password)); + }, + Some(LoginAction::Browser) => { + login.error = None; + self.backend.login_with_browser(); + }, + Some(LoginAction::CancelBrowser) => { + self.backend.cancel_browser_login(); + login.device = None; + }, + None => {}, + } + } + if self.settings_open { + let username = self.user.as_ref().map(|u| u.username.clone()); + match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, self.backend.data_root()) { + Some(SettingsAction::Close) => self.settings_open = false, + Some(SettingsAction::SignOut) => { + self.settings_open = false; + self.backend.logout(); + }, + Some(SettingsAction::SignIn) => { + self.settings_open = false; + self.open_login(); + }, + None => {}, + } + } + if let Some(url) = self.preview.clone() { + let close = widgets::modal(ctx, Id::new("preview"), (ctx.content_rect().width() * 0.9).min(1100.0), |ui| { + let bounds = egui::vec2(ui.available_width(), ctx.content_rect().height() * 0.8); + match self.cache.get(ctx, &url) { + crate::backend::media_cache::Lookup::Ready(t) => { + let size = fit_inside(t.size_vec2(), bounds); + ui.vertical_centered(|ui| ui.add(egui::Image::new(&t).fit_to_exact_size(size))); + }, + _ => { + ui.add_sized(bounds, egui::Spinner::new()); + }, + } + }); + if close { + self.preview = None; + } + } + } + + fn lightbox_action(&mut self, ctx: &egui::Context, action: LightboxAction, item: &FeedItem) { + match action { + LightboxAction::Close => self.lightbox = None, + LightboxAction::Navigate(token) => { + if let Some(next) = self.find_item(&token).cloned() { + self.request_prompt(next.prompt_token); + } + for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] { + if feed.find(&token).is_some() { + feed.last_viewed = Some(token.clone()); + } + } + if let Some(lb) = &mut self.lightbox { + lb.token = token; + } + }, + LightboxAction::Recreate => self.handle_request(ctx, AppRequest::Recreate { kind: item.kind, token: item.token.clone() }), + LightboxAction::MakeVideo => self.handle_request(ctx, AppRequest::MakeVideo { token: item.token.clone() }), + LightboxAction::Download => self.download(std::slice::from_ref(&item.token)), + LightboxAction::Share => self.handle_request(ctx, AppRequest::Share(item.token.clone())), + LightboxAction::Delete => { + self.backend.delete_media(item.token.clone()); + self.lightbox = None; + }, + LightboxAction::CopyPrompt(text) => self.handle_request(ctx, AppRequest::CopyText(text)), + LightboxAction::Play(url) => { + if let Err(err) = open::that(&url) { + self.toasts.error(format!("Couldn't open the video: {err}")); + } + }, + } + } +} + +impl eframe::App for ArtcraftApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + for event in self.backend.drain() { + self.handle_event(ctx, event); + } + self.timers(ctx); + } + + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + self.cache.poll(&ctx); + self.shell(ui); + self.page_ui(ui); + for request in std::mem::take(&mut self.requests) { + self.handle_request(&ctx, request); + } + self.overlays(&ctx); + self.toasts.show(&ctx); + if let Some(shot) = &mut self.screenshot { + shot.tick(&ctx); + } + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + let saved = Saved { page: self.page, enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone() }; + eframe::set_value(storage, STORAGE_KEY, &saved); + } + + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + theme::BG.to_normalized_gamma_f32() + } +} + +/// Diagnostics: `ARTCRAFT_SCREENSHOT=` saves a screenshot once the window has settled +/// (and `ARTCRAFT_SCREENSHOT_EXIT=1` then closes the app), for checking the UI headlessly. +struct ScreenshotRequest { + path: std::path::PathBuf, + exit: bool, + frames: u32, + requested: bool, +} + +impl ScreenshotRequest { + /// Frames to wait before capturing (models, session and thumbnails get a moment to load). + const SETTLE_SECS: f64 = 8.0; + + fn from_env() -> Option { + let path = std::env::var_os("ARTCRAFT_SCREENSHOT")?.into(); + Some(Self { path, exit: std::env::var_os("ARTCRAFT_SCREENSHOT_EXIT").is_some(), frames: 0, requested: false }) + } + + fn tick(&mut self, ctx: &egui::Context) { + self.frames += 1; + ctx.request_repaint(); + if !self.requested && ctx.input(|i| i.time) >= Self::SETTLE_SECS { + self.requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + let image = ctx.input(|i| { + i.raw.events.iter().find_map(|e| match e { + egui::Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }); + if let Some(image) = image { + let [w, h] = image.size; + let pixels: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + match image::RgbaImage::from_raw(w as u32, h as u32, pixels).map(|img| img.save(&self.path)) { + Some(Ok(())) => log::info!("Saved a screenshot to {}", self.path.display()), + other => log::error!("Couldn't save the screenshot: {other:?}"), + } + if self.exit { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } +} + +/// `artcraft_.`, the extension taken from the URL. +fn file_name_for(item: &FeedItem) -> String { + let ext = item.full_url.rsplit('.').next().filter(|e| e.len() <= 4 && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(if item.kind == MediaKind::Video { "mp4" } else { "png" }); + format!("artcraft_{}.{ext}", item.token) +} + +/// The ArtCraft wordmark (the Tauri app's white-on-dark SVG), rasterized for the sidebar. +fn load_logo(ctx: &egui::Context) -> egui::TextureHandle { + const LOGO: &[u8] = include_bytes!("../../frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg"); + /// Twice the 20 pt the sidebar draws it at, for high-DPI screens. + const HEIGHT_PX: f32 = 48.0; + let image = resvg::usvg::Tree::from_data(LOGO, &resvg::usvg::Options::default()).ok().and_then(|tree| { + let scale = HEIGHT_PX / tree.size().height(); + let (w, h) = ((tree.size().width() * scale).ceil() as u32, HEIGHT_PX as u32); + let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?; + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut()); + Some(egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pixmap.data())) + }); + ctx.load_texture("artcraft-logo", image.unwrap_or_else(|| egui::ColorImage::filled([1, 1], egui::Color32::TRANSPARENT)), egui::TextureOptions::LINEAR) +} diff --git a/artcraft-native/src/backend/media_cache.rs b/artcraft-native/src/backend/media_cache.rs new file mode 100644 index 0000000000..34bded086a --- /dev/null +++ b/artcraft-native/src/backend/media_cache.rs @@ -0,0 +1,153 @@ +//! Images shown in the UI: CDN thumbnails and local previews, as GPU textures. +//! +//! Downloads run on the tokio runtime (a few at a time) and decode off the UI thread; the UI +//! uploads finished images in [`MediaCache::poll`]. Textures nobody has drawn for a while are +//! dropped once the cache grows past its budget, so long feeds don't hold every image forever. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use egui::{ColorImage, TextureHandle, TextureOptions}; +use log::warn; +use tokio::runtime::Handle; +use tokio::sync::Semaphore; + +/// How many images may download at once. +const MAX_CONCURRENT_DOWNLOADS: usize = 6; +/// Longest side of a decoded image; larger ones are downscaled before upload. +const MAX_DECODED_SIDE: u32 = 2048; +/// Textures kept even when unused (roughly two screens of thumbnails). +const SOFT_CAPACITY: usize = 160; +/// Frames an image may go undrawn before it can be evicted. +const EVICT_AFTER_FRAMES: u64 = 600; + +pub struct MediaCache { + rt: Handle, + http: reqwest::Client, + limiter: Arc, + tx: Sender, + rx: Receiver, + entries: HashMap, + frame: u64, +} + +/// What [`MediaCache::get`] knows about an image. +pub enum Lookup { + Loading, + Ready(TextureHandle), + Failed, +} + +enum Entry { + Loading { last_used: u64 }, + Ready { texture: TextureHandle, last_used: u64 }, + Failed, +} + +struct Loaded { + key: String, + image: Option, +} + +impl MediaCache { + pub fn new(rt: Handle, http: reqwest::Client) -> Self { + let (tx, rx) = channel(); + Self { rt, http, limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)), tx, rx, entries: HashMap::new(), frame: 0 } + } + + /// The texture for `url`, starting its download the first time it's asked for. + pub fn get(&mut self, ctx: &egui::Context, url: &str) -> Lookup { + let frame = self.frame; + match self.entries.get_mut(url) { + Some(Entry::Ready { texture, last_used }) => { + *last_used = frame; + Lookup::Ready(texture.clone()) + }, + Some(Entry::Loading { last_used }) => { + *last_used = frame; + Lookup::Loading + }, + Some(Entry::Failed) => Lookup::Failed, + None => { + self.entries.insert(url.to_owned(), Entry::Loading { last_used: frame }); + self.spawn_download(ctx, url.to_owned()); + Lookup::Loading + }, + } + } + + /// Decodes `bytes` off the UI thread and registers the result under `key`. + pub fn insert_bytes(&mut self, ctx: &egui::Context, key: &str, bytes: Arc<[u8]>) { + if self.entries.contains_key(key) { + return; + } + self.entries.insert(key.to_owned(), Entry::Loading { last_used: self.frame }); + let (tx, ctx, key) = (self.tx.clone(), ctx.clone(), key.to_owned()); + self.rt.spawn_blocking(move || { + let image = decode(&bytes); + let _ = tx.send(Loaded { key, image }); + ctx.request_repaint(); + }); + } + + /// Uploads finished downloads and evicts stale textures. Call once per frame. + pub fn poll(&mut self, ctx: &egui::Context) { + self.frame += 1; + while let Ok(Loaded { key, image }) = self.rx.try_recv() { + // Dropped while loading (evicted): don't resurrect it. + let Some(entry) = self.entries.get_mut(&key) else { + continue; + }; + *entry = match image { + Some(image) => Entry::Ready { texture: ctx.load_texture(&key, image, TextureOptions::LINEAR), last_used: self.frame }, + None => Entry::Failed, + }; + } + if self.entries.len() > SOFT_CAPACITY { + let frame = self.frame; + self.entries.retain(|_, e| match e { + Entry::Ready { last_used, .. } | Entry::Loading { last_used } => frame.saturating_sub(*last_used) < EVICT_AFTER_FRAMES, + Entry::Failed => true, + }); + } + } + + fn spawn_download(&self, ctx: &egui::Context, url: String) { + let (http, limiter, tx, ctx) = (self.http.clone(), self.limiter.clone(), self.tx.clone(), ctx.clone()); + self.rt.spawn(async move { + let image = match limiter.acquire_owned().await { + Ok(_permit) => download(&http, &url).await, + Err(_) => None, + }; + let _ = tx.send(Loaded { key: url, image }); + ctx.request_repaint(); + }); + } +} + +async fn download(http: &reqwest::Client, url: &str) -> Option { + let response = match http.get(url).send().await.and_then(|r| r.error_for_status()) { + Ok(response) => response, + Err(err) => { + warn!("Image download failed for {url}: {err}"); + return None; + }, + }; + let bytes = response.bytes().await.ok()?; + tokio::task::spawn_blocking(move || decode(&bytes)).await.ok().flatten() +} + +fn decode(bytes: &[u8]) -> Option { + let image = match image::load_from_memory(bytes) { + Ok(image) => image, + Err(err) => { + warn!("Image decode failed: {err}"); + return None; + }, + }; + let image = if image.width().max(image.height()) > MAX_DECODED_SIDE { image.thumbnail(MAX_DECODED_SIDE, MAX_DECODED_SIDE) } else { image }; + let rgba = image.to_rgba8(); + let size = [rgba.width() as usize, rgba.height() as usize]; + Some(ColorImage::from_rgba_unmultiplied(size, rgba.as_raw())) +} diff --git a/artcraft-native/src/backend/mod.rs b/artcraft-native/src/backend/mod.rs new file mode 100644 index 0000000000..669b7a2269 --- /dev/null +++ b/artcraft-native/src/backend/mod.rs @@ -0,0 +1,734 @@ +//! Everything that talks to the ArtCraft API. Calls run on a tokio runtime and report back as +//! [`Event`]s, which the UI drains once per frame; nothing here blocks the UI thread. + +pub mod media_cache; +pub mod session; +pub mod wire; + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use artcraft_api_defs::users::login_challenges::LoginChallengeState; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::endpoints::credits::get_session_credits::get_session_credits; +use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{ImageType, UploadImageBytesArgs, upload_image_media_file_from_bytes}; +use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{UploadImageFromFileArgs, upload_image_media_file_from_file}; +use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{UploadVideoFromFileArgs, upload_video_media_file_from_file}; +use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{OmniGenListImageModelsArgs, omni_gen_list_image_models}; +use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{OmniGenListVideoModelsArgs, omni_gen_list_video_models}; +use artcraft_client::endpoints::users::password_login::PasswordLoginRequest; +use artcraft_client::error::api_error::ApiError; +use artcraft_client::error::storyteller_error::StorytellerError; +use artcraft_client::utils::api_host::ApiHost; +use artcraft_client::utils::basic_json_post_request::basic_json_post_request; +use artcraft_client::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError}; +use enums::common::payments_namespace::PaymentsNamespace; +use log::{error, warn}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; +use tokens::tokens::media_files::MediaFileToken; + +use crate::feed::types::MediaKind; +use crate::models::{self, ModelInfo}; +use session::Credentials; +use wire::{BatchMedia, MediaFile, PromptResponse, SessionInfo, SessionJobs, SessionUser, UploadResponse, UserMediaList}; + +/// Items per library page (`useGalleryData`'s `PAGE_SIZE`). +pub const LIBRARY_PAGE_SIZE: u32 = 40; +/// Model listings are retried a few times before giving up (`list_*_models_command`). +const MODEL_LIST_ATTEMPTS: u32 = 3; +const MODEL_LIST_BACKOFF: Duration = Duration::from_millis(250); +/// The share link base (`SHARE_URL_BASE`). +pub const SHARE_URL_BASE: &str = "https://getartcraft.com/media/"; +/// Sent on every request (`artcraft_client_identity::ARTCRAFT_DESKTOP_USER_AGENT`). +const USER_AGENT: &str = "storyteller-client/1.0"; + +/// Which media an operation is for (OmniGen's modality in the URL). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Modality { + Image, + Video, +} + +impl Modality { + fn path(self) -> &'static str { + match self { + Modality::Image => "image", + Modality::Video => "video", + } + } +} + +impl From for Modality { + fn from(kind: MediaKind) -> Self { + match kind { + MediaKind::Image => Modality::Image, + MediaKind::Video => Modality::Video, + } + } +} + +/// A result coming back from the backend. +pub enum Event { + /// The session check finished: `Some` when signed in. + Session(Option), + LoginFailed(String), + /// A browser sign-in started: show the code while we wait. + DeviceLogin { + url: String, + code: String, + }, + Credits(u64), + Models(Modality, Result, String>), + /// A cost estimate for the request identified by `key`. + Cost { + key: String, + credits: Option, + }, + Enqueued { + modality: Modality, + job_tokens: Vec, + meta: EnqueueMeta, + }, + EnqueueFailed { + modality: Modality, + message: String, + }, + Uploaded { + ref_id: u64, + result: Result, + }, + Jobs(Vec), + LibraryPage { + key: String, + page: u32, + items: Vec, + total_pages: u32, + }, + LibraryFailed { + key: String, + message: String, + }, + Batch { + job_token: String, + items: Vec, + }, + Prompt(wire::Prompt), + Deleted(String), + Saved(PathBuf), + Toast { + error: bool, + message: String, + }, +} + +/// What the UI remembers about a request it just sent, to label the pending card. +#[derive(Clone, Debug)] +pub struct EnqueueMeta { + pub prompt: String, + pub model_id: String, + pub batch_count: u32, + pub ref_image: Option, +} + +/// A finished upload: the media token, plus what the server knows about the file. +#[derive(Clone, Debug)] +pub struct UploadedMedia { + pub token: String, + pub duration_secs: f32, + pub thumbnail: Option, + pub full_url: Option, +} + +pub struct Backend { + rt: tokio::runtime::Runtime, + ctx: egui::Context, + tx: Sender, + rx: Receiver, + host: ApiHost, + root: PathBuf, + creds: Arc>>, + http: reqwest::Client, + device_login_cancel: Arc, +} + +impl Backend { + pub fn new(ctx: egui::Context) -> Self { + let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(4).thread_name("artcraft-io").enable_all().build().expect("tokio runtime"); + let (tx, rx) = channel(); + let root = session::data_root(); + let host = session::api_host(&root); + let creds = session::load(&root); + let http = reqwest::Client::builder().user_agent(USER_AGENT).gzip(true).timeout(Duration::from_secs(60)).build().unwrap_or_default(); + Self { rt, ctx, tx, rx, host, root, creds: Arc::new(RwLock::new(creds)), http, device_login_cancel: Arc::new(AtomicBool::new(false)) } + } + + pub fn runtime(&self) -> tokio::runtime::Handle { + self.rt.handle().clone() + } + + /// A plain HTTP client for CDN downloads (thumbnails, files). + pub fn http(&self) -> reqwest::Client { + self.http.clone() + } + + pub fn data_root(&self) -> &Path { + &self.root + } + + /// Events that arrived since the last call. + pub fn drain(&self) -> Vec { + self.rx.try_iter().collect() + } + + // --- Session ----------------------------------------------------------------------------- + + /// Checks who's signed in (`GET /v1/session`), then fetches their credits. + pub fn refresh_session(&self) { + let api = self.api(); + self.spawn(move |tx| async move { + if api.creds.is_none() { + let _ = tx.send(Event::Session(None)); + return; + } + match api.get_json::("/v1/session").await { + Ok(info) if info.logged_in => { + let _ = tx.send(Event::Session(info.user)); + api.send_credits(&tx).await; + }, + Ok(_) => { + let _ = tx.send(Event::Session(None)); + }, + Err(err) => { + warn!("Session check failed: {err}"); + let _ = tx.send(Event::Session(None)); + let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't reach ArtCraft: {err}") }); + }, + } + }); + } + + pub fn refresh_credits(&self) { + let api = self.api(); + self.spawn(move |tx| async move { api.send_credits(&tx).await }); + } + + /// Signs in with a username (or email) and password (`POST /v1/login`). + pub fn login_with_password(&self, username_or_email: String, password: String) { + let (host, root, creds, ctx) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.ctx.clone()); + let this = self.handle(); + self.spawn(move |tx| async move { + let result = async { + let client = LoginChallengeClient::new(&host).map_err(login_error)?; + let (response, set_cookie) = client.password_login(&PasswordLoginRequest { username_or_email, password }).await.map_err(login_error)?; + response.signed_session.filter(|s| !s.is_empty()).or_else(|| set_cookie.as_deref().and_then(session_from_set_cookie)).ok_or_else(|| "The server didn't return a session.".to_owned()) + } + .await; + match result { + Ok(session) => { + store_session(&root, &creds, session); + this.refresh_session(); + }, + Err(message) => { + let _ = tx.send(Event::LoginFailed(message)); + }, + } + ctx.request_repaint(); + }); + } + + /// Signs in through the browser: shows a code, opens the approval page, and waits. + pub fn login_with_browser(&self) { + self.device_login_cancel.store(false, Ordering::Relaxed); + let (host, root, creds, cancel) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.device_login_cancel.clone()); + let this = self.handle(); + self.spawn(move |tx| async move { + let client = match LoginChallengeClient::new(&host) { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Event::LoginFailed(login_error(err))); + return; + }, + }; + let challenge = match client.create().await { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Event::LoginFailed(login_error(err))); + return; + }, + }; + let _ = tx.send(Event::DeviceLogin { url: challenge.verification_url.clone(), code: challenge.confirmation_code.clone() }); + if let Err(err) = open::that(&challenge.verification_url) { + warn!("Couldn't open the browser: {err}"); + } + let interval = Duration::from_secs(u64::from(challenge.poll_interval_seconds.max(1))); + loop { + tokio::time::sleep(interval).await; + if cancel.load(Ordering::Relaxed) { + return; + } + if chrono::Utc::now() > challenge.expires_at { + let _ = tx.send(Event::LoginFailed("The sign-in code expired. Please try again.".to_owned())); + return; + } + match client.poll(&challenge.device_token).await { + Ok(result) => { + let signed = result.response.maybe_signed_session.or_else(|| result.session_set_cookie.as_deref().and_then(session_from_set_cookie)); + if let Some(session) = signed { + store_session(&root, &creds, session); + this.refresh_session(); + return; + } + if result.response.status == LoginChallengeState::Failed { + let _ = tx.send(Event::LoginFailed("Sign-in was declined or failed.".to_owned())); + return; + } + }, + Err(err) => warn!("Login poll failed: {}", login_error(err)), + } + } + }); + } + + pub fn cancel_browser_login(&self) { + self.device_login_cancel.store(true, Ordering::Relaxed); + } + + /// Signs out on the server (best effort) and forgets the session here. + pub fn logout(&self) { + let api = self.api(); + let (root, creds) = (self.root.clone(), self.creds.clone()); + if let Ok(mut c) = creds.write() { + *c = None; + } + session::delete(&root); + self.spawn(move |tx| async move { + if let Some(set) = api.creds.as_ref() { + let result: Result = basic_json_post_request(&api.host, "/v1/logout", Some(set), serde_json::json!({})).await; + if let Err(err) = result { + warn!("Logout request failed: {err}"); + } + } + let _ = tx.send(Event::Session(None)); + }); + } + + // --- Models and costs -------------------------------------------------------------------- + + /// Fetches the OmniGen model listing for `modality`. + pub fn load_models(&self, modality: Modality) { + let host = self.host.clone(); + self.spawn(move |tx| async move { + let mut last_err = String::new(); + for attempt in 0..MODEL_LIST_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(MODEL_LIST_BACKOFF).await; + } + let result = match modality { + Modality::Image => omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::image_model).collect::>()), + Modality::Video => omni_gen_list_video_models(OmniGenListVideoModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::video_model).collect::>()), + }; + match result { + Ok(models) => { + let _ = tx.send(Event::Models(modality, Ok(models))); + return; + }, + Err(err) => last_err = api_message(&err), + } + } + error!("Listing {modality:?} models failed: {last_err}"); + let _ = tx.send(Event::Models(modality, Err(last_err))); + }); + } + + /// Asks what a request would cost (`POST /v1/omni_gen/cost/{modality}`). + pub fn estimate_cost(&self, modality: Modality, key: String, fields: Map) { + let api = self.api(); + self.spawn(move |tx| async move { + let path = format!("/v1/omni_gen/cost/{}", modality.path()); + let credits = match basic_json_post_request::<_, Value>(&api.host, &path, api.creds.as_ref(), fields).await { + Ok(response) => response.get("cost_in_credits").and_then(Value::as_u64), + Err(err) => { + warn!("Cost estimate failed: {}", api_message(&err)); + None + }, + }; + let _ = tx.send(Event::Cost { key, credits }); + }); + } + + /// Starts a generation (`POST /v1/omni_gen/generate/{modality}`). + pub fn generate(&self, modality: Modality, mut fields: Map, meta: EnqueueMeta) { + let api = self.api(); + self.spawn(move |tx| async move { + let Some(creds) = api.creds.as_ref() else { + let _ = tx.send(Event::EnqueueFailed { modality, message: "Please sign in to generate.".to_owned() }); + return; + }; + fields.entry("idempotency_token").or_insert_with(|| Value::String(uuid::Uuid::new_v4().to_string())); + let path = format!("/v1/omni_gen/generate/{}", modality.path()); + match basic_json_post_request::<_, Value>(&api.host, &path, Some(creds), fields).await { + Ok(response) => { + let tokens = job_tokens(&response); + if tokens.is_empty() { + let _ = tx.send(Event::EnqueueFailed { modality, message: "The server didn't start a job.".to_owned() }); + } else { + let _ = tx.send(Event::Enqueued { modality, job_tokens: tokens, meta }); + } + api.send_credits(&tx).await; + }, + Err(err) => { + let _ = tx.send(Event::EnqueueFailed { modality, message: api_message(&err) }); + }, + } + }); + } + + // --- Uploads ----------------------------------------------------------------------------- + + /// Uploads a reference file from disk; images, MP4 videos and audio. + pub fn upload_file(&self, ref_id: u64, kind: crate::prompt_box::refs::RefKind, path: PathBuf) { + use crate::prompt_box::refs::RefKind; + let api = self.api(); + self.spawn(move |tx| async move { + let token = match kind { + RefKind::Image => upload_image_media_file_from_file(UploadImageFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, is_intermediate_system_file: true, maybe_prompt_token: None, maybe_generation_provider: None, maybe_batch_token: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_message(&e)), + RefKind::Video => upload_video_media_file_from_file(UploadVideoFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, maybe_prompt_token: None, maybe_generation_provider: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_error_message(&e)), + RefKind::Audio => api.upload_audio(&path).await, + }; + let result = match token { + Ok(token) => Ok(api.describe_upload(token).await), + Err(message) => Err(message), + }; + let _ = tx.send(Event::Uploaded { ref_id, result }); + }); + } + + /// Uploads a pasted image (PNG bytes). + pub fn upload_png(&self, ref_id: u64, png: Vec) { + let api = self.api(); + self.spawn(move |tx| async move { + let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), image_bytes: png, image_type: ImageType::Png, is_intermediate_system_file: true, maybe_generation_provider: None }).await; + let result = match result { + Ok(r) => Ok(api.describe_upload(r.media_file_token.to_string()).await), + Err(err) => Err(api_message(&err)), + }; + let _ = tx.send(Event::Uploaded { ref_id, result }); + }); + } + + // --- Feed -------------------------------------------------------------------------------- + + /// Polls the session's recent jobs (`GET /v1/jobs/session`). + pub fn poll_jobs(&self) { + let api = self.api(); + if api.creds.is_none() { + return; + } + self.spawn(move |tx| async move { + match api.get_json::("/v1/jobs/session").await { + Ok(jobs) => { + let _ = tx.send(Event::Jobs(jobs.jobs)); + }, + Err(err) => warn!("Polling jobs failed: {err}"), + } + }); + } + + /// Loads one page of the user's library, filtered to `classes` (e.g. `image`, `video`). + pub fn load_library(&self, key: String, username: String, classes: &'static str, page: u32) { + let api = self.api(); + self.spawn(move |tx| async move { + let path = format!("/v1/media_files/list/user/{username}?filter_media_classes={classes}&page_size={LIBRARY_PAGE_SIZE}&page_index={page}"); + match api.get_json::(&path).await { + Ok(list) => { + let total_pages = list.pagination.map_or(0, |p| p.total_page_count); + let items = list.results.into_iter().filter(|m| m.media_type != "scene_json" && m.origin_category != "upload").collect(); + let _ = tx.send(Event::LibraryPage { key, page, items, total_pages }); + }, + Err(message) => { + let _ = tx.send(Event::LibraryFailed { key, message }); + }, + } + }); + } + + /// Lists every file a batch job produced. + pub fn load_batch(&self, job_token: String, batch_token: String) { + let api = self.api(); + self.spawn(move |tx| async move { + match api.get_json::(&format!("/v1/media_files/batch_gen_redux/{batch_token}")).await { + Ok(batch) => { + let _ = tx.send(Event::Batch { job_token, items: batch.media_files }); + }, + Err(err) => warn!("Loading batch {batch_token} failed: {err}"), + } + }); + } + + /// Fetches the prompt record behind a generation (for Recreate and the list view). + pub fn load_prompt(&self, prompt_token: String) { + let api = self.api(); + self.spawn(move |tx| async move { + match api.get_json::(&format!("/v1/prompts/{prompt_token}")).await { + Ok(r) if r.success => { + let _ = tx.send(Event::Prompt(r.prompt)); + }, + Ok(_) => warn!("Prompt {prompt_token} not found"), + Err(err) => warn!("Loading prompt {prompt_token} failed: {err}"), + } + }); + } + + pub fn delete_media(&self, token: String) { + let api = self.api(); + self.spawn(move |tx| async move { + match delete_media_file(&api.host, api.creds.as_ref(), &MediaFileToken::new(token.clone())).await { + Ok(_) => { + let _ = tx.send(Event::Deleted(token)); + let _ = tx.send(Event::Toast { error: false, message: "File deleted.".to_owned() }); + }, + Err(err) => { + let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't delete: {}", api_message(&err)) }); + }, + } + }); + } + + /// Downloads `url` to `path`. + pub fn save_url(&self, url: String, path: PathBuf) { + let http = self.http.clone(); + self.spawn(move |tx| async move { + let result = async { + let bytes = http.get(&url).send().await.and_then(|r| r.error_for_status()).map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?; + tokio::fs::write(&path, &bytes).await.map_err(|e| e.to_string()) + } + .await; + match result { + Ok(()) => { + let _ = tx.send(Event::Saved(path)); + }, + Err(err) => { + let _ = tx.send(Event::Toast { error: true, message: format!("File download failed: {err}") }); + }, + } + }); + } + + // --- Plumbing ---------------------------------------------------------------------------- + + fn api(&self) -> Api { + let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set)); + Api { host: self.host.clone(), creds, http: self.http.clone() } + } + + /// A cheap handle for tasks that need to call back into the backend. + fn handle(&self) -> BackendHandle { + BackendHandle { host: self.host.clone(), creds: self.creds.clone(), http: self.http.clone(), tx: self.tx.clone(), ctx: self.ctx.clone(), rt: self.rt.handle().clone() } + } + + fn spawn(&self, task: F) + where + F: FnOnce(Sender) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let (tx, ctx) = (self.tx.clone(), self.ctx.clone()); + self.rt.spawn(async move { + task(tx).await; + ctx.request_repaint(); + }); + } +} + +/// Enough of the backend to refresh the session from inside a task. +#[derive(Clone)] +struct BackendHandle { + host: ApiHost, + creds: Arc>>, + http: reqwest::Client, + tx: Sender, + ctx: egui::Context, + rt: tokio::runtime::Handle, +} + +impl BackendHandle { + fn refresh_session(&self) { + let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set)); + let api = Api { host: self.host.clone(), creds, http: self.http.clone() }; + let (tx, ctx) = (self.tx.clone(), self.ctx.clone()); + self.rt.spawn(async move { + match api.get_json::("/v1/session").await { + Ok(info) if info.logged_in => { + let _ = tx.send(Event::Session(info.user)); + api.send_credits(&tx).await; + }, + Ok(_) => { + let _ = tx.send(Event::LoginFailed("The session wasn't accepted. Please try again.".to_owned())); + }, + Err(err) => { + let _ = tx.send(Event::LoginFailed(err)); + }, + } + ctx.request_repaint(); + }); + } +} + +/// One request's worth of host, credentials and HTTP client. +struct Api { + host: ApiHost, + creds: Option, + http: reqwest::Client, +} + +impl Api { + /// A GET with the desktop app's identity headers and cookies, decoded leniently. + async fn get_json(&self, path: &str) -> Result { + let url = format!("{}{path}", self.host.to_api_hostname_and_scheme()); + let mut request = self.http.get(&url).header("Accept", "application/json").header("Origin", self.host.request_origin()); + if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) { + request = request.header("Cookie", cookie); + } + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status(); + let body = response.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}"))); + } + serde_json::from_str(&body).map_err(|e| format!("Unexpected response from {path}: {e}")) + } + + async fn send_credits(&self, tx: &Sender) { + match get_session_credits(&self.host, self.creds.as_ref(), PaymentsNamespace::Artcraft).await { + Ok(c) => { + let _ = tx.send(Event::Credits(c.sum_total_credits)); + }, + Err(err) => warn!("Credits check failed: {}", api_message(&err)), + } + } + + /// `POST /v1/media_files/upload/audio` (no client function for audio yet). + async fn upload_audio(&self, path: &Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("audio").to_owned(); + let form = reqwest::multipart::Form::new().text("uuid_idempotency_token", uuid::Uuid::new_v4().to_string()).text("maybe_visibility", "public").part("file", reqwest::multipart::Part::bytes(bytes).file_name(name)); + let url = format!("{}/v1/media_files/upload/audio", self.host.to_api_hostname_and_scheme()); + let mut request = self.http.post(url).header("Accept", "application/json").header("Origin", self.host.request_origin()).multipart(form); + if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) { + request = request.header("Cookie", cookie); + } + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status(); + let body = response.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}"))); + } + let upload: UploadResponse = serde_json::from_str(&body).map_err(|e| e.to_string())?; + if upload.media_file_token.is_empty() { + return Err("The server didn't return a media token.".to_owned()); + } + Ok(upload.media_file_token) + } + + /// Looks up an uploaded file's duration and links (best effort). + async fn describe_upload(&self, token: String) -> UploadedMedia { + #[derive(serde::Deserialize, Default)] + #[serde(default)] + struct FileResponse { + media_file: MediaFile, + } + let info = self.get_json::(&format!("/v1/media_files/file/{token}")).await.ok(); + let file = info.map(|r| r.media_file); + UploadedMedia { duration_secs: file.as_ref().and_then(|f| f.maybe_duration_millis).map_or(0.0, |ms| ms as f32 / 1000.0), thumbnail: file.as_ref().and_then(|f| f.media_links.thumbnail(256)), full_url: file.as_ref().map(|f| f.media_links.cdn_url.clone()).filter(|u| !u.is_empty()), token } + } +} + +fn store_session(root: &Path, creds: &Arc>>, session: String) { + let visitor = creds.read().ok().and_then(|c| c.as_ref().and_then(|c| c.visitor.clone())); + let new = Credentials { session, visitor }; + session::save(root, &new); + if let Ok(mut c) = creds.write() { + *c = Some(new); + } +} + +/// The `session` value out of a raw `Set-Cookie` header. +fn session_from_set_cookie(header: &str) -> Option { + cookie::Cookie::parse(header).ok().filter(|c| c.name() == "session").map(|c| c.value().to_owned()) +} + +/// All job tokens a generate call created (`inference_job_token` plus `all_job_tokens`). +fn job_tokens(response: &Value) -> Vec { + let mut tokens: Vec = response.get("inference_job_token").and_then(Value::as_str).map(str::to_owned).into_iter().collect(); + for t in response.get("all_job_tokens").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) { + if !tokens.iter().any(|x| x == t) { + tokens.push(t.to_owned()); + } + } + tokens +} + +/// The server's `error_message` (or `message`) from an error body. +fn body_message(body: &str) -> Option { + let v: Value = serde_json::from_str(body).ok()?; + v.get("error_message").or_else(|| v.get("message")).and_then(Value::as_str).map(str::to_owned) +} + +/// A user-facing message for a client error. +pub fn api_message(err: &StorytellerError) -> String { + match err { + StorytellerError::Api(api) => api_error_message(api), + StorytellerError::Client(client) => format!("{client:?}"), + } +} + +fn api_error_message(err: &ApiError) -> String { + let body = match err { + ApiError::InvalidRequest(b) | ApiError::Unauthorized(b) | ApiError::PaymentRequired(b) | ApiError::Forbidden(b) | ApiError::NotFound(b) | ApiError::TooManyRequests(b) => Some(b.as_str()), + ApiError::InternalServerError { body, .. } | ApiError::UncategorizedBadResponseWithStatusAndBody { body, .. } => Some(body.as_str()), + _ => None, + }; + let fallback = match err { + ApiError::Unauthorized(_) | ApiError::Forbidden(_) => "Please sign in to ArtCraft.".to_owned(), + ApiError::PaymentRequired(_) => "You don't have enough credits for this.".to_owned(), + ApiError::TooManyRequests(_) => "Too many requests. Please wait a moment.".to_owned(), + ApiError::NetworkError(_) | ApiError::Timeout(_) => "Couldn't reach ArtCraft. Check your connection.".to_owned(), + other => format!("{other:?}"), + }; + body.and_then(body_message).unwrap_or(fallback) +} + +fn login_error(err: LoginChallengeClientError) -> String { + err.maybe_rejection.and_then(|r| r.error_message).unwrap_or_else(|| err.message.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_every_job_token_once() { + let response = serde_json::json!({ "success": true, "inference_job_token": "j1", "all_job_tokens": ["j1", "j2", "j3"] }); + assert_eq!(job_tokens(&response), ["j1", "j2", "j3"]); + assert_eq!(job_tokens(&serde_json::json!({ "inference_job_token": "only" })), ["only"]); + } + + #[test] + fn reads_the_servers_error_message() { + assert_eq!(body_message(r#"{"success":false,"error_message":"Not enough credits"}"#).as_deref(), Some("Not enough credits")); + assert_eq!(body_message(r#"{"message":"Origin is not allowed"}"#).as_deref(), Some("Origin is not allowed")); + assert_eq!(body_message("502"), None); + assert_eq!(api_error_message(&ApiError::PaymentRequired("{}".into())), "You don't have enough credits for this."); + } + + #[test] + fn takes_the_session_from_set_cookie() { + assert_eq!(session_from_set_cookie("session=abc.def; Path=/; Secure").as_deref(), Some("abc.def")); + assert_eq!(session_from_set_cookie("visitor=x; Path=/"), None); + } +} diff --git a/artcraft-native/src/backend/session.rs b/artcraft-native/src/backend/session.rs new file mode 100644 index 0000000000..ff5d9a182a --- /dev/null +++ b/artcraft-native/src/backend/session.rs @@ -0,0 +1,118 @@ +//! Where the signed-in session lives: our own file under `~/Artcraft/credentials`, seeded on +//! first run from the Tauri app's cookie jar so an existing ArtCraft login carries over. + +use std::fs; +use std::path::{Path, PathBuf}; + +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; +use artcraft_client::utils::api_host::ApiHost; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; + +/// The Tauri app's identifier: its cookie jar lives in `//.cookies`. +const TAURI_APP_IDENTIFIER: &str = "ai.artcraft.app"; +const TAURI_COOKIES_FILENAME: &str = ".cookies"; +const CREDENTIALS_FILENAME: &str = "native_session.json"; + +/// The two ArtCraft cookies, as sent on every request (`Cookie: visitor=…; session=…`). +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Credentials { + pub session: String, + #[serde(default)] + pub visitor: Option, +} + +impl Credentials { + pub fn to_set(&self) -> StorytellerCredentialSet { + StorytellerCredentialSet::initialize(self.visitor.clone().map(StorytellerAvtCookie::new), Some(StorytellerSessionCookie::new(self.session.clone()))) + } +} + +/// `~/Artcraft`, shared with the Tauri app (`AppDataRoot`). +pub fn data_root() -> PathBuf { + directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()).unwrap_or_else(|| PathBuf::from(".")).join("Artcraft") +} + +/// The API host from `~/Artcraft/settings/env_configs.json` (production unless it says +/// `"storyteller_host": "localhost"`). +pub fn api_host(root: &Path) -> ApiHost { + #[derive(Deserialize)] + struct EnvConfigs { + storyteller_host: Option, + storyteller_port: Option, + } + let path = root.join("settings").join("env_configs.json"); + let configs = fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str::(&s).ok()); + match configs { + Some(EnvConfigs { storyteller_host: Some(host), storyteller_port }) if host == "localhost" => ApiHost::Localhost { port: storyteller_port.unwrap_or(12345) }, + _ => ApiHost::Storyteller, + } +} + +/// The saved session, or the Tauri app's (imported and saved for next time). +pub fn load(root: &Path) -> Option { + let path = credentials_path(root); + if let Ok(text) = fs::read_to_string(&path) { + match serde_json::from_str::(&text) { + Ok(creds) if !creds.session.is_empty() => return Some(creds), + Ok(_) => {}, + Err(err) => warn!("Ignoring unreadable {}: {err}", path.display()), + } + } + let imported = import_tauri_cookies()?; + info!("Imported the ArtCraft login from the desktop app's cookie jar"); + save(root, &imported); + Some(imported) +} + +pub fn save(root: &Path, creds: &Credentials) { + let path = credentials_path(root); + let result = path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|_| fs::write(&path, serde_json::to_vec_pretty(creds).unwrap_or_default())); + if let Err(err) = result { + warn!("Couldn't save the session to {}: {err}", path.display()); + } +} + +pub fn delete(root: &Path) { + let path = credentials_path(root); + if let Err(err) = fs::remove_file(&path) { + if err.kind() != std::io::ErrorKind::NotFound { + warn!("Couldn't delete {}: {err}", path.display()); + } + } +} + +fn credentials_path(root: &Path) -> PathBuf { + root.join("credentials").join(CREDENTIALS_FILENAME) +} + +/// Reads `session` and `visitor` for the ArtCraft API from the Tauri HTTP plugin's jar: a JSON +/// array of `cookie_store` cookies (`{"raw_cookie": "...", "domain": {...}, "expires": {...}}`). +fn import_tauri_cookies() -> Option { + let path = directories::BaseDirs::new()?.cache_dir().join(TAURI_APP_IDENTIFIER).join(TAURI_COOKIES_FILENAME); + let text = fs::read_to_string(&path).ok()?; + let cookies: Vec = serde_json::from_str(&text).ok()?; + let now = chrono::Utc::now(); + let mut creds = Credentials::default(); + for entry in &cookies { + let for_api = entry.get("domain").map(|d| d.to_string()).is_some_and(|d| d.contains("storyteller.ai")); + let expired = entry.pointer("/expires/AtUtc").and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).is_some_and(|t| t < now); + let Some(raw) = entry.get("raw_cookie").and_then(|v| v.as_str()) else { + continue; + }; + if !for_api || expired { + continue; + } + let Ok(cookie) = cookie::Cookie::parse(raw) else { + continue; + }; + match cookie.name() { + "session" => creds.session = cookie.value().to_owned(), + "visitor" => creds.visitor = Some(cookie.value().to_owned()), + _ => {}, + } + } + (!creds.session.is_empty()).then_some(creds) +} diff --git a/artcraft-native/src/backend/wire.rs b/artcraft-native/src/backend/wire.rs new file mode 100644 index 0000000000..d02490cf52 --- /dev/null +++ b/artcraft-native/src/backend/wire.rs @@ -0,0 +1,249 @@ +//! Lenient shapes for the API responses the app reads itself. Enums stay strings and every field +//! has a default, so a new server-side value can't break decoding (the typed client structs use +//! closed enums). + +use serde::Deserialize; + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct MediaLinks { + pub cdn_url: String, + pub maybe_thumbnail_template: Option, + pub maybe_video_previews: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct VideoPreviews { + pub still: String, + pub animated: String, + pub still_thumbnail_template: String, + pub animated_thumbnail_template: String, +} + +impl MediaLinks { + /// A still thumbnail `width` pixels wide: the video still frame, the resized image, or the + /// original (`thumbnail-utils.ts`). + pub fn thumbnail(&self, width: u32) -> Option { + let w = width.to_string(); + if let Some(previews) = &self.maybe_video_previews { + if !previews.still_thumbnail_template.is_empty() { + return Some(previews.still_thumbnail_template.replace("{WIDTH}", &w)); + } + if !previews.still.is_empty() { + return Some(previews.still.clone()); + } + } + match &self.maybe_thumbnail_template { + Some(template) if !template.is_empty() => Some(template.replace("{WIDTH}", &w)), + _ if !self.cdn_url.is_empty() && self.maybe_video_previews.is_none() => Some(self.cdn_url.clone()), + _ => None, + } + } +} + +/// `GET /v1/media_files/list/user/{username}`. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct UserMediaList { + pub success: bool, + pub results: Vec, + pub pagination: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Pagination { + pub current: u32, + pub total_page_count: u32, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct MediaFile { + pub token: String, + pub media_class: Option, + pub media_type: String, + pub origin_category: String, + pub maybe_batch_token: Option, + pub maybe_prompt_token: Option, + pub maybe_model_type: Option, + pub maybe_origin_model_type: Option, + pub maybe_duration_millis: Option, + pub media_links: MediaLinks, + pub created_at: String, +} + +/// `GET /v1/media_files/batch_gen_redux/{token}`. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct BatchMedia { + pub success: bool, + pub media_files: Vec, +} + +/// `GET /v1/jobs/session`. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct SessionJobs { + pub success: bool, + pub jobs: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct Job { + pub job_token: String, + pub request: JobRequest, + pub status: JobStatus, + pub maybe_result: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct JobRequest { + pub inference_category: String, + pub maybe_prompt_token: Option, + pub maybe_model_type: Option, + pub maybe_raw_inference_text: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct JobStatus { + pub status: String, + pub maybe_extra_status_description: Option, + pub maybe_failure_category_updated: Option, + pub maybe_failure_message: Option, + pub progress_percentage: u8, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct JobResult { + pub entity_token: String, + pub maybe_batch_token: Option, + pub media_links: MediaLinks, +} + +/// Where a job is in its life. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JobPhase { + Running, + Succeeded, + Failed, +} + +impl JobStatus { + pub fn phase(&self) -> JobPhase { + match self.status.as_str() { + "pending" | "started" => JobPhase::Running, + "complete_success" => JobPhase::Succeeded, + _ => JobPhase::Failed, + } + } + + /// The user-facing failure reason (`FAILURE_REASON_LABEL`). + pub fn failure_reason(&self) -> String { + let label = match self.maybe_failure_category_updated.as_deref().unwrap_or("") { + "rule_bans_user_image" => "Image violates content policy", + "rule_bans_user_image_with_faces" => "Images with faces are not allowed", + "rule_bans_user_text_prompt" => "Text prompt violates content policy", + "rule_bans_user_content" => "Content violates content policy", + "rule_bans_generated_video" => "Generated video flagged by content policy", + "rule_bans_generated_audio" => "Generated audio flagged by content policy", + "rule_bans_generated_content" => "Generated content flagged by content policy", + "unknown" => "An unknown error occurred", + _ if self.status.starts_with("cancelled") => "Cancelled", + _ => "Generation failed", + }; + label.to_owned() + } +} + +/// `GET /v1/prompts/{token}`. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct PromptResponse { + pub success: bool, + pub prompt: Prompt, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct Prompt { + pub token: String, + pub maybe_positive_prompt: Option, + pub maybe_negative_prompt: Option, + pub maybe_model_type: Option, + pub maybe_aspect_ratio: Option, + pub maybe_resolution: Option, + pub maybe_duration_seconds: Option, + pub maybe_generate_audio: Option, + pub maybe_batch_count: Option, + pub maybe_context_images: Option>, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct ContextImage { + pub media_token: String, + pub semantic: String, + pub media_links: MediaLinks, +} + +/// `GET /v1/session` (only the parts the app shows). +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct SessionInfo { + pub success: bool, + pub logged_in: bool, + pub user: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct SessionUser { + pub username: String, + pub display_name: String, + pub email_gravatar_hash: String, +} + +/// The generic `{success, media_file_token}` upload reply. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct UploadResponse { + pub success: bool, + pub media_file_token: String, +} + +/// Parses an RFC 3339 timestamp into unix seconds (0 when absent or malformed). +pub fn unix_secs(timestamp: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(timestamp).map(|t| t.timestamp()).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thumbnails_prefer_the_video_still_then_the_resized_image() { + let video: MediaLinks = serde_json::from_value(serde_json::json!({ + "cdn_url": "https://cdn/v.mp4", + "maybe_video_previews": { "still": "https://cdn/s.jpg", "animated": "https://cdn/a.webp", "still_thumbnail_template": "https://cdn/w={WIDTH}/s.jpg", "animated_thumbnail_template": "" } + })) + .unwrap(); + assert_eq!(video.thumbnail(512).as_deref(), Some("https://cdn/w=512/s.jpg")); + let image: MediaLinks = serde_json::from_value(serde_json::json!({ "cdn_url": "https://cdn/i.png", "maybe_thumbnail_template": null })).unwrap(); + assert_eq!(image.thumbnail(512).as_deref(), Some("https://cdn/i.png")); + } + + #[test] + fn unknown_statuses_and_fields_still_decode() { + let job: Job = serde_json::from_value(serde_json::json!({ "job_token": "j", "status": { "status": "something_new" }, "brand_new_field": 1 })).unwrap(); + assert_eq!(job.status.phase(), JobPhase::Failed); + assert_eq!(job.status.failure_reason(), "Generation failed"); + } +} diff --git a/artcraft-native/src/feed/grid.rs b/artcraft-native/src/feed/grid.rs new file mode 100644 index 0000000000..2cbf2bf59d --- /dev/null +++ b/artcraft-native/src/feed/grid.rs @@ -0,0 +1,531 @@ +//! The generation feed as a masonry grid (`GenerationGridView`) or a list (`GenerationListView`): +//! running jobs first-class alongside failures and finished media, newest first, loading more as +//! the user scrolls. + +use std::collections::{HashMap, HashSet}; + +use egui::{Align2, Color32, CornerRadius, FontFamily, FontId, Id, Mesh, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::backend::media_cache::{Lookup, MediaCache}; +use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob, pending_status, time_ago}; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::{creator_icons, widgets}; + +/// `react-masonry-css` gutter and the cap on tall portraits (≈ 5:7). +const GAP: f32 = 8.0; +const MAX_RATIO: f32 = 1.4; +/// Start loading the next page this far before the end. +const LOAD_MORE_MARGIN: f32 = 400.0; +const LIST_THUMB: f32 = 100.0; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ViewMode { + #[default] + Grid, + List, +} + +/// Something the user did in the feed. +#[derive(Clone, Debug, PartialEq)] +pub enum FeedAction { + Open(String), + Recreate(String), + MakeVideo(String), + Share(String), + Download(String), + CopyPrompt(String), + DismissFailed(String), + ToggleSelect(String), + LoadMore, +} + +/// Looks up a model's display name and maker for the cards' model chip. +pub trait ModelNames { + fn display_name(&self, model_id: &str) -> String; + fn creator(&self, model_id: &str) -> String; +} + +pub struct FeedView<'a> { + pub id: Id, + pub mode: ViewMode, + pub pending: &'a [PendingJob], + pub failed: &'a [FailedJob], + pub items: &'a [FeedItem], + pub has_more: bool, + pub loading: bool, + /// Items that are new this session (shown even before the library catches up). + pub selecting: bool, + pub selected: &'a HashSet, + pub last_viewed: Option<&'a str>, + /// Prompt texts by prompt token (list view and pending cards). + pub prompts: &'a HashMap, + pub make_video: bool, + pub bottom_padding: f32, +} + +enum Entry<'a> { + Pending(&'a PendingJob), + Failed(&'a FailedJob), + Item(&'a FeedItem), +} + +impl Entry<'_> { + fn time(&self) -> i64 { + match self { + Entry::Pending(j) => j.created_at, + Entry::Failed(j) => j.updated_at, + Entry::Item(i) => i.created_at, + } + } +} + +/// Measured height/width ratios of loaded thumbnails, so cards keep their shape. +#[derive(Clone, Default)] +pub struct RatioCache(HashMap); + +pub fn show(ui: &mut Ui, view: &FeedView<'_>, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames) -> Vec { + let mut entries: Vec> = view.pending.iter().map(Entry::Pending).chain(view.failed.iter().map(Entry::Failed)).chain(view.items.iter().map(Entry::Item)).collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.time())); + let mut actions = Vec::new(); + let now = chrono::Utc::now().timestamp(); + if view.pending.iter().any(|_| true) { + ui.ctx().request_repaint_after(std::time::Duration::from_secs(1)); + } + + egui::ScrollArea::vertical().id_salt(view.id.with("scroll")).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let width = ui.available_width(); + let origin = ui.min_rect().min; + let layout = match view.mode { + ViewMode::Grid => masonry(&entries, width, ratios), + ViewMode::List => list_layout(&entries, width), + }; + let total = layout.iter().map(|r| r.bottom()).fold(0.0_f32, f32::max); + ui.allocate_exact_size(vec2(width, total + view.bottom_padding + if view.has_more { 64.0 } else { 0.0 }), Sense::hover()); + + for (entry, local) in entries.iter().zip(&layout) { + if !local.intersects(viewport.expand(200.0)) { + continue; + } + let rect = local.translate(origin.to_vec2()); + match (entry, view.mode) { + (Entry::Pending(job), ViewMode::Grid) => pending_card(ui, rect, job, now, names), + (Entry::Failed(job), ViewMode::Grid) => failed_card(ui, view.id, rect, job, cache, names, &mut actions), + (Entry::Item(item), ViewMode::Grid) => item_card(ui, view, rect, item, cache, ratios, names, &mut actions), + (Entry::Pending(job), ViewMode::List) => pending_row(ui, rect, job, now, names), + (Entry::Failed(job), ViewMode::List) => failed_row(ui, view.id, rect, job, names, &mut actions), + (Entry::Item(item), ViewMode::List) => item_row(ui, view, rect, item, cache, names, now, &mut actions), + } + } + + if view.has_more { + let spinner = Rect::from_center_size(origin + vec2(width / 2.0, total + 32.0), vec2(24.0, 24.0)); + if view.loading { + icons::paint_spinner(ui, spinner, theme::MUTED); + } + if !view.loading && viewport.max.y + LOAD_MORE_MARGIN >= total { + actions.push(FeedAction::LoadMore); + } + } + }); + actions +} + +/// Number of columns for a width (`BREAKPOINT_COLS = {default: 4, 900: 3, 640: 2}`). +fn columns(width: f32) -> usize { + if width <= 640.0 { + 2 + } else if width <= 900.0 { + 3 + } else { + 4 + } +} + +/// Places each entry in the shortest column, like `react-masonry-css`. +fn masonry(entries: &[Entry<'_>], width: f32, ratios: &RatioCache) -> Vec { + let cols = columns(width); + let col_w = (width - GAP * (cols - 1) as f32) / cols as f32; + let mut heights = vec![0.0_f32; cols]; + entries + .iter() + .map(|entry| { + let ratio = match entry { + Entry::Item(item) => ratios.0.get(&item.token).copied().unwrap_or(1.0).min(MAX_RATIO), + _ => 1.0, + }; + let (col, y) = heights.iter().copied().enumerate().min_by(|a, b| a.1.total_cmp(&b.1)).unwrap_or((0, 0.0)); + let rect = Rect::from_min_size(pos2(col as f32 * (col_w + GAP), y), vec2(col_w, col_w * ratio)); + heights[col] = rect.bottom() + GAP; + rect + }) + .collect() +} + +/// One row per entry, centred at `max-w-5xl`. +fn list_layout(entries: &[Entry<'_>], width: f32) -> Vec { + let w = width.min(1024.0); + let x = (width - w) / 2.0; + let row_h = LIST_THUMB + 24.0; + (0..entries.len()).map(|i| Rect::from_min_size(pos2(x, i as f32 * row_h), vec2(w, row_h))).collect() +} + +fn pending_card(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) { + let p = ui.painter(); + p.rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(8)); + shimmer(ui, rect); + let (progress, time_label) = pending_status(job, now); + if job.batch_count > 1 { + let banner = Rect::from_min_max(rect.min + vec2(8.0, 8.0), pos2(rect.right() - 8.0, rect.top() + 34.0)); + p.rect_filled(banner, 0.0, Color32::from_black_alpha(153)); + p.text(banner.center(), Align2::CENTER_CENTER, format!("Generating {} {} \u{b7} Results may appear one at a time", job.batch_count, job.kind.plural()), FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.7)); + } + let c = rect.center() - vec2(0.0, 18.0); + icons::paint_spinner(ui, Rect::from_center_size(c, vec2(26.0, 26.0)), theme::fade(Color32::WHITE, 0.2)); + let p = ui.painter(); + p.text(c + vec2(0.0, 26.0), Align2::CENTER_CENTER, format!("{progress}%"), FontId::new(12.0, FontFamily::Monospace), theme::fade(Color32::WHITE, 0.4)); + p.text(c + vec2(0.0, 42.0), Align2::CENTER_CENTER, time_label, FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.3)); + let footer = bottom_gradient(ui, rect, 96.0, 178); + let text_rect = footer.shrink2(vec2(12.0, 10.0)); + let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.8), text_rect.width(), 3); + let y = text_rect.bottom() - 24.0 - galley.size().y; + ui.painter().galley(pos2(text_rect.left(), y), galley, Color32::WHITE); + let model_id = job.model_id.as_deref().unwrap_or_default(); + model_chip(ui, pos2(text_rect.left(), text_rect.bottom() - 15.0), model_id, names, 10.0, theme::fade(Color32::WHITE, 0.5)); + widgets::progress_bar(ui.painter(), Rect::from_min_size(pos2(text_rect.left(), text_rect.bottom() - 2.0), vec2(text_rect.width(), 4.0)), progress as f32 / 100.0); +} + +fn failed_card(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, cache: &mut MediaCache, names: &dyn ModelNames, actions: &mut Vec) { + ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26)); + if let Some(Lookup::Ready(t)) = job.ref_image.as_deref().map(|u| cache.get(ui.ctx(), u)) { + egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).tint(theme::fade(Color32::WHITE, 0.1)).paint_at(ui, rect); + } + let c = rect.center() - vec2(0.0, 20.0); + let p = ui.painter(); + icons::paint(p, Rect::from_center_size(c - vec2(0.0, 18.0), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD); + p.text(c + vec2(0.0, 6.0), Align2::CENTER_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD); + if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) { + let galley = clamp_lines(ui, msg, 10.0, theme::fade(theme::BAD, 0.6), rect.width() - 32.0, 3); + ui.painter().galley(pos2(rect.center().x - galley.size().x / 2.0, c.y + 16.0), galley, Color32::WHITE); + } + let button = Rect::from_center_size(c + vec2(0.0, 62.0), vec2(84.0, 28.0)); + let resp = ui.interact(button, id.with(("dismiss", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + let p = ui.painter(); + p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH }); + let ink = if resp.hovered() { theme::fade(Color32::WHITE, 0.7) } else { theme::fade(Color32::WHITE, 0.5) }; + icons::paint(p, Rect::from_center_size(button.left_center() + vec2(18.0, 0.0), vec2(12.0, 12.0)), Icon::X, ink); + p.text(button.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), ink); + if resp.clicked() { + actions.push(FeedAction::DismissFailed(job.job_token.clone())); + } + let footer = bottom_gradient(ui, rect, 56.0, 153); + let text = footer.shrink2(vec2(12.0, 10.0)); + let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.7), text.width(), 1); + ui.painter().galley(pos2(text.left(), text.bottom() - 30.0), galley, Color32::WHITE); + model_chip(ui, pos2(text.left(), text.bottom() - 12.0), job.model_id.as_deref().unwrap_or_default(), names, 10.0, theme::fade(Color32::WHITE, 0.4)); +} + +#[allow(clippy::too_many_arguments)] +fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames, actions: &mut Vec) { + let resp = ui.interact(rect, view.id.with(("item", &item.token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + let hovered = resp.hovered(); + ui.painter().rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4)); + match item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) { + Some(Lookup::Ready(t)) => { + let size = t.size_vec2(); + if size.x > 0.0 && !ratios.0.contains_key(&item.token) { + ratios.0.insert(item.token.clone(), size.y / size.x); + } + egui::Image::new(&t).uv(cover_uv_for(size, rect.size())).corner_radius(theme::RADIUS).paint_at(ui, rect); + }, + Some(Lookup::Loading) => shimmer(ui, rect), + _ => icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }, theme::FAINT), + } + if item.kind == MediaKind::Video { + let badge = Rect::from_center_size(rect.center(), vec2(40.0, 40.0)); + ui.painter().circle_filled(badge.center(), 20.0, Color32::from_black_alpha(110)); + icons::paint(ui.painter(), badge.shrink(12.0).translate(vec2(1.5, 0.0)), Icon::Play, theme::fade(Color32::WHITE, 0.9)); + } + let selected = view.selected.contains(&item.token); + let ring = if selected { + Some(Stroke::new(2.0, theme::ACCENT_400)) + } else if hovered { + Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6))) + } else if view.last_viewed == Some(item.token.as_str()) { + Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.5))) + } else { + None + }; + if let Some(stroke) = ring { + ui.painter().rect_stroke(rect, theme::RADIUS, stroke, egui::StrokeKind::Inside); + } + if view.selecting { + let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(20.0, 20.0)); + let (fill, border) = if selected { (theme::ACCENT_400, theme::ACCENT_400) } else { (Color32::from_black_alpha(102), theme::fade(Color32::WHITE, 0.6)) }; + ui.painter().rect(chip, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside); + if selected { + icons::paint(ui.painter(), chip.shrink(4.0), Icon::Check, Color32::WHITE); + } + } + if view.last_viewed == Some(item.token.as_str()) { + let galley = widgets::caps(ui, "Last viewed", 10.0, theme::medium(), 0.5, theme::fade(Color32::WHITE, 0.8)); + let badge = Rect::from_min_size(pos2(rect.right() - 8.0 - galley.size().x - 26.0, rect.top() + 8.0), vec2(galley.size().x + 26.0, 18.0)); + let p = ui.painter(); + p.rect_filled(badge, theme::RADIUS, Color32::from_black_alpha(178)); + icons::paint(p, Rect::from_center_size(badge.left_center() + vec2(11.0, 0.0), vec2(11.0, 11.0)), Icon::Eye, theme::fade(Color32::WHITE, 0.8)); + p.galley(pos2(badge.left() + 20.0, badge.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + } + let mut action_clicked = false; + // The pointer may be on an action button rather than the card itself. + if (hovered || ui.rect_contains_pointer(rect)) && !view.selecting { + let footer = bottom_gradient(ui, rect, 56.0, 178); + let chip_y = footer.bottom() - 18.0; + let mut x = footer.left() + 8.0; + x = chip(ui, pos2(x, chip_y), Some(if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }), None, item.kind.label()); + if let Some(model) = item.model_id.as_deref() { + chip(ui, pos2(x + 6.0, chip_y), None, Some(&names.creator(model)), &truncate(&names.display_name(model), 16)); + } + let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new(); + if item.prompt_token.is_some() { + buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone()))); + } + if view.make_video && item.kind == MediaKind::Image { + buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone()))); + } + buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone()))); + buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone()))); + let pill = Rect::from_min_size(pos2(footer.right() - 8.0 - 8.0 - 28.0 * buttons.len() as f32, chip_y - 18.0), vec2(8.0 + 28.0 * buttons.len() as f32, 36.0)); + ui.painter().rect_filled(pill, 0.0, Color32::from_black_alpha(153)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink(4.0)).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.spacing_mut().item_spacing.x = 0.0; + for (icon, tip, action) in buttons { + if widgets::overlay_icon(&mut child, icon, tip).clicked() { + actions.push(action); + action_clicked = true; + } + } + } + if resp.clicked() && !action_clicked { + actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) }); + } +} + +fn pending_row(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) { + let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB)); + ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_white_alpha(8)); + shimmer(ui, thumb); + icons::paint_spinner(ui, Rect::from_center_size(thumb.center(), vec2(22.0, 22.0)), theme::fade(Color32::WHITE, 0.25)); + let (progress, label) = pending_status(job, now); + let x = thumb.right() + 16.0; + let w = rect.right() - x; + let prompt = if job.prompt.is_empty() { "Generating\u{2026}" } else { job.prompt.as_str() }; + let galley = clamp_lines(ui, prompt, 14.0, theme::INK, w, 2); + let gh = galley.size().y; + ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK); + let meta_y = thumb.top() + gh + 14.0; + let mx = model_chip(ui, pos2(x, meta_y), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::MUTED); + if job.batch_count > 1 { + ui.painter().text(pos2(mx + 8.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {} {}", job.batch_count, job.kind.plural()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED); + } + let bar = Rect::from_min_size(pos2(x, meta_y + 16.0), vec2(w.min(320.0), 4.0)); + widgets::progress_bar(ui.painter(), bar, progress as f32 / 100.0); + ui.painter().text(pos2(bar.right() + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("{progress}% \u{b7} {label}"), FontId::new(11.0, FontFamily::Monospace), theme::FAINT); + row_divider(ui, rect); +} + +fn failed_row(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, names: &dyn ModelNames, actions: &mut Vec) { + let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB)); + ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26)); + icons::paint(ui.painter(), Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD); + let x = thumb.right() + 16.0; + let w = rect.right() - x - 100.0; + let galley = clamp_lines(ui, &job.prompt, 14.0, theme::INK, w, 2); + let gh = galley.size().y; + ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK); + ui.painter().text(pos2(x, thumb.top() + gh + 14.0), Align2::LEFT_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD); + if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) { + let g = clamp_lines(ui, msg, 11.0, theme::fade(theme::BAD, 0.6), w, 2); + ui.painter().galley(pos2(x, thumb.top() + gh + 24.0), g, Color32::WHITE); + } + model_chip(ui, pos2(x, thumb.bottom() - 8.0), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::FAINT); + let button = Rect::from_min_size(pos2(rect.right() - 92.0, thumb.top()), vec2(92.0, 28.0)); + let resp = ui.interact(button, id.with(("dismiss-row", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + ui.painter().rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH }); + ui.painter().text(button.center(), Align2::CENTER_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), theme::MUTED); + if resp.clicked() { + actions.push(FeedAction::DismissFailed(job.job_token.clone())); + } + row_divider(ui, rect); +} + +#[allow(clippy::too_many_arguments)] +fn item_row(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, names: &dyn ModelNames, now: i64, actions: &mut Vec) { + let resp = ui.interact(rect, view.id.with(("row", &item.token)), Sense::click()); + let hovered = resp.hovered(); + if hovered { + ui.painter().rect_filled(rect.expand2(vec2(8.0, 0.0)), 0.0, Color32::from_white_alpha(6)); + } + let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB)); + ui.painter().rect_filled(thumb, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4)); + if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) { + egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).corner_radius(theme::RADIUS).paint_at(ui, thumb); + } + if view.selected.contains(&item.token) { + ui.painter().rect_stroke(thumb, theme::RADIUS, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside); + } + let x = thumb.right() + 16.0; + let w = rect.right() - x - 140.0; + let prompt = item.prompt_token.as_deref().and_then(|t| view.prompts.get(t)); + match prompt { + Some(text) => { + let g = clamp_lines(ui, text, 14.0, theme::INK, w, 3); + ui.painter().galley(pos2(x, thumb.top()), g, theme::INK); + }, + None if item.prompt_token.is_some() => { + for (i, frac) in [1.0, 0.7].iter().enumerate() { + ui.painter().rect_filled(Rect::from_min_size(pos2(x, thumb.top() + 4.0 + i as f32 * 20.0), vec2(w * frac, 12.0)), CornerRadius::same(2), theme::WASH); + } + }, + None => { + ui.painter().text(pos2(x, thumb.top() + 8.0), Align2::LEFT_CENTER, format!("{} Generation", item.kind.label()), FontId::new(14.0, FontFamily::Proportional), theme::MUTED); + }, + } + let meta_y = thumb.bottom() - 10.0; + let mut mx = x; + if let Some(model) = item.model_id.as_deref() { + mx = model_chip(ui, pos2(x, meta_y), model, names, 12.0, theme::MUTED); + ui.painter().text(pos2(mx + 6.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {}", item.kind.label()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED); + } + let _ = mx; + ui.painter().text(pos2(rect.right(), thumb.top() + 8.0), Align2::RIGHT_CENTER, time_ago(item.created_at, now), FontId::new(11.0, FontFamily::Proportional), theme::FAINT); + let mut action_clicked = false; + if hovered && !view.selecting { + let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new(); + if let Some(text) = prompt { + buttons.push((Icon::Copy, "Copy prompt", FeedAction::CopyPrompt(text.clone()))); + } + if item.prompt_token.is_some() { + buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone()))); + } + if view.make_video && item.kind == MediaKind::Image { + buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone()))); + } + buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone()))); + buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone()))); + let bar = Rect::from_min_size(pos2(rect.right() - 28.0 * buttons.len() as f32, thumb.bottom() - 28.0), vec2(28.0 * buttons.len() as f32, 28.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.spacing_mut().item_spacing.x = 0.0; + for (icon, tip, action) in buttons { + if widgets::overlay_icon(&mut child, icon, tip).clicked() { + actions.push(action); + action_clicked = true; + } + } + } + if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !action_clicked { + actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) }); + } + row_divider(ui, rect); +} + +fn row_divider(ui: &Ui, rect: Rect) { + ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, Stroke::new(1.0, Color32::from_white_alpha(10))); +} + +/// A small dark chip: an icon (or a creator logo) and a label. Returns its right edge. +fn chip(ui: &Ui, left_center: egui::Pos2, icon: Option, creator: Option<&str>, label: &str) -> f32 { + let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::new(12.0, theme::medium()), theme::fade(Color32::WHITE, 0.9)); + let lead = if icon.is_some() || creator.is_some() { 16.0 } else { 0.0 }; + let rect = Rect::from_min_size(pos2(left_center.x, left_center.y - 12.0), vec2(20.0 + lead + galley.size().x, 24.0)); + let p = ui.painter(); + p.rect_filled(rect, 0.0, Color32::from_black_alpha(153)); + let icon_rect = Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(11.0, 11.0)); + if let Some(icon) = icon { + icons::paint(p, icon_rect, icon, theme::fade(Color32::WHITE, 0.9)); + } else if let Some(creator) = creator { + let tex = creator_icons::texture(ui.ctx(), creator); + egui::Image::new(&tex).tint(theme::fade(Color32::WHITE, 0.9)).paint_at(ui, icon_rect.expand(0.5)); + } + p.galley(pos2(rect.left() + 10.0 + lead, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + rect.right() +} + +/// A creator logo followed by the model's name. Returns the right edge. +fn model_chip(ui: &Ui, left_center: egui::Pos2, model_id: &str, names: &dyn ModelNames, size: f32, color: Color32) -> f32 { + if model_id.is_empty() { + return left_center.x; + } + let icon_rect = Rect::from_center_size(pos2(left_center.x + size / 2.0 + 1.0, left_center.y), vec2(size + 2.0, size + 2.0)); + let tex = creator_icons::texture(ui.ctx(), &names.creator(model_id)); + egui::Image::new(&tex).tint(color).paint_at(ui, icon_rect); + let r = ui.painter().text(pos2(icon_rect.right() + 6.0, left_center.y), Align2::LEFT_CENTER, names.display_name(model_id), FontId::new(size, theme::medium()), color); + r.right() +} + +/// Darkens the bottom of `rect` (`bg-gradient-to-t from-black/70`), returning the faded band. +fn bottom_gradient(ui: &Ui, rect: Rect, height: f32, alpha: u8) -> Rect { + let band = Rect::from_min_max(pos2(rect.left(), (rect.bottom() - height).max(rect.top())), rect.max); + let mut mesh = Mesh::default(); + let (top, bottom) = (Color32::TRANSPARENT, Color32::from_black_alpha(alpha)); + mesh.colored_vertex(band.left_top(), top); + mesh.colored_vertex(band.right_top(), top); + mesh.colored_vertex(band.right_bottom(), bottom); + mesh.colored_vertex(band.left_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + ui.painter().add(mesh); + band +} + +/// The sweeping highlight over loading tiles (`.animate-shimmer`). +fn shimmer(ui: &Ui, rect: Rect) { + let t = (ui.input(|i| i.time) / 1.5).fract() as f32; + let x = rect.left() - rect.width() + t * rect.width() * 3.0; + let band = Rect::from_min_max(pos2(x, rect.top()), pos2(x + rect.width(), rect.bottom())).intersect(rect); + if band.width() > 0.0 { + let mut mesh = Mesh::default(); + let (edge, mid) = (Color32::TRANSPARENT, Color32::from_white_alpha(20)); + let cx = (x + rect.width() / 2.0).clamp(band.left(), band.right()); + for (px, c) in [(band.left(), edge), (cx, mid), (band.right(), edge)] { + mesh.colored_vertex(pos2(px, band.top()), c); + mesh.colored_vertex(pos2(px, band.bottom()), c); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 2, 3); + mesh.add_triangle(2, 3, 4); + mesh.add_triangle(3, 4, 5); + ui.painter().add(mesh); + } + ui.ctx().request_repaint(); +} + +/// Lays out `text` wrapped to `width`, keeping at most `max_lines` lines (with an ellipsis). +fn clamp_lines(ui: &Ui, text: &str, size: f32, color: Color32, width: f32, max_lines: usize) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple(text.to_owned(), FontId::new(size, FontFamily::Proportional), color, width); + job.wrap.max_rows = max_lines; + job.wrap.break_anywhere = false; + ui.painter().layout_job(job) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_owned() + } else { + format!("{}\u{2026}", s.chars().take(max - 1).collect::()) + } +} + +/// The UV rect that crops a texture of `size` to fill `target` (`object-cover`). +fn cover_uv_for(size: egui::Vec2, target: egui::Vec2) -> Rect { + if size.x <= 0.0 || size.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 { + return Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + } + let (src, dst) = (size.y / size.x, target.y / target.x); + if src > dst { + let f = dst / src; + Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) + } else { + let f = src / dst; + Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) + } +} diff --git a/artcraft-native/src/feed/mod.rs b/artcraft-native/src/feed/mod.rs new file mode 100644 index 0000000000..3f73c874db --- /dev/null +++ b/artcraft-native/src/feed/mod.rs @@ -0,0 +1,3 @@ +pub mod grid; +pub mod store; +pub mod types; diff --git a/artcraft-native/src/feed/store.rs b/artcraft-native/src/feed/store.rs new file mode 100644 index 0000000000..43f281101c --- /dev/null +++ b/artcraft-native/src/feed/store.rs @@ -0,0 +1,326 @@ +//! One create page's feed (`useDesktopGenerationFeed` + `useGalleryData`): jobs in flight and +//! failures from the session's job list, results as they land, and the library underneath. + +use std::collections::{HashMap, HashSet}; + +use crate::backend::wire::{Job, JobPhase, MediaFile, unix_secs}; +use crate::backend::EnqueueMeta; +use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob}; +use crate::models::Catalog; + +/// Failures older than this are history, not news. +const FAILURE_WINDOW_SECS: i64 = 24 * 60 * 60; +/// Thumbnails are requested at this width (`getMediaThumbnail(@512)`). +pub const THUMBNAIL_WIDTH: u32 = 512; + +/// Something the store wants the app to do or say after an update. +#[derive(Debug, PartialEq)] +pub enum FeedNotice { + Completed, + Failed(String), + /// Expand a finished batch job into all of its files. + LoadBatch { + job_token: String, + batch_token: String, + }, +} + +#[derive(Default)] +pub struct FeedStore { + pub pending: Vec, + pub failed: Vec, + pub items: Vec, + pub has_more: bool, + pub loading: bool, + pub selecting: bool, + pub selected: HashSet, + pub last_viewed: Option, + next_page: u32, + library_started: bool, + /// Jobs seen finished (so only new ones announce themselves). + seen_complete: HashSet, + /// Jobs seen running in this session (so their outcome gets a toast). + seen_running: HashSet, + first_poll_done: bool, + dismissed: HashSet, + meta: HashMap, +} + +impl FeedStore { + pub fn has_content(&self) -> bool { + !self.pending.is_empty() || !self.failed.is_empty() || !self.items.is_empty() + } + + /// Shows the jobs a generate call just started, before the next poll catches up. + pub fn add_enqueued(&mut self, kind: MediaKind, job_tokens: &[String], meta: &EnqueueMeta, catalog: &Catalog) { + let now = chrono::Utc::now().timestamp(); + let expected = catalog.find(&meta.model_id).map_or(30.0, |m| m.expected_secs); + for token in job_tokens { + self.meta.insert(token.clone(), meta.clone()); + self.seen_running.insert(token.clone()); + if !self.pending.iter().any(|p| &p.job_token == token) { + self.pending.insert(0, PendingJob { job_token: token.clone(), kind, prompt: meta.prompt.clone(), model_id: Some(meta.model_id.clone()), created_at: now, server_progress: None, expected_secs: expected, batch_count: meta.batch_count }); + } + } + } + + /// Applies a poll of the session's jobs, keeping only `kind`'s. + pub fn apply_jobs(&mut self, kind: MediaKind, jobs: &[Job], catalog: &Catalog) -> Vec { + let now = chrono::Utc::now().timestamp(); + let mut notices = Vec::new(); + let mut pending = Vec::new(); + let mut failed = Vec::new(); + let relevant: Vec<&Job> = jobs.iter().filter(|j| job_kind(j, catalog) == Some(kind) && !self.dismissed.contains(&j.job_token)).collect(); + for job in relevant { + let meta = self.meta.get(&job.job_token).cloned(); + let meta = meta.as_ref(); + let model_id = meta.map(|m| m.model_id.clone()).or_else(|| job.request.maybe_model_type.clone()); + let prompt = meta.map(|m| m.prompt.clone()).or_else(|| job.request.maybe_raw_inference_text.clone()).unwrap_or_default(); + match job.status.phase() { + JobPhase::Running => { + self.seen_running.insert(job.job_token.clone()); + let expected = model_id.as_deref().and_then(|id| catalog.find(id)).map_or(if kind == MediaKind::Video { 900.0 } else { 30.0 }, |m| m.expected_secs); + pending.push(PendingJob { job_token: job.job_token.clone(), kind, prompt, model_id, created_at: unix_secs(&job.created_at), server_progress: Some(job.status.progress_percentage), expected_secs: expected, batch_count: meta.map_or(1, |m| m.batch_count) }); + }, + JobPhase::Failed => { + let updated = unix_secs(&job.updated_at); + if self.seen_running.remove(&job.job_token) { + notices.push(FeedNotice::Failed(job.status.maybe_failure_message.clone().unwrap_or_else(|| job.status.failure_reason()))); + } + if now - updated <= FAILURE_WINDOW_SECS { + failed.push(FailedJob { job_token: job.job_token.clone(), prompt, model_id, updated_at: updated, reason: job.status.failure_reason(), message: job.status.maybe_failure_message.clone().filter(|_| job.status.maybe_failure_category_updated.as_deref() != Some("unknown")), ref_image: meta.and_then(|m| m.ref_image.clone()) }); + } + }, + JobPhase::Succeeded => { + if !self.seen_complete.insert(job.job_token.clone()) || !self.first_poll_done { + continue; + } + if self.seen_running.remove(&job.job_token) { + notices.push(FeedNotice::Completed); + } + let Some(result) = &job.maybe_result else { + continue; + }; + // The finished card takes the pending card's place: date it by the job. + let created_at = unix_secs(&job.created_at); + self.push_item(FeedItem { token: result.entity_token.clone(), kind, thumbnail: result.media_links.thumbnail(THUMBNAIL_WIDTH), full_url: result.media_links.cdn_url.clone(), created_at, model_id: model_id.clone(), prompt_token: job.request.maybe_prompt_token.clone(), batch_token: result.maybe_batch_token.clone(), duration_secs: None }); + if let Some(batch) = &result.maybe_batch_token { + notices.push(FeedNotice::LoadBatch { job_token: job.job_token.clone(), batch_token: batch.clone() }); + } + }, + } + } + // Jobs started a moment ago may not be in the list yet: keep them. + for p in self.pending.drain(..) { + let listed = jobs.iter().any(|j| j.job_token == p.job_token); + if !listed && now - p.created_at < 60 && !pending.iter().any(|q: &PendingJob| q.job_token == p.job_token) { + pending.push(p); + } + } + // Everything finished before the first poll comes from the library instead. + self.first_poll_done = true; + pending.sort_by_key(|p| std::cmp::Reverse(p.created_at)); + failed.sort_by_key(|f| std::cmp::Reverse(f.updated_at)); + self.pending = pending; + self.failed = failed; + notices + } + + /// Adds every file of a finished batch, dated like the batch's first card. + pub fn apply_batch(&mut self, kind: MediaKind, files: &[MediaFile]) { + for file in files { + let mut item = feed_item(kind, file); + if let Some(first) = self.items.iter().find(|i| i.batch_token.is_some() && i.batch_token == file.maybe_batch_token) { + item.created_at = first.created_at; + } + self.push_item(item); + } + } + + pub fn library_started(&self) -> bool { + self.library_started + } + + /// The next library page to fetch, if one is due. + pub fn next_library_page(&mut self) -> Option { + if self.loading || (self.library_started && !self.has_more) { + return None; + } + self.loading = true; + self.library_started = true; + Some(self.next_page) + } + + pub fn apply_library_page(&mut self, kind: MediaKind, page: u32, files: &[MediaFile], total_pages: u32) { + self.loading = false; + if page != self.next_page { + return; + } + for file in files { + self.push_item(feed_item(kind, file)); + } + self.next_page = page + 1; + self.has_more = self.next_page < total_pages; + } + + pub fn library_failed(&mut self) { + self.loading = false; + } + + /// Starts the library over (after signing in or out). + pub fn reset(&mut self) { + *self = Self { dismissed: std::mem::take(&mut self.dismissed), ..Self::default() }; + } + + pub fn dismiss(&mut self, job_token: &str) { + self.dismissed.insert(job_token.to_owned()); + self.failed.retain(|f| f.job_token != job_token); + } + + pub fn remove_item(&mut self, token: &str) { + self.items.retain(|i| i.token != token); + self.selected.remove(token); + } + + pub fn find(&self, token: &str) -> Option<&FeedItem> { + self.items.iter().find(|i| i.token == token) + } + + /// Finished items newest first (the lightbox's prev/next order). + pub fn ordered_tokens(&self) -> Vec { + let mut items: Vec<&FeedItem> = self.items.iter().collect(); + items.sort_by_key(|i| std::cmp::Reverse(i.created_at)); + items.into_iter().map(|i| i.token.clone()).collect() + } + + fn push_item(&mut self, item: FeedItem) { + if !item.token.is_empty() && !self.items.iter().any(|i| i.token == item.token) { + self.items.push(item); + } + } +} + +/// Builds a feed item from a library or batch file. +pub fn feed_item(kind: MediaKind, file: &MediaFile) -> FeedItem { + FeedItem { + token: file.token.clone(), + kind: match file.media_class.as_deref() { + Some("video") => MediaKind::Video, + Some("image") => MediaKind::Image, + _ => kind, + }, + thumbnail: file.media_links.thumbnail(THUMBNAIL_WIDTH), + full_url: file.media_links.cdn_url.clone(), + created_at: unix_secs(&file.created_at), + model_id: file.maybe_model_type.clone().or_else(|| file.maybe_origin_model_type.clone()), + prompt_token: file.maybe_prompt_token.clone(), + batch_token: file.maybe_batch_token.clone(), + duration_secs: file.maybe_duration_millis.map(|ms| ms as f32 / 1000.0), + } +} + +/// Whether a job made an image or a video: by its model, else by its category. +fn job_kind(job: &Job, catalog: &Catalog) -> Option { + if let Some(model) = job.request.maybe_model_type.as_deref() { + if catalog.image.iter().any(|m| m.id == model) { + return Some(MediaKind::Image); + } + if catalog.video.iter().any(|m| m.id == model) { + return Some(MediaKind::Video); + } + } + let category = job.request.inference_category.as_str(); + if category.contains("video") { + Some(MediaKind::Video) + } else if category.contains("image") { + Some(MediaKind::Image) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ModelInfo; + + fn catalog() -> Catalog { + let mut c = Catalog::default(); + c.set_image(vec![ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), prompt_supported: true, ..Default::default() }]); + c.set_video(vec![ModelInfo { id: "seedance_2p0".into(), name: "Seedance 2.0".into(), ..Default::default() }]); + c + } + + fn job(token: &str, model: &str, status: &str, created: &str) -> Job { + serde_json::from_value(serde_json::json!({ + "job_token": token, + "request": { "inference_category": "image_generation", "maybe_model_type": model, "maybe_raw_inference_text": "a red knight" }, + "status": { "status": status, "progress_percentage": 40, "maybe_failure_message": "Too spicy", "maybe_failure_category_updated": "rule_bans_user_text_prompt" }, + "maybe_result": { "entity_token": format!("m_{token}"), "media_links": { "cdn_url": "https://cdn/x.png", "maybe_thumbnail_template": "https://cdn/w={WIDTH}/x.png" } }, + "created_at": created, + "updated_at": created, + })) + .expect("job json") + } + + fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339() + } + + #[test] + fn first_poll_leaves_old_results_to_the_library() { + let (c, mut store) = (catalog(), FeedStore::default()); + let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &now_rfc3339())], &c); + assert!(notices.is_empty()); + assert!(store.items.is_empty()); + } + + #[test] + fn running_jobs_become_pending_then_announce_their_result() { + let (c, mut store) = (catalog(), FeedStore::default()); + let t = now_rfc3339(); + store.apply_jobs(MediaKind::Image, &[], &c); + store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "started", &t)], &c); + assert_eq!(store.pending.len(), 1); + assert_eq!(store.pending[0].prompt, "a red knight"); + assert_eq!(store.pending[0].server_progress, Some(40)); + + let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &t)], &c); + assert_eq!(notices, vec![FeedNotice::Completed]); + assert!(store.pending.is_empty()); + assert_eq!(store.items[0].token, "m_j1"); + assert_eq!(store.items[0].thumbnail.as_deref(), Some("https://cdn/w=512/x.png")); + } + + #[test] + fn failures_show_with_their_reason_and_can_be_dismissed() { + let (c, mut store) = (catalog(), FeedStore::default()); + let t = now_rfc3339(); + store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "pending", &t)], &c); + let notices = store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c); + assert_eq!(notices, vec![FeedNotice::Failed("Too spicy".into())]); + assert_eq!(store.failed[0].reason, "Text prompt violates content policy"); + store.dismiss("j2"); + store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c); + assert!(store.failed.is_empty()); + } + + #[test] + fn jobs_go_to_the_feed_of_their_model() { + let (c, mut store) = (catalog(), FeedStore::default()); + store.apply_jobs(MediaKind::Video, &[job("j3", "nano_banana_pro", "started", &now_rfc3339())], &c); + assert!(store.pending.is_empty()); + store.apply_jobs(MediaKind::Video, &[job("j4", "seedance_2p0", "started", &now_rfc3339())], &c); + assert_eq!(store.pending.len(), 1); + } + + #[test] + fn just_enqueued_jobs_survive_a_poll_that_predates_them() { + let (c, mut store) = (catalog(), FeedStore::default()); + let meta = EnqueueMeta { prompt: "p".into(), model_id: "nano_banana_pro".into(), batch_count: 2, ref_image: None }; + store.add_enqueued(MediaKind::Image, &["fresh".into()], &meta, &c); + store.apply_jobs(MediaKind::Image, &[], &c); + assert_eq!(store.pending.len(), 1); + assert_eq!(store.pending[0].batch_count, 2); + } +} diff --git a/artcraft-native/src/feed/types.rs b/artcraft-native/src/feed/types.rs new file mode 100644 index 0000000000..ee3ba9f209 --- /dev/null +++ b/artcraft-native/src/feed/types.rs @@ -0,0 +1,107 @@ +//! What the generation feed shows: jobs still running, jobs that failed, and finished media. + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MediaKind { + Image, + Video, +} + +impl MediaKind { + pub fn label(self) -> &'static str { + match self { + MediaKind::Image => "Image", + MediaKind::Video => "Video", + } + } + + pub fn plural(self) -> &'static str { + match self { + MediaKind::Image => "images", + MediaKind::Video => "videos", + } + } +} + +/// A finished image or video in the user's library. +#[derive(Clone, Debug)] +pub struct FeedItem { + /// The media file token. + pub token: String, + pub kind: MediaKind, + /// A thumbnail URL (512 px wide where the CDN can resize). + pub thumbnail: Option, + /// The original file. + pub full_url: String, + /// Unix seconds. + pub created_at: i64, + /// The model that made it (`maybe_model_type`). + pub model_id: Option, + pub prompt_token: Option, + pub batch_token: Option, + pub duration_secs: Option, +} + +/// A generation the server is still working on. +#[derive(Clone, Debug)] +pub struct PendingJob { + pub job_token: String, + pub kind: MediaKind, + pub prompt: String, + pub model_id: Option, + /// Unix seconds when it was enqueued. + pub created_at: i64, + /// The server's progress (0–100), when it reports any. + pub server_progress: Option, + /// How long this model usually takes, for the estimated progress bar. + pub expected_secs: f32, + pub batch_count: u32, +} + +/// A generation that failed or was cancelled. +#[derive(Clone, Debug)] +pub struct FailedJob { + pub job_token: String, + pub prompt: String, + pub model_id: Option, + pub updated_at: i64, + pub reason: String, + pub message: Option, + /// The first reference image, shown faintly behind the error. + pub ref_image: Option, +} + +/// The progress and time-left labels for a pending job (`derivePendingStatus`). +pub fn pending_status(job: &PendingJob, now: i64) -> (u8, String) { + let elapsed = (now - job.created_at).max(0) as f32; + let estimated = ((elapsed / job.expected_secs.max(1.0)) * 100.0).min(95.0) as u8; + let progress = job.server_progress.filter(|p| *p > 0).unwrap_or(estimated).min(99); + if progress >= 95 { + return (progress, "Almost done...".to_owned()); + } + let left = (job.expected_secs - elapsed).max(1.0) as i64; + let (h, m, s) = (left / 3600, (left % 3600) / 60, left % 60); + let label = if h > 0 && m > 0 { + format!("~{h}h {m}m") + } else if h > 0 { + format!("~{h}h") + } else if m > 0 { + format!("~{m}m") + } else { + format!("~{s}s") + }; + (progress, label) +} + +/// "just now", "5 minutes ago", … for list rows. +pub fn time_ago(then: i64, now: i64) -> String { + let d = (now - then).max(0); + let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); + match d { + 0..=59 => "just now".to_owned(), + 60..=3599 => plural(d / 60, "minute"), + 3600..=86_399 => plural(d / 3600, "hour"), + 86_400..=2_591_999 => plural(d / 86_400, "day"), + 2_592_000..=31_535_999 => plural(d / 2_592_000, "month"), + _ => plural(d / 31_536_000, "year"), + } +} diff --git a/artcraft-native/src/main.rs b/artcraft-native/src/main.rs new file mode 100644 index 0000000000..b4d2b5c5ea --- /dev/null +++ b/artcraft-native/src/main.rs @@ -0,0 +1,33 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +//! ArtCraft, natively: an egui desktop app over the ArtCraft API (no Tauri, no webview). + +mod app; +mod backend; +mod feed; +mod models; +mod overlays; +mod pages; +mod prompt_box; +mod shell; +mod theme; +mod ui; + +const APP_ID: &str = "ai.artcraft.native"; + +fn main() -> eframe::Result { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let mut viewport = egui::ViewportBuilder::default().with_title("ArtCraft").with_app_id(APP_ID).with_inner_size([1440.0, 900.0]).with_min_inner_size([960.0, 640.0]); + if let Some(icon) = app_icon() { + viewport = viewport.with_icon(icon); + } + let options = eframe::NativeOptions { viewport, persist_window: true, ..Default::default() }; + eframe::run_native("ArtCraft", options, Box::new(|cc| Ok(Box::new(app::ArtcraftApp::new(cc))))) +} + +/// The window and taskbar icon. +fn app_icon() -> Option { + const ICON: &[u8] = include_bytes!("../../crates/desktop/artcraft/icons/icon.png"); + let rgba = image::load_from_memory(ICON).ok()?.into_rgba8(); + Some(egui::IconData { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() }) +} diff --git a/artcraft-native/src/models.rs b/artcraft-native/src/models.rs new file mode 100644 index 0000000000..84fd713a05 --- /dev/null +++ b/artcraft-native/src/models.rs @@ -0,0 +1,629 @@ +//! The model catalog: capabilities come from the server's OmniGen listing (so new models and +//! options show up without an app update); names, descriptions, families and typical durations +//! are filled in here when the server doesn't say. + +use crate::feed::grid::ModelNames; +use crate::feed::types::MediaKind; + +/// Default models (`defaultModelForPage.ts`). +pub const DEFAULT_IMAGE_MODEL: &str = "nano_banana_pro"; +pub const DEFAULT_VIDEO_MODEL: &str = "seedance_2p0"; +/// The fallback prompt limit (`maxPromptLength ?? 1000`). +pub const DEFAULT_PROMPT_MAX: usize = 1000; +/// The model pickers group by family once they list this many models. +const GROUP_THRESHOLD: usize = 8; + +/// One generation model and what it accepts. +#[derive(Clone, Debug, Default)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub description: String, + /// Snake-case maker (`google`, `bytedance`, …), for the logo. + pub creator: String, + pub family: String, + pub disabled: bool, + pub prompt_supported: bool, + /// `None` = unlimited. + pub prompt_max: Option, + pub aspect_ratios: Vec, + pub aspect_default: Option, + pub resolutions: Vec, + pub resolution_default: Option, + pub qualities: Vec, + pub quality_default: Option, + pub batch_sizes: Vec, + pub batch_default: u16, + pub image_refs_max: usize, + // Video only. + pub text_to_video: bool, + pub start_frame: bool, + pub start_frame_required: bool, + pub end_frame: bool, + pub sound_toggle: bool, + pub video_refs_max: usize, + pub video_refs_max_secs: Option, + pub audio_refs_max: usize, + pub audio_refs_max_secs: Option, + pub duration_options: Vec, + pub duration_min: Option, + pub duration_max: Option, + pub duration_max_with_refs: Option, + pub duration_default: Option, + pub bitrates: Vec, + pub bitrate_default: Option, + pub output_formats: Vec, + pub output_format_default: Option, + /// How long a generation usually takes (drives the estimated progress bar). + pub expected_secs: f32, +} + +impl ModelInfo { + /// Batch sizes the count picker offers (`predefined` or `1..=max`). + pub fn batch_options(&self) -> Vec { + if self.batch_sizes.is_empty() { + vec![1] + } else { + self.batch_sizes.clone() + } + } + + /// `count` if this model allows it, else its default. + pub fn valid_batch(&self, count: u16) -> u16 { + let options = self.batch_options(); + if options.contains(&count) { + count + } else if options.contains(&self.batch_default) { + self.batch_default + } else { + options[0] + } + } + + /// `current` if supported, else the default, else the first option (`resolveQuality` & co.). + pub fn resolve(current: Option<&str>, options: &[String], default: Option<&String>) -> Option { + match current { + Some(c) if options.iter().any(|o| o == c) => Some(c.to_owned()), + _ => default.filter(|d| options.is_empty() || options.contains(d)).cloned().or_else(|| options.first().cloned()), + } + } + + pub fn supports_reference_mode(&self) -> bool { + self.image_refs_max > 0 || self.video_refs_max > 0 || self.audio_refs_max > 0 + } + + /// Whether the duration picker shows (a range or several options). + pub fn duration_range(&self, with_refs: bool) -> Option<(u16, u16)> { + let max = if with_refs { self.duration_max_with_refs.or(self.duration_max) } else { self.duration_max }; + match (self.duration_min, max) { + (Some(min), Some(max)) if max > min => Some((min, max)), + _ => { + let (lo, hi) = (self.duration_options.iter().min()?, self.duration_options.iter().max()?); + (hi > lo).then_some((*lo, *hi)) + }, + } + } + + /// Snaps `current` into the model's duration range (preferring the higher neighbour), or its + /// default (`resolveVideoDuration`). + pub fn resolve_duration(&self, current: Option, with_refs: bool) -> Option { + let default = self.duration_default.or_else(|| self.duration_options.first().copied()).or(self.duration_min); + let current = current.or(default)?; + if !self.duration_options.is_empty() { + let cap = if with_refs { self.duration_max_with_refs } else { None }; + let mut options: Vec = self.duration_options.iter().copied().filter(|o| cap.is_none_or(|c| *o <= c)).collect(); + options.sort_unstable(); + return options.iter().copied().find(|&o| o >= current).or(options.last().copied()).or(default); + } + match self.duration_range(with_refs) { + Some((min, max)) => Some(current.clamp(min, max)), + None => default, + } + } +} + +/// Builds a model from the server's image listing. +pub fn image_model(d: &artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::OmniGenImageModelDetails) -> ModelInfo { + let id = String::from(d.model.clone()); + let mut m = base(&id, MediaKind::Image, d.full_name.as_deref(), d.model_creator.clone().map(String::from)); + m.disabled = d.is_disabled.unwrap_or(false); + m.prompt_supported = d.text_prompt_supported.unwrap_or(true); + m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id)); + m.aspect_ratios = strings(&d.aspect_ratio_options); + m.aspect_default = d.aspect_ratio_default.clone().map(String::from); + m.resolutions = strings(&d.resolution_options); + m.resolution_default = d.resolution_default.clone().map(String::from); + m.qualities = strings(&d.quality_options); + m.quality_default = d.default_quality.clone().map(String::from); + m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max); + m.batch_default = d.batch_size_default.unwrap_or(1); + m.image_refs_max = if d.image_refs_supported.unwrap_or(false) { d.image_refs_max.map_or(1, usize::from) } else { 0 }; + m +} + +/// Builds a model from the server's video listing. +pub fn video_model(d: &artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::OmniGenVideoModelDetails) -> ModelInfo { + let id = String::from(d.model.clone()); + let mut m = base(&id, MediaKind::Video, d.full_name.as_deref(), d.model_creator.clone().map(String::from)); + if let Some(short) = d.extra_info_short.as_deref().filter(|s| !s.is_empty()) { + m.description = short.to_owned(); + } + m.disabled = d.is_disabled.unwrap_or(false); + m.prompt_supported = d.text_prompt_supported.unwrap_or(true); + m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id)); + m.text_to_video = d.text_to_video_supported.unwrap_or(true); + m.start_frame = d.starting_keyframe_supported.unwrap_or(false); + m.start_frame_required = d.starting_keyframe_required.unwrap_or(false); + m.end_frame = d.ending_keyframe_supported.unwrap_or(false); + m.sound_toggle = d.show_generate_with_sound_toggle.unwrap_or(false); + let max = |supported: Option, max: Option| if supported.unwrap_or(false) { max.map_or(1, usize::from) } else { 0 }; + m.image_refs_max = max(d.image_references_supported, d.image_references_max); + m.video_refs_max = max(d.video_references_supported, d.video_references_max); + m.video_refs_max_secs = d.video_references_max_total_duration_seconds; + m.audio_refs_max = max(d.audio_references_supported, d.audio_references_max); + m.audio_refs_max_secs = d.audio_references_max_total_duration_seconds; + m.aspect_ratios = strings(&d.aspect_ratio_options); + m.aspect_default = d.aspect_ratio_default.clone().map(String::from); + m.resolutions = strings(&d.resolution_options); + m.resolution_default = d.resolution_default.clone().map(String::from); + m.qualities = strings(&d.quality_options); + m.quality_default = d.default_quality.clone().map(String::from); + m.bitrates = strings(&d.bitrate_options); + m.bitrate_default = d.bitrate_default.clone().map(String::from); + m.output_formats = d.output_format_options.clone().unwrap_or_default(); + m.output_format_default = d.output_format_default.clone(); + m.duration_options = d.duration_seconds_options.clone().unwrap_or_default(); + m.duration_min = d.duration_seconds_min; + m.duration_max = d.duration_seconds_max; + m.duration_max_with_refs = d.duration_seconds_max_with_image_references; + m.duration_default = d.duration_seconds_default; + m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max); + m.batch_default = d.batch_size_default.unwrap_or(1); + m +} + +fn strings>(options: &Option>) -> Vec { + options.as_deref().unwrap_or_default().iter().cloned().map(Into::into).collect() +} + +fn batch_sizes(options: Option<&[u16]>, min: Option, max: Option) -> Vec { + match options.filter(|o| !o.is_empty()) { + Some(options) => options.to_vec(), + None => (min.unwrap_or(1).max(1)..=max.unwrap_or(1).max(1)).collect(), + } +} + +fn base(id: &str, kind: MediaKind, full_name: Option<&str>, creator: Option) -> ModelInfo { + // Known families use the pickers' curated names ("Flux 1 Dev", not "FLUX.1 [dev]"); models + // the app doesn't know yet use the server's name. + let name = if has_curated_name(id) { display_name(id) } else { full_name.filter(|n| !n.is_empty()).map_or_else(|| display_name(id), str::to_owned) }; + // The server reports "artcraft" for models it serves itself; show the actual maker when the id + // says who that is (Nano Banana → Google), like the webapp. + let creator = match creator.filter(|c| !c.is_empty() && c != "unknown") { + Some(c) if c == "artcraft" && creator_for(id) != "artcraft" => creator_for(id).to_owned(), + Some(c) => c, + None => creator_for(id).to_owned(), + }; + ModelInfo { id: id.to_owned(), name, description: description(id).to_owned(), creator, family: family(id).to_owned(), prompt_supported: true, text_to_video: true, batch_default: 1, expected_secs: expected_secs(id, kind), ..Default::default() } +} + +/// Families whose picker names the app curates (the static model lists' `selectorName`s). +fn has_curated_name(id: &str) -> bool { + const PREFIXES: [&str; 11] = ["flux_", "nano_banana", "gpt_image", "seedream", "seedance", "kling", "veo_", "sora", "happy_horse", "grok_imagine", "qwen"]; + PREFIXES.iter().any(|p| id.starts_with(p)) +} + +/// A readable name for a model id: the irregular ones by table, the rest by rule +/// (`seedance_2p0_fast` → "Seedance 2.0 Fast"). +pub fn display_name(id: &str) -> String { + let special = match id { + "nano_banana" | "gemini_25_flash" => "Nano Banana", + "grok_image" => "Grok Image", + "grok_imagine_image" => "Grok", + "grok_imagine_video" => "Grok Video", + "grok_imagine_image_q" => "Grok Imagine Quality", + "gpt_image_1" => "GPT Image 1 (GPT-4o)", + "flux_pro_1" => "Flux Pro (Inpainting)", + "seedance_2p0_bp" => "Seedance 2.0 Plus", + "seedance_2p0_bp_fast" => "Seedance 2.0 Plus Fast", + "seedance_2p0_bp_mini" => "Seedance 2.0 Plus Mini", + "seedance_2p0_bpu" => "Seedance 2.0 Plus Ultra", + "seedance_2p0_bpu_fast" => "Seedance 2.0 Plus Ultra Fast", + "seedance_2p0_bpu_mini" => "Seedance 2.0 Plus Ultra Mini", + "marble_0p1_mini" => "Marble Mini", + "marble_0p1_plus" => "Marble Plus", + "minimax_h3" => "MiniMax H3", + _ => "", + }; + if !special.is_empty() { + return special.to_owned(); + } + let words: Vec = id + .split('_') + .filter(|w| !w.is_empty()) + .map(|w| { + let bytes = w.as_bytes(); + // "2p0" → "2.0", "1p5" → "1.5" + if bytes[0].is_ascii_digit() && w.contains('p') && w.chars().all(|c| c.is_ascii_digit() || c == 'p') { + return w.replace('p', "."); + } + match w { + "gpt" => "GPT".to_owned(), + "veo" => "Google Veo".to_owned(), + "u" => "Ultra".to_owned(), + "q" => "Quality".to_owned(), + _ => { + let mut c = w.chars(); + c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect()) + }, + } + }) + .collect(); + words.join(" ") +} + +/// A model's prompt limit when the listing doesn't give one (the static lists' `maxPromptLength`; +/// `None` = unlimited, unknown models get the desktop's 1000). +fn default_prompt_max(id: &str) -> Option { + const LIMITS: [(&str, Option); 15] = [("nano_banana", None), ("gpt_image", None), ("seedance_2", None), ("seedance_1", Some(3000)), ("grok_imagine_video", Some(4096)), ("grok_video", Some(4096)), ("grok", Some(8000)), ("midjourney", Some(6000)), ("flux", Some(4000)), ("seedream", Some(4000)), ("qwen", Some(800)), ("kling", Some(2500)), ("happy_horse", Some(2500)), ("sora", Some(2000)), ("veo", Some(5000))]; + LIMITS.iter().find(|(prefix, _)| id.starts_with(prefix)).map_or(Some(DEFAULT_PROMPT_MAX), |(_, limit)| *limit) +} + +/// The image picker's one-liners (`selectorDescription`). +fn description(id: &str) -> &'static str { + match id { + "grok_image" | "grok_imagine_image" => "Fast af", + "midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => "Stunning style and quality", + "flux_pro_1p1_ultra" => "Higher quality model", + "flux_pro_1p1" => "High quality model", + "flux_1_dev" => "Fast, but lower quality", + "flux_1_schnell" => "Fastest image gen, but lowest quality", + "nano_banana" | "nano_banana_2" => "Fast instructive editing", + "nano_banana_pro" => "Powerful instructive editing", + "gpt_image_1" => "Slow, but super smart", + "gpt_image_1p5" => "Faster, improved", + "gpt_image_2" => "Smart with great text support", + "gpt_image_2p5_flare" => "Precise edits, keeps subjects intact", + "gpt_image_2p5_sunburst" => "Premium detail, slower", + "seedream_4" | "seedream_4p5" | "seedream_5_lite" => "Fast", + "grok_video" | "grok_imagine_video" => "Fastest video model", + "sora_2" => "Smart video model", + "veo_3" => "Slow, high-quality model", + _ => "", + } +} + +/// Typical generation time (the static list's `progressBarTime`). +fn expected_secs(id: &str, kind: MediaKind) -> f32 { + match id { + "grok_image" | "grok_imagine_image" | "flux_pro_1p1" | "flux_1_dev" | "flux_1_schnell" => 10.0, + "grok_video" | "grok_imagine_video" => 50.0, + "nano_banana" | "nano_banana_2" | "nano_banana_pro" => 25.0, + "flux_pro_1p1_ultra" => 35.0, + "midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => 45.0, + "gpt_image_1" | "gpt_image_1p5" | "gpt_image_2p5_flare" | "seedream_4" | "seedream_4p5" | "seedream_5_lite" => 60.0, + "gpt_image_2" | "gpt_image_2p5_sunburst" => 120.0, + _ if kind == MediaKind::Video => 900.0, + _ => 30.0, + } +} + +/// The maker, by id prefix, when the server doesn't say. +pub fn creator_for(id: &str) -> &'static str { + const PREFIXES: [(&str, &str); 16] = [("flux", "black_forest_labs"), ("nano_banana", "google"), ("gemini", "google"), ("veo", "google"), ("imagen", "google"), ("gpt_image", "openai"), ("sora", "openai"), ("seedream", "bytedance"), ("seedance", "bytedance"), ("seededit", "bytedance"), ("kling", "kling"), ("grok", "grok"), ("midjourney", "midjourney"), ("qwen", "alibaba"), ("happy_horse", "alibaba"), ("minimax", "minimax")]; + PREFIXES.iter().find(|(p, _)| id.starts_with(p)).map_or("artcraft", |(_, c)| c) +} + +/// The picker family (`getModelFamilyName`). +fn family(id: &str) -> &'static str { + const FAMILIES: [(&str, &str); 22] = [("seedance", "Seedance"), ("kling", "Kling"), ("veo", "Veo"), ("sora", "Sora"), ("happy_horse", "Happy Horse"), ("wan", "Wan"), ("vidu", "Vidu"), ("preview", "Preview"), ("nano_banana", "Nano Banana"), ("gemini_25_flash", "Nano Banana"), ("seedream", "Seedream"), ("seededit", "SeedEdit"), ("gpt_image", "GPT Image"), ("flux", "Flux"), ("midjourney", "Midjourney"), ("grok", "Grok"), ("qwen", "Qwen"), ("recraft", "Recraft"), ("imagen", "Imagen"), ("minimax", "MiniMax"), ("marble", "World Labs"), ("suno", "Suno")]; + FAMILIES.iter().find(|(p, _)| id.starts_with(p)).map_or("Other", |(_, f)| f) +} + +/// Family order in the picker (`FAMILY_ORDER`): video families, then image families; the rest +/// alphabetical, "Other" last. +fn family_rank(family: &str) -> (usize, String) { + const ORDER: [&str; 17] = ["Seedance", "Kling", "Veo", "Sora", "Happy Horse", "Wan", "Vidu", "Grok", "Nano Banana", "Seedream", "GPT Image", "Flux", "Midjourney", "Qwen", "SeedEdit", "Recraft", "Imagen"]; + match ORDER.iter().position(|f| *f == family) { + Some(i) => (i, String::new()), + None if family == "Other" => (usize::MAX, String::new()), + None => (ORDER.len(), family.to_owned()), + } +} + +/// A group of models in the picker. +pub struct Family<'a> { + pub name: String, + pub models: Vec<&'a ModelInfo>, +} + +/// Groups `models` by family (when there are enough of them), newest variant first. +pub fn grouped(models: &[ModelInfo]) -> Option>> { + if models.len() < GROUP_THRESHOLD { + return None; + } + let mut families: Vec> = Vec::new(); + for m in models { + match families.iter_mut().find(|f| f.name == m.family) { + Some(f) => f.models.push(m), + None => families.push(Family { name: m.family.clone(), models: vec![m] }), + } + } + families.sort_by_key(|f| family_rank(&f.name)); + for f in &mut families { + f.models.sort_by(|a, b| natural_desc(&a.name, &b.name)); + } + Some(families) +} + +/// Descending, comparing digit runs as numbers ("Seedream 4.5" before "Seedream 4"). +fn natural_desc(a: &str, b: &str) -> std::cmp::Ordering { + fn key(s: &str) -> Vec<(String, f64)> { + s.split(|c: char| c.is_whitespace()).map(|w| (w.to_lowercase(), w.parse::().unwrap_or(-1.0))).collect() + } + let (ka, kb) = (key(a), key(b)); + for (x, y) in ka.iter().zip(&kb) { + let ord = if x.1 >= 0.0 && y.1 >= 0.0 { y.1.total_cmp(&x.1) } else { y.0.cmp(&x.0) }; + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + kb.len().cmp(&ka.len()).reverse() +} + +/// The image page's list: text-to-image capable, enabled, sorted by name. +fn image_page_models(models: &[ModelInfo]) -> Vec { + const HIDDEN: [&str; 2] = ["minimax_h3_turbo", "minimax_h3_ultra"]; + let mut list: Vec = models.iter().filter(|m| m.prompt_supported && !m.disabled && !HIDDEN.contains(&m.id.as_str())).cloned().collect(); + list.sort_by_key(|m| m.name.to_lowercase()); + list +} + +/// The video page's list: enabled, without SwitchX, sorted by name. +fn video_page_models(models: &[ModelInfo]) -> Vec { + let mut list: Vec = models.iter().filter(|m| !m.disabled && m.id != "switch_x").cloned().collect(); + list.sort_by_key(|m| m.name.to_lowercase()); + list +} + +/// Both catalogs, for looking up names in the feed, and each create page's picker list. +#[derive(Default)] +pub struct Catalog { + pub image: Vec, + pub video: Vec, + pub image_page: Vec, + pub video_page: Vec, +} + +impl Catalog { + pub fn set_image(&mut self, models: Vec) { + self.image_page = image_page_models(&models); + self.image = models; + } + + pub fn set_video(&mut self, models: Vec) { + self.video_page = video_page_models(&models); + self.video = models; + } + + pub fn find(&self, id: &str) -> Option<&ModelInfo> { + self.image.iter().chain(&self.video).find(|m| m.id == id) + } +} + +impl ModelNames for Catalog { + fn display_name(&self, model_id: &str) -> String { + self.find(model_id).map_or_else(|| display_name(model_id), |m| m.name.clone()) + } + + fn creator(&self, model_id: &str) -> String { + self.find(model_id).map_or_else(|| creator_for(model_id).to_owned(), |m| m.creator.clone()) + } +} + +/// Aspect ratio labels (`AspectRatioPicker`). +pub fn aspect_label(value: &str) -> String { + match value { + "auto" => "Auto", + "square" => "Square", + "wide_five_by_four" => "5:4 (Wide)", + "wide_four_by_three" => "4:3 (Wide)", + "wide_three_by_two" => "3:2 (Wide)", + "wide_sixteen_by_nine" => "16:9 (Wide)", + "wide_twenty_one_by_nine" => "21:9 (Wide)", + "tall_four_by_five" => "4:5 (Tall)", + "tall_three_by_four" => "3:4 (Tall)", + "tall_two_by_three" => "2:3 (Tall)", + "tall_nine_by_sixteen" => "9:16 (Tall)", + "tall_nine_by_twenty_one" => "9:21 (Tall)", + "auto_2k" => "Auto (2K)", + "auto_3k" => "Auto (3K)", + "auto_4k" => "Auto (4K)", + "square_hd" => "Square (HD)", + "wide" => "Wide", + "tall" => "Tall", + other => other, + } + .to_owned() +} + +/// The proportions an aspect ratio's icon draws (`AspectRatioIcon`); `None` for the auto ones. +pub fn aspect_proportion(value: &str) -> Option<(f32, f32)> { + Some(match value { + "square" | "square_hd" => (1.0, 1.0), + "wide" => (16.0, 10.0), + "wide_five_by_four" => (5.0, 4.0), + "wide_four_by_three" => (4.0, 3.0), + "wide_three_by_two" => (3.0, 2.0), + "wide_sixteen_by_nine" => (16.0, 9.0), + "wide_twenty_one_by_nine" => (21.0, 9.0), + "tall" => (10.0, 16.0), + "tall_four_by_five" => (4.0, 5.0), + "tall_three_by_four" => (3.0, 4.0), + "tall_two_by_three" => (2.0, 3.0), + "tall_nine_by_sixteen" => (9.0, 16.0), + "tall_nine_by_twenty_one" => (9.0, 21.0), + _ => return None, + }) +} + +/// Resolution labels (`ResolutionPicker`). +pub fn resolution_label(value: &str) -> String { + match value { + "half_k" => "0.5K", + "four_eighty_p" => "480p", + "seven_twenty_p" => "720p", + "one_k" => "1K", + "ten_eighty_p" => "1080p", + "two_k" => "2K", + "three_k" => "3K", + "four_k" => "4K", + other => other, + } + .to_owned() +} + +/// Whether a resolution is high definition (2K and up), for its SD/HD caption. +pub fn resolution_is_hd(value: &str) -> bool { + matches!(value, "two_k" | "three_k" | "four_k") +} + +/// Quality labels (`QualityPicker`). +pub fn quality_label(value: &str) -> String { + match value { + "auto" => "Auto", + "max" => "Max", + "xhigh" => "Extra high", + "high" => "High", + "medium" => "Medium", + "low" => "Low", + other => other, + } + .to_owned() +} + +/// Bitrate labels. +pub fn bitrate_label(value: &str) -> String { + match value { + "normal" => "Normal".to_owned(), + "high" => "High".to_owned(), + other => other.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model(id: &str) -> ModelInfo { + ModelInfo { id: id.to_owned(), name: display_name(id), family: family(id).to_owned(), batch_default: 1, ..Default::default() } + } + + mod naming { + use super::*; + + #[test] + fn humanizes_versions_and_known_words() { + assert_eq!(display_name("seedance_2p0_fast"), "Seedance 2.0 Fast"); + assert_eq!(display_name("veo_3p1"), "Google Veo 3.1"); + assert_eq!(display_name("gpt_image_2p5_flare"), "GPT Image 2.5 Flare"); + assert_eq!(display_name("kling_3p0_pro"), "Kling 3.0 Pro"); + assert_eq!(display_name("nano_banana"), "Nano Banana"); + assert_eq!(display_name("seedance_2p0_bpu"), "Seedance 2.0 Plus Ultra"); + } + + #[test] + fn prefers_curated_names_and_the_real_maker() { + let nb = base("nano_banana_pro", MediaKind::Image, Some("Nano Banana Pro"), Some("artcraft".into())); + assert_eq!((nb.name.as_str(), nb.creator.as_str()), ("Nano Banana Pro", "google")); + let flux = base("flux_1_dev", MediaKind::Image, Some("FLUX.1 [dev]"), Some("black_forest_labs".into())); + assert_eq!(flux.name, "Flux 1 Dev"); + let mj = base("midjourney_7_niji", MediaKind::Image, Some("Midjourney v7 Niji (Anime)"), Some("midjourney".into())); + assert_eq!((mj.name.as_str(), mj.creator.as_str()), ("Midjourney v7 Niji (Anime)", "midjourney")); + } + + #[test] + fn falls_back_to_creator_by_prefix() { + assert_eq!(creator_for("seedream_4p5"), "bytedance"); + assert_eq!(creator_for("veo_3"), "google"); + assert_eq!(creator_for("something_new"), "artcraft"); + } + + #[test] + fn prompt_limits_follow_the_static_lists() { + assert_eq!(default_prompt_max("nano_banana_pro"), None); + assert_eq!(default_prompt_max("seedance_2p0"), None); + assert_eq!(default_prompt_max("seedance_1p5_pro"), Some(3000)); + assert_eq!(default_prompt_max("grok_imagine_video_1p5"), Some(4096)); + assert_eq!(default_prompt_max("grok_imagine_image"), Some(8000)); + assert_eq!(default_prompt_max("unknown_model"), Some(DEFAULT_PROMPT_MAX)); + } + } + + mod options { + use super::*; + + #[test] + fn resolve_prefers_current_then_default_then_first() { + let opts = vec!["square".to_owned(), "wide_sixteen_by_nine".to_owned()]; + let default = "wide_sixteen_by_nine".to_owned(); + assert_eq!(ModelInfo::resolve(Some("square"), &opts, Some(&default)).as_deref(), Some("square")); + assert_eq!(ModelInfo::resolve(Some("tall"), &opts, Some(&default)).as_deref(), Some("wide_sixteen_by_nine")); + assert_eq!(ModelInfo::resolve(None, &opts, None).as_deref(), Some("square")); + assert_eq!(ModelInfo::resolve(None, &[], None), None); + } + + #[test] + fn batch_counts_snap_to_valid_values() { + let mut m = model("grok_imagine_image"); + m.batch_sizes = vec![6]; + m.batch_default = 6; + assert_eq!(m.valid_batch(1), 6); + m.batch_sizes = vec![1, 2, 3, 4]; + m.batch_default = 1; + assert_eq!(m.valid_batch(3), 3); + assert_eq!(m.valid_batch(9), 1); + } + + #[test] + fn durations_clamp_to_ranges_and_snap_to_options() { + let mut m = model("seedance_2p0"); + m.duration_min = Some(4); + m.duration_max = Some(15); + m.duration_max_with_refs = Some(10); + m.duration_default = Some(5); + assert_eq!(m.resolve_duration(None, false), Some(5)); + assert_eq!(m.resolve_duration(Some(20), false), Some(15)); + assert_eq!(m.resolve_duration(Some(14), true), Some(10)); + assert_eq!(m.duration_range(false), Some((4, 15))); + + let mut m = model("sora_2"); + m.duration_options = vec![4, 8, 12]; + m.duration_default = Some(4); + assert_eq!(m.resolve_duration(Some(6), false), Some(8)); + assert_eq!(m.resolve_duration(Some(20), false), Some(12)); + assert_eq!(m.duration_range(false), Some((4, 12))); + } + } + + mod grouping { + use super::*; + + #[test] + fn groups_large_lists_by_family_newest_first() { + let ids = ["nano_banana", "nano_banana_pro", "nano_banana_2", "seedream_4", "seedream_4p5", "flux_1_dev", "gpt_image_1", "gpt_image_2"]; + let models: Vec = ids.iter().map(|id| model(id)).collect(); + let families = grouped(&models).expect("8 models group"); + let names: Vec<&str> = families.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Nano Banana", "Seedream", "GPT Image", "Flux"]); + let seedream: Vec<&str> = families[1].models.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(seedream, ["Seedream 4.5", "Seedream 4"]); + } + + #[test] + fn small_lists_stay_flat() { + let models: Vec = ["veo_3", "sora_2"].iter().map(|id| model(id)).collect(); + assert!(grouped(&models).is_none()); + } + } +} diff --git a/artcraft-native/src/overlays/account.rs b/artcraft-native/src/overlays/account.rs new file mode 100644 index 0000000000..73561407a6 --- /dev/null +++ b/artcraft-native/src/overlays/account.rs @@ -0,0 +1,204 @@ +//! The sign-in dialog and the settings dialog. + +use egui::{Color32, Id, Sense, Stroke, Ui, pos2, vec2}; + +use crate::shell::{WEB_APP_URL, group_thousands}; +use crate::theme; +use crate::ui::icons::Icon; +use crate::ui::widgets; + +#[derive(Default)] +pub struct LoginDialog { + pub username: String, + pub password: String, + pub error: Option, + pub busy: bool, + /// A browser sign-in waiting for approval: (page, confirmation code). + pub device: Option<(String, String)>, +} + +#[derive(Debug, PartialEq)] +pub enum LoginAction { + Close, + Password, + Browser, + CancelBrowser, +} + +impl LoginDialog { + pub fn show(&mut self, ctx: &egui::Context) -> Option { + let mut action = None; + let close = widgets::modal(ctx, Id::new("login"), 420.0, |ui| { + ui.horizontal(|ui| { + ui.label(widgets::heading("Sign in to ArtCraft", 24.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() { + action = Some(LoginAction::Close); + } + }); + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Use your ArtCraft account to generate images and videos.").color(theme::MUTED)); + ui.add_space(16.0); + let width = ui.available_width(); + match &self.device { + Some((url, code)) => { + widgets::hud(ui, "Confirmation code", theme::FAINT); + ui.add_space(6.0); + ui.label(egui::RichText::new(code).size(28.0).family(theme::mono_bold()).color(theme::INK).extra_letter_spacing(4.0)); + ui.add_space(6.0); + ui.label(egui::RichText::new("Approve the sign-in in your browser and check the code matches. This window updates by itself.").color(theme::MUTED)); + ui.add_space(10.0); + ui.horizontal(|ui| { + if widgets::button(ui, Some(Icon::ExternalLink), "Open page again", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + action = Some(LoginAction::CancelBrowser); + } + }); + }, + None => { + if ui.add_enabled_ui(!self.busy, |ui| widgets::button(ui, Some(Icon::Globe), "Sign in with browser", widgets::Kind::Primary, width, 40.0)).inner.clicked() { + action = Some(LoginAction::Browser); + } + ui.add_space(14.0); + divider_with_label(ui, "or"); + ui.add_space(14.0); + widgets::hud(ui, "Username or email", theme::FAINT); + ui.add_space(4.0); + let user_resp = text_field(ui, &mut self.username, false, "you@example.com"); + ui.add_space(10.0); + widgets::hud(ui, "Password", theme::FAINT); + ui.add_space(4.0); + let pass_resp = text_field(ui, &mut self.password, true, "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"); + let submitted = (user_resp.lost_focus() || pass_resp.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(14.0); + let ready = !self.username.trim().is_empty() && !self.password.is_empty() && !self.busy; + let clicked = ui.add_enabled_ui(ready, |ui| widgets::button(ui, if self.busy { None } else { Some(Icon::LogIn) }, if self.busy { "Signing in\u{2026}" } else { "Sign in" }, widgets::Kind::Secondary, width, 40.0)).inner.clicked(); + if ready && (clicked || submitted) { + action = Some(LoginAction::Password); + } + }, + } + if let Some(error) = &self.error { + ui.add_space(10.0); + ui.label(egui::RichText::new(error).color(theme::BAD)); + } + ui.add_space(16.0); + ui.horizontal(|ui| { + widgets::hud_link(ui, "Create account", &format!("{WEB_APP_URL}/signup")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + widgets::hud_link(ui, "Forgot password", &format!("{WEB_APP_URL}/forgot-password")); + }); + }); + }); + if close && action.is_none() { + action = Some(if self.device.is_some() { LoginAction::CancelBrowser } else { LoginAction::Close }); + } + action + } +} + +#[derive(Debug, PartialEq)] +pub enum SettingsAction { + Close, + SignOut, + SignIn, +} + +/// The settings dialog (Settings → Misc and Account in the webapp). +pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option, data_root: &std::path::Path) -> Option { + let mut action = None; + let close = widgets::modal(ctx, Id::new("settings"), 520.0, |ui| { + ui.horizontal(|ui| { + ui.label(widgets::heading("Settings", 24.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() { + action = Some(SettingsAction::Close); + } + }); + }); + ui.add_space(14.0); + widgets::hud(ui, "General", theme::FAINT); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(400.0); + ui.label(egui::RichText::new("Enter to generate").size(14.0).family(theme::medium())); + ui.label(egui::RichText::new("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.").size(12.5).color(theme::MUTED)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if switch(ui, *enter_to_generate).clicked() { + *enter_to_generate = !*enter_to_generate; + } + }); + }); + ui.add_space(18.0); + widgets::hud(ui, "Account", theme::FAINT); + ui.add_space(6.0); + match username { + Some(user) => { + ui.label(egui::RichText::new(format!("Signed in as @{user}")).size(14.0)); + if let Some(c) = credits { + ui.label(egui::RichText::new(format!("{} credits", group_thousands(c))).size(12.5).color(theme::MUTED)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if widgets::button(ui, Some(Icon::Gem), "Buy credits", widgets::Kind::Accent, 0.0, theme::CONTROL_H).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing"))); + } + if widgets::button(ui, Some(Icon::LogOut), "Log out", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + action = Some(SettingsAction::SignOut); + } + }); + }, + None => { + if widgets::button(ui, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() { + action = Some(SettingsAction::SignIn); + } + }, + } + ui.add_space(18.0); + widgets::hud(ui, "About", theme::FAINT); + ui.add_space(6.0); + ui.label(egui::RichText::new(format!("ArtCraft native {}", env!("CARGO_PKG_VERSION"))).size(13.0).color(theme::MUTED)); + if widgets::button(ui, Some(Icon::Folder), "Open data folder", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + if let Err(err) = open::that(data_root) { + log::warn!("Couldn't open {}: {err}", data_root.display()); + } + } + }); + if close && action.is_none() { + action = Some(SettingsAction::Close); + } + action +} + +/// A single-line input on the sunken surface with a hairline (and a blue line while focused). +fn text_field(ui: &mut Ui, value: &mut String, password: bool, hint: &str) -> egui::Response { + let id = ui.next_auto_id(); + let focused = ui.memory(|m| m.has_focus(id)); + egui::Frame::new().fill(theme::SUNKEN).stroke(Stroke::new(1.0, if focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| ui.add(egui::TextEdit::singleline(value).id(id).password(password).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new(hint).color(theme::FAINT)))).inner +} + +/// An on/off switch. +fn switch(ui: &mut Ui, on: bool) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 20.0), Sense::click()); + let t = ui.ctx().animate_bool(resp.id, on); + let fill = if on { theme::ACCENT } else { theme::WASH_HOVER }; + ui.painter().rect(rect, egui::CornerRadius::same(10), fill, Stroke::new(1.0, theme::LINE), egui::StrokeKind::Inside); + let x = egui::lerp(rect.left() + 10.0..=rect.right() - 10.0, t); + ui.painter().circle_filled(pos2(x, rect.center().y), 7.0, Color32::WHITE); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +fn divider_with_label(ui: &mut Ui, label: &str) { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::hover()); + let galley = widgets::caps(ui, label, 10.0, theme::mono(), 1.5, theme::FAINT); + let gap = galley.size().x / 2.0 + 10.0; + let p = ui.painter(); + p.hline(rect.left()..=rect.center().x - gap, rect.center().y, theme::hairline()); + p.hline(rect.center().x + gap..=rect.right(), rect.center().y, theme::hairline()); + p.galley(rect.center() - galley.size() / 2.0, galley, theme::FAINT); +} diff --git a/artcraft-native/src/overlays/library_picker.rs b/artcraft-native/src/overlays/library_picker.rs new file mode 100644 index 0000000000..b7c18cb3b3 --- /dev/null +++ b/artcraft-native/src/overlays/library_picker.rs @@ -0,0 +1,197 @@ +//! "Pick from library" (`GalleryModal mode="select"`): the user's images, videos or audio in a +//! grid, choosing up to the free reference slots. + +use std::collections::HashSet; + +use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, pos2, vec2}; + +use crate::backend::media_cache::{Lookup, MediaCache}; +use crate::backend::wire::MediaFile; +use crate::feed::types::MediaKind; +use crate::prompt_box::deck::cover_uv; +use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia}; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::widgets; + +const TILE_GAP: f32 = 8.0; +const COLUMNS: usize = 5; + +pub struct LibraryPicker { + pub kind: RefKind, + pub slot: ImageSlot, + /// Which page asked (the picked media goes back there). + pub page: MediaKind, + pub max: usize, + pub items: Vec, + pub loading: bool, + pub has_more: bool, + pub next_page: u32, + /// Already attached: shown but not selectable. + pub attached: HashSet, + selected: Vec, +} + +#[derive(Debug)] +pub enum PickerAction { + Close, + Confirm(Vec), + LoadMore, +} + +impl LibraryPicker { + pub fn new(kind: RefKind, slot: ImageSlot, page: MediaKind, max: usize, attached: HashSet) -> Self { + Self { kind, slot, page, max: max.max(1), items: Vec::new(), loading: false, has_more: true, next_page: 0, attached, selected: Vec::new() } + } + + /// The library's media class filter for this picker. + pub fn media_class(&self) -> &'static str { + match self.kind { + RefKind::Image => "image", + RefKind::Video => "video", + RefKind::Audio => "audio", + } + } + + pub fn apply_page(&mut self, page: u32, files: Vec, total_pages: u32) { + self.loading = false; + if page != self.next_page { + return; + } + for f in files { + if !self.items.iter().any(|i| i.token == f.token) { + self.items.push(f); + } + } + self.next_page = page + 1; + self.has_more = self.next_page < total_pages; + } + + pub fn show(&mut self, ctx: &egui::Context, cache: &mut MediaCache) -> Vec { + let mut actions = Vec::new(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).min(960.0); + let height = (screen.height() - 120.0).min(680.0); + let noun = match self.kind { + RefKind::Image => "images", + RefKind::Video => "videos", + RefKind::Audio => "audio", + }; + let close = widgets::modal(ctx, Id::new("library-picker"), width, |ui| { + ui.horizontal(|ui| { + ui.label(widgets::heading("Pick from library", 22.0)); + ui.add_space(8.0); + widgets::hud(ui, noun, theme::MUTED); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() { + actions.push(PickerAction::Close); + } + }); + }); + ui.add_space(10.0); + let grid_h = height - 120.0; + egui::ScrollArea::vertical().max_height(grid_h).min_scrolled_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let w = ui.available_width(); + let tile = (w - TILE_GAP * (COLUMNS - 1) as f32) / COLUMNS as f32; + let rows = self.items.len().div_ceil(COLUMNS); + let total = rows as f32 * (tile + TILE_GAP); + let origin = ui.min_rect().min; + ui.allocate_exact_size(vec2(w, total + 48.0), Sense::hover()); + let mut toggled = None; + for (i, item) in self.items.iter().enumerate() { + let local = Rect::from_min_size(pos2((i % COLUMNS) as f32 * (tile + TILE_GAP), (i / COLUMNS) as f32 * (tile + TILE_GAP)), vec2(tile, tile)); + if !local.intersects(viewport.expand(tile)) { + continue; + } + let rect = local.translate(origin.to_vec2()); + let attached = self.attached.contains(&item.token); + let order = self.selected.iter().position(|t| *t == item.token); + if tile_ui(ui, rect, item, self.kind, attached, order, cache) { + toggled = Some(item.token.clone()); + } + } + if let Some(token) = toggled { + self.toggle(token); + } + if self.items.is_empty() && !self.loading && !self.has_more { + ui.painter().text(origin + vec2(w / 2.0, 80.0), Align2::CENTER_CENTER, format!("No {noun} in your library yet"), FontId::new(14.0, egui::FontFamily::Proportional), theme::MUTED); + } + if self.loading { + icons::paint_spinner(ui, Rect::from_center_size(origin + vec2(w / 2.0, total + 24.0), vec2(24.0, 24.0)), theme::MUTED); + } else if self.has_more && viewport.max.y + 300.0 >= total { + actions.push(PickerAction::LoadMore); + } + }); + ui.add_space(12.0); + ui.horizontal(|ui| { + widgets::hud(ui, &format!("{}/{} selected", self.selected.len(), self.max), theme::MUTED); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let any = !self.selected.is_empty(); + if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Use selected", widgets::Kind::Primary, 0.0, theme::CONTROL_H)).inner.clicked() { + actions.push(PickerAction::Confirm(self.picks())); + } + if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Deselect all", widgets::Kind::Secondary, 0.0, theme::CONTROL_H)).inner.clicked() { + self.selected.clear(); + } + }); + }); + }); + if close { + actions.push(PickerAction::Close); + } + actions + } + + fn toggle(&mut self, token: String) { + if let Some(i) = self.selected.iter().position(|t| *t == token) { + self.selected.remove(i); + } else if self.max == 1 { + self.selected = vec![token]; + } else if self.selected.len() < self.max { + self.selected.push(token); + } + } + + fn picks(&self) -> Vec { + self.selected.iter().filter_map(|t| self.items.iter().find(|i| i.token == *t)).map(|f| RefMedia::from_library(self.kind, f.token.clone(), f.media_links.thumbnail(256), Some(f.media_links.cdn_url.clone()), f.maybe_duration_millis.map_or(0.0, |ms| ms as f32 / 1000.0))).collect() + } +} + +/// One square tile; returns whether it was clicked (and is selectable). +fn tile_ui(ui: &mut egui::Ui, rect: Rect, item: &MediaFile, kind: RefKind, attached: bool, order: Option, cache: &mut MediaCache) -> bool { + let resp = ui.interact(rect, Id::new(("picker-tile", &item.token)), if attached { Sense::hover() } else { Sense::click() }); + let p = ui.painter(); + p.rect_filled(rect, 0.0, theme::fade(theme::CONTROLS, 0.6)); + match item.media_links.thumbnail(256).map(|u| cache.get(ui.ctx(), &u)) { + Some(Lookup::Ready(t)) => { + let image = egui::Image::new(&t).uv(cover_uv(t.size_vec2())); + let image = if attached { image.tint(Color32::from_gray(90)) } else { image }; + image.paint_at(ui, rect); + }, + _ => { + let icon = match kind { + RefKind::Image => Icon::Image, + RefKind::Video => Icon::Video, + RefKind::Audio => Icon::Music, + }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(26.0, 26.0)), icon, theme::FAINT); + }, + } + if let Some(ms) = item.maybe_duration_millis.filter(|_| kind != RefKind::Image) { + let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 18.0), rect.max); + ui.painter().rect_filled(strip, 0.0, Color32::from_black_alpha(178)); + ui.painter().text(strip.center(), Align2::CENTER_CENTER, format!("{:.0}s", ms as f32 / 1000.0), FontId::new(11.0, theme::semibold()), Color32::WHITE); + } + let p = ui.painter(); + if attached { + p.text(rect.center(), Align2::CENTER_CENTER, "Attached", FontId::new(11.0, theme::semibold()), theme::fade(Color32::WHITE, 0.7)); + } else if let Some(i) = order { + p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside); + let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(22.0, 22.0)); + p.rect_filled(chip, theme::RADIUS, theme::ACCENT_400); + p.text(chip.center(), Align2::CENTER_CENTER, (i + 1).to_string(), FontId::new(11.0, theme::semibold()), Color32::WHITE); + } else if resp.hovered() { + p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)), egui::StrokeKind::Inside); + } + !attached && resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} diff --git a/artcraft-native/src/overlays/lightbox.rs b/artcraft-native/src/overlays/lightbox.rs new file mode 100644 index 0000000000..d0dc197111 --- /dev/null +++ b/artcraft-native/src/overlays/lightbox.rs @@ -0,0 +1,277 @@ +//! The lightbox (`lightbox-modal`): the media large on black, prev/next through the feed, and a +//! side column with the prompt, details and actions. + +use egui::{Align2, Color32, FontId, Id, Key, Rect, Sense, Ui, pos2, vec2}; + +use crate::backend::media_cache::{Lookup, MediaCache}; +use crate::backend::wire::Prompt; +use crate::feed::grid::ModelNames; +use crate::feed::types::{FeedItem, MediaKind}; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::{creator_icons, widgets}; + +const SIDE_WIDTH: f32 = 280.0; + +pub struct Lightbox { + pub kind: MediaKind, + pub token: String, + confirm_delete: bool, + prompt_expanded: bool, +} + +#[derive(Debug, PartialEq)] +pub enum LightboxAction { + Close, + Navigate(String), + Recreate, + MakeVideo, + Download, + Share, + Delete, + CopyPrompt(String), + Play(String), +} + +impl Lightbox { + pub fn new(kind: MediaKind, token: String) -> Self { + Self { kind, token, confirm_delete: false, prompt_expanded: false } + } + + pub fn show(&mut self, ctx: &egui::Context, item: &FeedItem, order: &[String], prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache) -> Vec { + let mut actions = Vec::new(); + let screen = ctx.content_rect(); + let size = vec2((screen.width() - 80.0).min(1200.0), (screen.height() - 80.0).min(760.0)); + let index = order.iter().position(|t| *t == item.token); + let prev = index.and_then(|i| i.checked_sub(1)).map(|i| order[i].clone()); + let next = index.and_then(|i| order.get(i + 1)).cloned(); + + let typing = ctx.memory(|m| m.focused().is_some()); + if !typing && !self.confirm_delete { + let (left, right) = ctx.input(|i| (i.key_pressed(Key::ArrowLeft) || i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowRight) || i.key_pressed(Key::ArrowDown))); + if let (true, Some(t)) = (left, &prev) { + actions.push(LightboxAction::Navigate(t.clone())); + } + if let (true, Some(t)) = (right, &next) { + actions.push(LightboxAction::Navigate(t.clone())); + } + } + + let close = widgets::modal(ctx, Id::new("lightbox"), size.x, |ui| { + ui.set_height(size.y - 32.0); + ui.horizontal_top(|ui| { + let media_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width() - SIDE_WIDTH - 16.0, size.y - 32.0)); + ui.allocate_rect(media_rect, Sense::hover()); + self.media(ui, media_rect, item, cache, &prev, &next, &mut actions); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_width(SIDE_WIDTH); + self.side(ui, item, prompt, names, cache, &mut actions); + }); + }); + }); + if close && !self.confirm_delete { + actions.push(LightboxAction::Close); + } + if self.confirm_delete { + self.confirm(ctx, &mut actions); + } + actions + } + + #[allow(clippy::too_many_arguments)] + fn media(&self, ui: &mut Ui, rect: Rect, item: &FeedItem, cache: &mut MediaCache, prev: &Option, next: &Option, actions: &mut Vec) { + ui.painter().rect_filled(rect, 0.0, Color32::BLACK); + let url = if item.kind == MediaKind::Image { Some(item.full_url.as_str()) } else { item.thumbnail.as_deref() }; + match url.map(|u| cache.get(ui.ctx(), u)) { + Some(Lookup::Ready(texture)) => { + let fit = fit_inside(texture.size_vec2(), rect.shrink(8.0).size()); + egui::Image::new(&texture).paint_at(ui, Rect::from_center_size(rect.center(), fit)); + }, + Some(Lookup::Loading) => { + // Show the thumbnail while the full image loads. + if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) { + let fit = fit_inside(t.size_vec2(), rect.shrink(8.0).size()); + egui::Image::new(&t).paint_at(ui, Rect::from_center_size(rect.center(), fit)); + } + icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), theme::MUTED); + }, + _ => { + ui.painter().text(rect.center(), Align2::CENTER_CENTER, "Image not available", FontId::new(14.0, egui::FontFamily::Proportional), theme::FAINT); + }, + } + if item.kind == MediaKind::Video { + let play = Rect::from_center_size(rect.center(), vec2(72.0, 72.0)); + let resp = ui.interact(play, Id::new(("lightbox-play", &item.token)), Sense::click()).on_hover_text("Play in your video player").on_hover_cursor(egui::CursorIcon::PointingHand); + ui.painter().circle_filled(play.center(), 36.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 })); + icons::paint(ui.painter(), play.shrink(22.0).translate(vec2(3.0, 0.0)), Icon::Play, Color32::WHITE); + if resp.clicked() { + actions.push(LightboxAction::Play(item.full_url.clone())); + } + } + let hovering = ui.rect_contains_pointer(rect); + for (target, left, tip) in [(prev, true, "Previous item"), (next, false, "Next item")] { + let Some(token) = target else { + continue; + }; + let x = if left { rect.left() + 28.0 } else { rect.right() - 28.0 }; + let button = Rect::from_center_size(pos2(x, rect.center().y), vec2(40.0, 40.0)); + let resp = ui.interact(button, Id::new(("lightbox-nav", tip)), Sense::click()).on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand); + if hovering || resp.hovered() { + ui.painter().circle_filled(button.center(), 20.0, Color32::from_black_alpha(if resp.hovered() { 210 } else { 140 })); + icons::paint(ui.painter(), button.shrink(11.0), if left { Icon::ChevronLeft } else { Icon::ChevronRight }, Color32::WHITE); + } + if resp.clicked() { + actions.push(LightboxAction::Navigate(token.clone())); + } + } + } + + fn side(&mut self, ui: &mut Ui, item: &FeedItem, prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache, actions: &mut Vec) { + ui.horizontal(|ui| { + widgets::hud(ui, item.kind.label(), theme::MUTED); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() { + actions.push(LightboxAction::Close); + } + }); + }); + ui.add_space(8.0); + let text = prompt.and_then(|p| p.maybe_positive_prompt.clone()).filter(|t| !t.is_empty()); + ui.horizontal(|ui| { + widgets::hud(ui, "Prompt", theme::FAINT); + if let Some(t) = &text { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add(egui::Button::new(egui::RichText::new("Copy").size(11.0)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + actions.push(LightboxAction::CopyPrompt(t.clone())); + } + }); + } + }); + match (&text, item.prompt_token.is_some(), prompt.is_some()) { + (Some(t), _, _) => { + let mut job = egui::text::LayoutJob::simple(t.clone(), FontId::new(13.0, egui::FontFamily::Proportional), theme::INK, ui.available_width()); + if !self.prompt_expanded { + job.wrap.max_rows = 4; + } + let galley = ui.painter().layout_job(job); + let clipped = galley.elided; + ui.label(galley); + if clipped || self.prompt_expanded { + let label = if self.prompt_expanded { "Hide" } else { "See all" }; + if ui.add(egui::Button::new(egui::RichText::new(label).size(11.5).color(theme::ACCENT_INK)).frame(false)).clicked() { + self.prompt_expanded = !self.prompt_expanded; + } + } + }, + (None, true, false) => { + let (rect, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint_spinner(ui, rect, theme::FAINT); + }, + _ => { + ui.label(egui::RichText::new("No prompt").size(13.0).color(theme::FAINT)); + }, + } + ui.add_space(14.0); + widgets::hud(ui, "Information", theme::FAINT); + ui.add_space(4.0); + if let Some(model) = item.model_id.as_deref() { + info_row(ui, "Model", |ui| { + let tex = creator_icons::texture(ui.ctx(), &names.creator(model)); + ui.add(egui::Image::new(&tex).tint(theme::INK).fit_to_exact_size(vec2(13.0, 13.0))); + ui.label(egui::RichText::new(names.display_name(model)).size(12.5)); + }); + } + if let Some(p) = prompt { + if let Some(ar) = &p.maybe_aspect_ratio { + info_row(ui, "Aspect Ratio", |ui| { + ui.label(egui::RichText::new(crate::models::aspect_label(ar)).size(12.5)); + }); + } + if let Some(res) = &p.maybe_resolution { + info_row(ui, "Resolution", |ui| { + ui.label(egui::RichText::new(crate::models::resolution_label(res)).size(12.5)); + }); + } + } + if let Some(d) = item.duration_secs { + info_row(ui, "Duration", |ui| { + ui.label(egui::RichText::new(format!("{d:.1}s")).size(12.5)); + }); + } + if let Some(Lookup::Ready(t)) = (item.kind == MediaKind::Image).then(|| cache.get(ui.ctx(), &item.full_url)) { + let s = t.size(); + info_row(ui, "Size", |ui| { + ui.label(egui::RichText::new(format!("{} \u{d7} {}", s[0], s[1])).size(12.5)); + }); + } + if let Some(created) = chrono::DateTime::from_timestamp(item.created_at, 0) { + let local = created.with_timezone(&chrono::Local); + info_row(ui, "Created", |ui| { + ui.label(egui::RichText::new(local.format("%b %-d, %Y %-I:%M:%S %p").to_string()).size(12.5)); + }); + } + + ui.add_space(16.0); + let w = (ui.available_width() - 8.0) / 2.0; + if item.prompt_token.is_some() && widgets::button(ui, Some(Icon::RotateCw), "Recreate", widgets::Kind::Primary, ui.available_width(), theme::CONTROL_H).clicked() { + actions.push(LightboxAction::Recreate); + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + if item.kind == MediaKind::Image && widgets::button(ui, Some(Icon::Video), "Make Video", widgets::Kind::Accent, w, theme::CONTROL_H).clicked() { + actions.push(LightboxAction::MakeVideo); + } + if widgets::button(ui, Some(Icon::Download), "Download", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() { + actions.push(LightboxAction::Download); + } + if widgets::button(ui, Some(Icon::Link), "Share", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() { + actions.push(LightboxAction::Share); + } + if widgets::button(ui, Some(Icon::Trash), "Delete", widgets::Kind::Danger, w, theme::CONTROL_H).clicked() { + self.confirm_delete = true; + } + }); + } + + fn confirm(&mut self, ctx: &egui::Context, actions: &mut Vec) { + let mut choice = None; + let close = widgets::modal(ctx, Id::new("lightbox-delete"), 420.0, |ui| { + ui.label(widgets::heading("Delete this media?", 20.0)); + ui.add_space(8.0); + ui.label(egui::RichText::new("This will permanently remove the media from your library. This action cannot be undone.").color(theme::MUTED)); + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::button(ui, None, "Delete", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() { + choice = Some(true); + } + if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + choice = Some(false); + } + }); + }); + if close || choice.is_some() { + self.confirm_delete = false; + } + if choice == Some(true) { + actions.push(LightboxAction::Delete); + } + } +} + +fn info_row(ui: &mut Ui, label: &str, value: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::hover()); + ui.painter().text(rect.left_center(), Align2::LEFT_CENTER, label, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED); + value(ui); + }); +} + +/// The largest size with `content`'s aspect that fits in `bounds`. +pub fn fit_inside(content: egui::Vec2, bounds: egui::Vec2) -> egui::Vec2 { + if content.x <= 0.0 || content.y <= 0.0 { + return bounds; + } + let scale = (bounds.x / content.x).min(bounds.y / content.y); + content * scale +} diff --git a/artcraft-native/src/overlays/mod.rs b/artcraft-native/src/overlays/mod.rs new file mode 100644 index 0000000000..627ae6bd1c --- /dev/null +++ b/artcraft-native/src/overlays/mod.rs @@ -0,0 +1,3 @@ +pub mod account; +pub mod library_picker; +pub mod lightbox; diff --git a/artcraft-native/src/pages/common.rs b/artcraft-native/src/pages/common.rs new file mode 100644 index 0000000000..1d1f85d393 --- /dev/null +++ b/artcraft-native/src/pages/common.rs @@ -0,0 +1,345 @@ +//! What the create pages share: the page shell (`DesktopCreatePageShell`), uploading references, +//! cost-estimate bookkeeping and the app-level requests pages make. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use egui::{Align2, Color32, Id, Mesh, Order, Rect, Ui, pos2, vec2}; +use serde_json::{Map, Value}; + +use crate::backend::media_cache::MediaCache; +use crate::backend::{Backend, Modality, UploadedMedia}; +use crate::feed::grid::{self, FeedAction, FeedView, RatioCache, ViewMode}; +use crate::feed::store::FeedStore; +use crate::feed::types::MediaKind; +use crate::models::Catalog; +use crate::prompt_box; +use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References}; +use crate::theme; +use crate::ui::toast::Toasts; +use crate::ui::widgets; + +/// How long settings must sit still before a cost estimate is requested. +const COST_DEBOUNCE_SECS: f64 = 0.3; +/// Gap between the prompt box and the window's bottom edge (`bottom-4`). +const BOX_BOTTOM: f32 = 16.0; + +/// The app services a page can use this frame. +pub struct Env<'a> { + pub ctx: egui::Context, + pub backend: &'a Backend, + pub cache: &'a mut MediaCache, + pub toasts: &'a mut Toasts, + pub catalog: &'a Catalog, + pub signed_in: bool, + pub enter_to_generate: bool, + pub view_mode: ViewMode, + pub ratios: &'a mut RatioCache, + pub prompts: &'a std::collections::HashMap, + pub requests: &'a mut Vec, +} + +/// Things only the app can do. +#[derive(Debug)] +pub enum AppRequest { + SignIn, + Open { kind: MediaKind, token: String }, + PickFromLibrary { kind: RefKind, slot: ImageSlot, max: usize, page: MediaKind }, + MakeVideo { token: String }, + Recreate { kind: MediaKind, token: String }, + Share(String), + Download(Vec), + CopyText(String), + Preview(String), + LoadMoreLibrary(FeedKey), +} + +/// Which feed a library page is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FeedKey { + Image, + Video, + Library, +} + +impl FeedKey { + pub fn of(kind: MediaKind) -> Self { + match kind { + MediaKind::Image => FeedKey::Image, + MediaKind::Video => FeedKey::Video, + } + } + + /// The library's media class filter. + pub fn media_classes(self) -> &'static str { + match self { + FeedKey::Image => "image", + FeedKey::Video => "video", + FeedKey::Library => "image,video", + } + } +} + +/// The page around the feed: the splash empty state (or the feed), a fade behind the prompt box, +/// and the prompt box (drawn by `prompt_box`) floating at the bottom centre. +#[allow(clippy::too_many_arguments)] +pub fn create_shell(ui: &mut Ui, env: &mut Env<'_>, kind: MediaKind, title: &str, subtitle: &str, feed: &mut FeedStore, box_height: &mut f32, prompt_box: impl FnOnce(&mut Ui, &mut Env<'_>)) { + let area = ui.max_rect(); + let bottom_offset = *box_height + 40.0; + if feed.has_content() { + let view = FeedView { id: Id::new(("feed", kind.label())), mode: env.view_mode, pending: &feed.pending, failed: &feed.failed, items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: kind == MediaKind::Image, bottom_padding: bottom_offset + 24.0 }; + let mut feed_ui = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0)))); + let actions = grid::show(&mut feed_ui, &view, env.cache, env.ratios, env.catalog); + handle_feed_actions(env, kind, feed, actions); + bottom_fade(ui, area); + } else { + empty_state(ui, env, area, bottom_offset, title, subtitle); + } + if env.signed_in && !feed.library_started() { + env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind))); + } + if feed.selecting { + selection_bar(ui, area, *box_height + 32.0, feed, kind, env.requests); + } + + let width = (area.width() - 32.0).min(prompt_box::MAX_WIDTH); + let pos = pos2(area.center().x, area.bottom() - BOX_BOTTOM); + let response = egui::Area::new(Id::new(("prompt-box", kind.label()))).order(Order::Middle).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| { + ui.set_width(width); + prompt_box(ui, env); + }); + *box_height = response.response.rect.height(); +} + +fn handle_feed_actions(env: &mut Env<'_>, kind: MediaKind, feed: &mut FeedStore, actions: Vec) { + for action in actions { + match action { + FeedAction::Open(token) => { + feed.last_viewed = Some(token.clone()); + env.requests.push(AppRequest::Open { kind, token }); + }, + FeedAction::ToggleSelect(token) => { + if !feed.selected.remove(&token) { + feed.selected.insert(token); + } + }, + FeedAction::DismissFailed(job) => feed.dismiss(&job), + FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind))), + FeedAction::Recreate(token) => env.requests.push(AppRequest::Recreate { kind, token }), + FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }), + FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)), + FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])), + FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)), + } + } +} + +/// The floating "N selected · Download · ×" pill (`SelectionActionBar`), `bottom` above the +/// window's bottom edge. +pub fn selection_bar(ui: &Ui, area: Rect, bottom: f32, feed: &mut FeedStore, kind: MediaKind, requests: &mut Vec) { + let pos = pos2(area.center().x, area.bottom() - bottom); + egui::Area::new(Id::new(("selection-bar", kind.label()))).order(Order::Foreground).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::new().fill(theme::fade(theme::PANEL, 0.95)).stroke(theme::hairline()).corner_radius(egui::CornerRadius::same(24)).inner_margin(egui::Margin::symmetric(10, 8)).shadow(theme::shadow()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + ui.add_space(6.0); + ui.label(egui::RichText::new(format!("{} selected", feed.selected.len())).size(13.0).family(theme::medium())); + let any = !feed.selected.is_empty(); + if ui.add_enabled_ui(any, |ui| widgets::button(ui, Some(crate::ui::icons::Icon::Download), "Download", widgets::Kind::Secondary, 0.0, 30.0)).inner.clicked() { + requests.push(AppRequest::Download(feed.selected.iter().cloned().collect())); + } + if widgets::ghost(ui, crate::ui::icons::Icon::X, None, "Exit selection", false).clicked() { + feed.selecting = false; + feed.selected.clear(); + } + }); + }); + }); +} + +/// The splash: a bordered section with corner marks, the page title and a sign-in prompt for +/// visitors (`CreateEmptyState`). +fn empty_state(ui: &mut Ui, env: &mut Env<'_>, area: Rect, bottom_offset: f32, title: &str, subtitle: &str) { + let usable = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - bottom_offset)); + let width = (usable.width() - 64.0).min(768.0); + let height = if env.signed_in { 190.0 } else { 250.0 }; + let section = Rect::from_center_size(usable.center(), vec2(width, height)); + let p = ui.painter(); + p.rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside); + widgets::corner_marks(p, section); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 52.0)))); + child.label(widgets::heading(title, 48.0)); + child.add_space(12.0); + child.label(egui::RichText::new(subtitle).size(16.0).color(theme::MUTED)); + if !env.signed_in { + child.add_space(24.0); + if widgets::button(&mut child, Some(crate::ui::icons::Icon::Sparkles), "Sign in to create", widgets::Kind::Primary, 0.0, 44.0).clicked() { + env.requests.push(AppRequest::SignIn); + } + } +} + +/// The 192 px fade behind the floating prompt box (`h-48 from-ui-background`). +fn bottom_fade(ui: &Ui, area: Rect) { + let band = Rect::from_min_max(pos2(area.left(), area.bottom() - 192.0), area.max); + let mut mesh = Mesh::default(); + let (top, bottom) = (Color32::TRANSPARENT, theme::BG); + mesh.colored_vertex(band.left_top(), top); + mesh.colored_vertex(band.right_top(), top); + mesh.colored_vertex(band.right_bottom(), bottom); + mesh.colored_vertex(band.left_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + // Same layer as the feed (painted after it), so it stays under the floating prompt box. + ui.painter().add(mesh); +} + +/// The selected model, else the page default, else the first. +pub fn selected_model<'a>(models: &'a [crate::models::ModelInfo], selected: Option<&str>, default_id: &str) -> Option<&'a crate::models::ModelInfo> { + selected.and_then(|id| models.iter().find(|m| m.id == id)).or_else(|| models.iter().find(|m| m.id == default_id)).or_else(|| models.first()) +} + +// --- References ------------------------------------------------------------------------------- + +/// Starts uploading `path` into `slot` (or the deck for videos and audio). +pub fn upload_path(env: &mut Env<'_>, refs: &mut References, path: &Path, kind: RefKind, slot: ImageSlot) { + if kind == RefKind::Video && !path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("mp4")) { + env.toasts.error("Failed to upload video. Please upload an MP4 file."); + return; + } + let mut item = RefMedia::uploading(kind, None); + if kind == RefKind::Image { + let key = format!("local://{}", item.id); + match std::fs::read(path) { + Ok(bytes) => { + env.cache.insert_bytes(&env.ctx, &key, Arc::from(bytes)); + item.preview = Some(key); + }, + Err(err) => { + env.toasts.error(format!("Couldn't read {}: {err}", file_name(path))); + return; + }, + } + } + env.backend.upload_file(item.id, kind, path.to_path_buf()); + place(refs, item, slot); +} + +/// Uploads a pasted image (PNG bytes) into `slot`. +pub fn upload_png(env: &mut Env<'_>, refs: &mut References, png: Vec, slot: ImageSlot) { + let mut item = RefMedia::uploading(RefKind::Image, None); + let key = format!("local://{}", item.id); + env.cache.insert_bytes(&env.ctx, &key, Arc::from(png.clone())); + item.preview = Some(key); + env.backend.upload_png(item.id, png); + place(refs, item, slot); +} + +fn place(refs: &mut References, item: RefMedia, slot: ImageSlot) { + match (item.kind, slot) { + (RefKind::Image, ImageSlot::FirstFrame) => refs.first_frame = Some(item), + (RefKind::Image, ImageSlot::LastFrame) => refs.last_frame = Some(item), + (kind, _) => refs.list_mut(kind).push(item), + } +} + +/// Applies a finished upload. Returns `false` (and drops the reference) when it failed. +pub fn apply_upload(toasts: &mut Toasts, refs: &mut References, ref_id: u64, result: Result) -> bool { + match result { + Ok(media) => { + if let Some(r) = refs.find_mut(ref_id) { + r.status = RefStatus::Ready; + r.token = Some(media.token); + r.duration_secs = media.duration_secs; + r.full_url = media.full_url; + if r.preview.is_none() { + r.preview = media.thumbnail; + } + } + true + }, + Err(message) => { + let kind = refs.find_mut(ref_id).map_or("file", |r| r.kind.noun()); + refs.remove(ref_id); + toasts.error(format!("Failed to upload {kind}: {message}")); + false + }, + } +} + +/// Picked library media, ready to attach. +pub fn attach_from_library(refs: &mut References, kind: RefKind, slot: ImageSlot, picks: Vec) { + for pick in picks { + let already = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).any(|r| r.token == pick.token); + if !already { + place(refs, RefMedia { kind, ..pick }, slot); + } + } +} + +/// Asks the user for files of `kinds` (`multiple` for decks). +pub fn pick_files(kinds: &[RefKind], multiple: bool) -> Vec { + let mut dialog = rfd::FileDialog::new(); + for kind in kinds { + let (name, exts): (&str, &[&str]) = match kind { + RefKind::Image => ("Images", &["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"]), + RefKind::Video => ("Videos (MP4)", &["mp4"]), + RefKind::Audio => ("Audio", &["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"]), + }; + dialog = dialog.add_filter(name, exts); + } + if multiple { + dialog.pick_files().unwrap_or_default() + } else { + dialog.pick_file().into_iter().collect() + } +} + +pub fn file_name(path: &Path) -> String { + path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_owned() +} + +// --- Cost estimates --------------------------------------------------------------------------- + +/// The latest cost estimate for a page, requested when the settings settle. +#[derive(Default)] +pub struct CostState { + /// The request the estimate below is for. + key: String, + pub credits: Option, + pending_key: Option<(String, f64)>, +} + +impl CostState { + /// Requests an estimate for `fields` once they've been stable for a moment. + pub fn update(&mut self, ui: &Ui, backend: &Backend, modality: Modality, fields: &Map) { + let key = Value::Object(fields.clone()).to_string(); + if key == self.key { + return; + } + let now = ui.input(|i| i.time); + match &self.pending_key { + Some((k, since)) if *k == key => { + if now - since >= COST_DEBOUNCE_SECS { + self.key = key.clone(); + self.credits = None; + self.pending_key = None; + backend.estimate_cost(modality, key, fields.clone()); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS)); + } + }, + _ => { + self.pending_key = Some((key, now)); + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS)); + }, + } + } + + /// Takes an estimate that arrived for `key`. + pub fn apply(&mut self, key: &str, credits: Option) { + if key == self.key { + self.credits = credits; + } + } +} diff --git a/artcraft-native/src/pages/create_image.rs b/artcraft-native/src/pages/create_image.rs new file mode 100644 index 0000000000..74fb0f5800 --- /dev/null +++ b/artcraft-native/src/pages/create_image.rs @@ -0,0 +1,283 @@ +//! Create Image (`TextToImage` + `PromptBoxImage`): prompt, reference images, model, aspect +//! ratio, resolution, quality and count; the cost; and the image feed. + +use egui::{Id, Ui}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::backend::wire::Prompt; +use crate::backend::{EnqueueMeta, Modality}; +use crate::feed::store::FeedStore; +use crate::feed::types::MediaKind; +use crate::models::{DEFAULT_IMAGE_MODEL, DEFAULT_PROMPT_MAX, ModelInfo}; +use crate::pages::common::{self, AppRequest, CostState, Env}; +use crate::pages::toolbar; +use crate::prompt_box::deck::{DeckAction, DeckLimits}; +use crate::prompt_box::model_selector::model_selector; +use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References}; +use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState, editor}; + +const PLACEHOLDER: &str = "Describe what you want in the image..."; + +/// What the page remembers between launches. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ImageSettings { + pub prompt: String, + pub model: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub quality: Option, + pub count: u16, +} + +#[derive(Default)] +pub struct CreateImagePage { + pub settings: ImageSettings, + pub refs: References, + pub feed: FeedStore, + pub cost: CostState, + pub generating: bool, + box_state: PromptBoxState, + box_height: f32, +} + +impl CreateImagePage { + pub fn new(settings: ImageSettings) -> Self { + let mut page = Self { settings, box_height: 140.0, ..Default::default() }; + page.box_state.editor.focus_requested = true; + page + } + + pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) { + let models = &env.catalog.image_page; + let model = common::selected_model(models, self.settings.model.as_deref(), DEFAULT_IMAGE_MODEL).cloned(); + if let Some(m) = &model { + self.reconcile(m); + let fields = self.fields(m, true); + self.cost.update(ui, env.backend, Modality::Image, &fields); + } + let mut feed = std::mem::take(&mut self.feed); + let mut box_height = self.box_height; + common::create_shell(ui, env, MediaKind::Image, "Create Image", "Add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref())); + self.feed = feed; + self.box_height = box_height; + } + + fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) { + let max_images = model.map_or(0, |m| m.image_refs_max); + let deck = if max_images > 0 { DeckMode::References(DeckLimits { max_images, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: env.signed_in }) } else { DeckMode::None }; + let accepts: &[RefKind] = if max_images > 0 { &[RefKind::Image] } else { &[] }; + let prompt_max = model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max); + let props = PromptBoxProps { id: Id::new("image-prompt-box"), placeholder: PLACEHOLDER, enter_to_generate: env.enter_to_generate, mentions: &[], max_length: Some(prompt_max), deck, accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some(), generating: self.generating, generate_tooltip: "Generate", warning: None, banner: None }; + let models = &env.catalog.image_page; + let settings = &mut self.settings; + let mut picked_model = None; + let mut left = |ui: &mut Ui| { + if let Some(id) = model_selector(ui, Id::new("image-model"), models, model, false) { + picked_model = Some(id); + } + let Some(m) = model else { + return; + }; + let aspect = ModelInfo::resolve(settings.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref()); + if let Some(v) = toolbar::aspect_ratio(ui, Id::new("image-aspect"), m, aspect.as_deref().unwrap_or("")) { + settings.aspect_ratio = Some(v); + } + let resolution = ModelInfo::resolve(settings.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref()); + if let Some(v) = toolbar::resolution(ui, Id::new("image-resolution"), m, resolution.as_deref().unwrap_or("")) { + settings.resolution = Some(v); + } + let quality = ModelInfo::resolve(settings.quality.as_deref(), &m.qualities, m.quality_default.as_ref()); + if let Some(v) = toolbar::quality(ui, Id::new("image-quality"), m, quality.as_deref().unwrap_or("")) { + settings.quality = Some(v); + } + }; + let count = settings.count; + let mut picked_count = None; + let mut right = |ui: &mut Ui| { + if let Some(m) = model { + picked_count = toolbar::count(ui, Id::new("image-count"), m, count, "images"); + } + }; + let actions = prompt_box::show(ui, &mut settings.prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right); + if let Some(id) = picked_model { + self.settings.model = Some(id); + } + if let Some(n) = picked_count { + self.settings.count = n; + } + for action in actions { + self.handle(action, env, model, max_images); + } + } + + fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, max_images: usize) { + match action { + PromptBoxAction::Generate => self.generate(env, model), + PromptBoxAction::ClearAll => { + self.settings.prompt.clear(); + self.refs.clear(); + }, + PromptBoxAction::Deck(deck) => match deck { + DeckAction::Upload { .. } => { + let free = max_images.saturating_sub(self.refs.images.len()); + for path in common::pick_files(&[RefKind::Image], free > 1).into_iter().take(free) { + common::upload_path(env, &mut self.refs, &path, RefKind::Image, ImageSlot::Reference); + } + }, + DeckAction::Library { kind, slot } => { + env.requests.push(AppRequest::PickFromLibrary { kind, slot, max: max_images.saturating_sub(self.refs.images.len()).max(1), page: MediaKind::Image }); + }, + DeckAction::Remove(id) => self.refs.remove(id), + DeckAction::ClearAll => self.refs.images.clear(), + DeckAction::ReorderImages { from, to } => { + let item = self.refs.images.remove(from); + self.refs.images.insert(to.min(self.refs.images.len()), item); + }, + DeckAction::Preview(id) => { + if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) { + env.requests.push(AppRequest::Preview(url)); + } + }, + DeckAction::SwapFrames => {}, + }, + PromptBoxAction::DroppedFiles(paths) => self.add_dropped(env, &paths, max_images), + PromptBoxAction::PastedImage(png) => { + if self.refs.images.len() >= max_images { + env.toasts.error(max_images_message(max_images)); + } else { + common::upload_png(env, &mut self.refs, png, ImageSlot::Reference); + } + }, + } + } + + /// Routes dropped files: images into the deck (up to the model's limit); others get a note. + fn add_dropped(&mut self, env: &mut Env<'_>, paths: &[std::path::PathBuf], max_images: usize) { + let kinds: Vec> = paths.iter().map(|p| RefKind::from_path(p)).collect(); + let images: Vec<&std::path::PathBuf> = paths.iter().zip(&kinds).filter(|(_, k)| **k == Some(RefKind::Image)).map(|(p, _)| p).collect(); + let has_video = kinds.contains(&Some(RefKind::Video)); + let has_audio = kinds.contains(&Some(RefKind::Audio)); + if has_video || has_audio { + let what = match (has_video, has_audio) { + (true, true) => "Video or audio", + (true, false) => "Video", + _ => "Audio", + }; + env.toasts.error(format!("{what} references aren't available here")); + } else if images.is_empty() { + env.toasts.error("Only image files can be added here"); + } + if images.is_empty() { + return; + } + let free = max_images.saturating_sub(self.refs.images.len()); + if free == 0 { + env.toasts.error(max_images_message(max_images)); + return; + } + for path in images.into_iter().take(free) { + common::upload_path(env, &mut self.refs, path, RefKind::Image, ImageSlot::Reference); + } + } + + fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) { + if self.generating || self.settings.prompt.trim().is_empty() { + return; + } + let Some(model) = model else { + return; + }; + if !env.signed_in { + env.requests.push(AppRequest::SignIn); + return; + } + let limit = model.prompt_max; + if editor::is_over_limit(&self.settings.prompt, limit) { + env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default())); + return; + } + if self.refs.any_uploading() { + env.toasts.error("Please wait for your references to finish uploading"); + return; + } + self.generating = true; + let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(self.settings.count.max(1)), ref_image: self.refs.images.first().and_then(|r| r.preview.clone()) }; + env.backend.generate(Modality::Image, self.fields(model, false), meta); + } + + /// The OmniGen request (`GenerateImageRequest` after `api_fields`). `estimate` swaps + /// still-uploading references for placeholders so the cost reflects them. + fn fields(&self, model: &ModelInfo, estimate: bool) -> Map { + let mut f = Map::new(); + f.insert("model".into(), json!(model.id)); + f.insert("prompt".into(), json!(self.settings.prompt)); + f.insert("image_batch_count".into(), json!(model.valid_batch(self.settings.count))); + if let Some(v) = ModelInfo::resolve(self.settings.aspect_ratio.as_deref(), &model.aspect_ratios, model.aspect_default.as_ref()) { + f.insert("aspect_ratio".into(), json!(v)); + } + if !model.resolutions.is_empty() { + if let Some(v) = ModelInfo::resolve(self.settings.resolution.as_deref(), &model.resolutions, model.resolution_default.as_ref()) { + f.insert("resolution".into(), json!(v)); + } + } + if !model.qualities.is_empty() { + if let Some(v) = ModelInfo::resolve(self.settings.quality.as_deref(), &model.qualities, model.quality_default.as_ref()) { + f.insert("quality".into(), json!(v)); + } + } + if model.image_refs_max > 0 && !self.refs.images.is_empty() { + let tokens: Vec = self.refs.images.iter().enumerate().filter_map(|(i, r)| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}")))).collect(); + if !tokens.is_empty() { + f.insert("image_media_tokens".into(), json!(tokens)); + } + } + if estimate { + f.remove("prompt"); + } + f + } + + /// Keeps the count valid for the model (`isValidGenerationCount`). + fn reconcile(&mut self, model: &ModelInfo) { + let count = model.valid_batch(self.settings.count); + if count != self.settings.count { + self.settings.count = count; + } + } + + pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) { + self.generating = false; + self.feed.add_enqueued(MediaKind::Image, job_tokens, meta, env.catalog); + env.toasts.success("Image generation enqueued!"); + } + + pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) { + self.generating = false; + env.toasts.error(if message.is_empty() { "Failed to start image generation. Please try again." } else { message }); + } + + /// Recreate: the original prompt, model, settings and reference images. + pub fn apply_prompt(&mut self, prompt: &Prompt) { + self.settings.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default(); + if let Some(model) = &prompt.maybe_model_type { + self.settings.model = Some(model.clone()); + } + if prompt.maybe_aspect_ratio.is_some() { + self.settings.aspect_ratio = prompt.maybe_aspect_ratio.clone(); + } + if prompt.maybe_resolution.is_some() { + self.settings.resolution = prompt.maybe_resolution.clone(); + } + if let Some(count) = prompt.maybe_batch_count { + self.settings.count = count; + } + self.refs.images = prompt.maybe_context_images.iter().flatten().filter(|c| !matches!(c.semantic.as_str(), "vid_end_frame" | "vid_ref" | "audioref")).map(|c| RefMedia::from_library(RefKind::Image, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0)).collect(); + self.box_state.editor.focus_requested = true; + } +} + +fn max_images_message(max: usize) -> String { + format!("Max {max} image reference{}", if max == 1 { "" } else { "s" }) +} diff --git a/artcraft-native/src/pages/create_video.rs b/artcraft-native/src/pages/create_video.rs new file mode 100644 index 0000000000..ef0f76a614 --- /dev/null +++ b/artcraft-native/src/pages/create_video.rs @@ -0,0 +1,541 @@ +//! Create Video (`ImageToVideo` + `PromptBoxVideo`): keyframe or omni-reference input, the +//! model's pickers (aspect ratio, resolution, bitrate, format, duration, sound, count), the cost, +//! and the video feed. + +use std::path::PathBuf; + +use egui::{Id, Ui}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::backend::wire::Prompt; +use crate::backend::{EnqueueMeta, Modality}; +use crate::feed::store::FeedStore; +use crate::feed::types::MediaKind; +use crate::models::{self, DEFAULT_PROMPT_MAX, DEFAULT_VIDEO_MODEL, ModelInfo}; +use crate::pages::common::{self, AppRequest, CostState, Env}; +use crate::pages::toolbar; +use crate::prompt_box::deck::{DeckAction, DeckLimits}; +use crate::prompt_box::editor::{self, MentionItem, MentionKind}; +use crate::prompt_box::model_selector::model_selector; +use crate::prompt_box::pickers::{self, PickOption, Trigger}; +use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References}; +use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState}; +use crate::ui::icons::Icon; +use crate::ui::widgets::Leading; + +const PLACEHOLDER: &str = "Describe what you want to happen in the video..."; +const PLACEHOLDER_REFERENCE: &str = "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."; +const TEXT_ONLY_BANNER: &str = "This model can't generate from text alone - add a starting frame to animate your prompt."; + +/// How the user steers the video: first/last frames, or a mix of reference media. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputMode { + Keyframe, + #[default] + Reference, +} + +/// What the page remembers between launches. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct VideoSettings { + pub prompt: String, + pub model: Option, + pub aspect_ratio: Option, + pub resolution: Option, + pub bitrate: Option, + pub output_format: Option, + pub duration: Option, + pub generate_audio: bool, + pub input_mode: InputMode, + pub count: u16, +} + +impl Default for VideoSettings { + fn default() -> Self { + Self { prompt: String::new(), model: None, aspect_ratio: None, resolution: None, bitrate: None, output_format: None, duration: None, generate_audio: true, input_mode: InputMode::Reference, count: 1 } + } +} + +#[derive(Default)] +pub struct CreateVideoPage { + pub settings: VideoSettings, + pub refs: References, + pub feed: FeedStore, + pub cost: CostState, + pub generating: bool, + box_state: PromptBoxState, + box_height: f32, +} + +impl CreateVideoPage { + pub fn new(settings: VideoSettings) -> Self { + let mut page = Self { settings, box_height: 140.0, ..Default::default() }; + page.box_state.editor.focus_requested = true; + page + } + + pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) { + let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned(); + if let Some(m) = &model { + self.reconcile(m); + let fields = self.fields(m, true); + self.cost.update(ui, env.backend, Modality::Video, &fields); + } + let mut feed = std::mem::take(&mut self.feed); + let mut box_height = self.box_height; + common::create_shell(ui, env, MediaKind::Video, "Create Video", "Choose an image, add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref())); + self.feed = feed; + self.box_height = box_height; + } + + fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) { + let mode = self.settings.input_mode; + let reference = mode == InputMode::Reference; + let limits = model.map(|m| deck_limits(m, env.signed_in)); + let deck = match (model, mode) { + (Some(m), InputMode::Keyframe) if m.start_frame => DeckMode::Keyframes { show_last: m.end_frame, library: env.signed_in }, + (Some(_), InputMode::Reference) => limits.clone().map_or(DeckMode::None, DeckMode::References), + _ => DeckMode::None, + }; + let accepts: Vec = match (&deck, model) { + (DeckMode::Keyframes { .. }, _) => vec![RefKind::Image], + (DeckMode::References(l), _) => [(RefKind::Image, l.max_images), (RefKind::Video, l.max_videos), (RefKind::Audio, l.max_audios)].into_iter().filter(|(_, n)| *n > 0).map(|(k, _)| k).collect(), + _ => Vec::new(), + }; + let mentions = if reference { self.mentions() } else { Vec::new() }; + let needs_start = model.is_some_and(|m| self.needs_start_frame(m)); + let banner = model.filter(|m| !m.text_to_video && !self.has_image_input()).map(|_| TEXT_ONLY_BANNER); + let props = PromptBoxProps { id: Id::new("video-prompt-box"), placeholder: if reference { PLACEHOLDER_REFERENCE } else { PLACEHOLDER }, enter_to_generate: env.enter_to_generate, mentions: &mentions, max_length: Some(model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max)), deck, accepts: &accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some() && !needs_start, generating: self.generating, generate_tooltip: if needs_start { "Add a starting image before generating" } else { "Generate" }, warning: needs_start.then_some("Starting frame required"), banner }; + + let models = &env.catalog.video_page; + let mut prompt = std::mem::take(&mut self.settings.prompt); + let count = self.settings.count; + let settings = &mut self.settings; + let with_refs = reference && !self.refs.images.is_empty(); + let mut picked_model = None; + let mut picked_mode = None; + let mut left = |ui: &mut Ui| { + if let Some(id) = model_selector(ui, Id::new("video-model"), models, model, true) { + picked_model = Some(id); + } + if let Some(m) = model { + picked_mode = video_toolbar(ui, m, settings, with_refs).or(picked_mode); + } + }; + let mut picked_count = None; + let mut right = |ui: &mut Ui| { + if let Some(m) = model { + picked_count = toolbar::count(ui, Id::new("video-count"), m, count, "videos"); + } + }; + let actions = prompt_box::show(ui, &mut prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right); + self.settings.prompt = prompt; + if let Some(id) = picked_model { + self.settings.model = Some(id); + } + if let Some(n) = picked_count { + self.settings.count = n; + } + if let Some(mode) = picked_mode { + switch_mode(&mut self.settings, &mut self.refs, mode); + } + for action in actions { + self.handle(action, env, model, limits.as_ref()); + } + } + + fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, limits: Option<&DeckLimits>) { + match action { + PromptBoxAction::Generate => self.generate(env, model), + PromptBoxAction::ClearAll => { + self.settings.prompt.clear(); + self.refs.clear(); + }, + PromptBoxAction::Deck(deck) => match deck { + DeckAction::Upload { kinds, slot } => { + let multiple = slot == ImageSlot::Reference; + let paths = common::pick_files(&kinds, multiple); + self.add_files(env, &paths, slot, limits); + }, + DeckAction::Library { kind, slot } => { + let max = match slot { + ImageSlot::Reference => limits.map_or(1, |l| free_slots(&self.refs, kind, l)).max(1), + _ => 1, + }; + env.requests.push(AppRequest::PickFromLibrary { kind, slot, max, page: MediaKind::Video }); + }, + DeckAction::Remove(id) => self.refs.remove(id), + DeckAction::ClearAll => { + self.refs.images.clear(); + self.refs.videos.clear(); + self.refs.audios.clear(); + }, + DeckAction::ReorderImages { from, to } => { + let item = self.refs.images.remove(from); + self.refs.images.insert(to.min(self.refs.images.len()), item); + }, + DeckAction::Preview(id) => { + if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) { + env.requests.push(AppRequest::Preview(url)); + } + }, + DeckAction::SwapFrames => self.refs.swap_frames(), + }, + PromptBoxAction::DroppedFiles(paths) => self.add_files(env, &paths, ImageSlot::Reference, limits), + PromptBoxAction::PastedImage(png) => match self.settings.input_mode { + InputMode::Keyframe => match self.free_keyframe(model) { + Some(slot) => common::upload_png(env, &mut self.refs, png, slot), + None => env.toasts.error(frames_full_message(model)), + }, + InputMode::Reference => { + if limits.is_some_and(|l| free_slots(&self.refs, RefKind::Image, l) > 0) { + common::upload_png(env, &mut self.refs, png, ImageSlot::Reference); + } else { + env.toasts.error(max_message(RefKind::Image, limits.map_or(0, |l| l.max_images), None)); + } + }, + }, + } + } + + /// Routes picked or dropped files (`handleDroppedFiles`): keyframe mode fills the first frame, + /// then the last; reference mode adds each kind up to its limit. + fn add_files(&mut self, env: &mut Env<'_>, paths: &[PathBuf], slot: ImageSlot, limits: Option<&DeckLimits>) { + let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned(); + for path in paths { + let Some(kind) = RefKind::from_path(path) else { + env.toasts.error(format!("{} isn't a supported file", common::file_name(path))); + continue; + }; + match (self.settings.input_mode, kind) { + (InputMode::Keyframe, RefKind::Image) => { + let target = if slot == ImageSlot::Reference { self.free_keyframe(model.as_ref()) } else { Some(slot) }; + match target { + Some(slot) => common::upload_path(env, &mut self.refs, path, kind, slot), + None => env.toasts.error(frames_full_message(model.as_ref())), + } + }, + (InputMode::Keyframe, _) => env.toasts.error(format!("{} references aren't available here", if kind == RefKind::Video { "Video" } else { "Audio" })), + (InputMode::Reference, _) => { + let Some(l) = limits else { + continue; + }; + let max = match kind { + RefKind::Image => l.max_images, + RefKind::Video => l.max_videos, + RefKind::Audio => l.max_audios, + }; + if max == 0 { + env.toasts.error(format!("{}{} references aren't available here", kind.noun()[..1].to_uppercase(), &kind.noun()[1..])); + } else if free_slots(&self.refs, kind, l) == 0 { + let secs = if kind == RefKind::Video { l.max_video_secs } else { l.max_audio_secs }; + env.toasts.error(max_message(kind, max, secs.filter(|_| kind != RefKind::Image))); + } else { + common::upload_path(env, &mut self.refs, path, kind, ImageSlot::Reference); + } + }, + } + } + } + + /// After an upload lands: enforce the model's total-duration limits. + pub fn check_durations(&mut self, env: &mut Env<'_>, ref_id: u64) { + let Some(model) = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL) else { + return; + }; + for (kind, max) in [(RefKind::Video, model.video_refs_max_secs), (RefKind::Audio, model.audio_refs_max_secs)] { + let Some(max) = max else { + continue; + }; + if self.refs.list(kind).iter().any(|r| r.id == ref_id) && self.refs.total_secs(kind) > f32::from(max) + 0.5 { + self.refs.remove(ref_id); + env.toasts.error(format!("Total {} duration cannot exceed {max}s", kind.noun())); + } + } + } + + fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) { + if self.generating { + return; + } + if self.settings.prompt.trim().is_empty() { + env.toasts.error("Please enter a prompt to generate video"); + return; + } + let Some(model) = model else { + env.toasts.error("Please select a model to generate video"); + return; + }; + if !env.signed_in { + env.requests.push(AppRequest::SignIn); + return; + } + let limit = model.prompt_max; + if editor::is_over_limit(&self.settings.prompt, limit) { + env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default())); + return; + } + if self.needs_start_frame(model) { + env.toasts.error("Please add a starting frame image to generate video"); + return; + } + if self.refs.any_uploading() { + env.toasts.error("Please wait for your references to finish uploading"); + return; + } + self.generating = true; + let ref_image = self.refs.first_frame.as_ref().or(self.refs.images.first()).and_then(|r| r.preview.clone()); + let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image }; + env.backend.generate(Modality::Video, self.fields(model, false), meta); + } + + /// The OmniGen request (`GenerateVideoRequest` after `api_fields`). + fn fields(&self, m: &ModelInfo, estimate: bool) -> Map { + let s = &self.settings; + let mut f = Map::new(); + f.insert("model".into(), json!(m.id)); + if !estimate { + f.insert("prompt".into(), json!(s.prompt)); + } + let resolved = [("aspect_ratio", ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref())), ("resolution", ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref()).filter(|_| !m.resolutions.is_empty())), ("bitrate", ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).filter(|_| !m.bitrates.is_empty())), ("output_format", ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).filter(|_| !m.output_formats.is_empty()))]; + for (key, value) in resolved { + if let Some(v) = value { + f.insert(key.into(), json!(v)); + } + } + let reference = s.input_mode == InputMode::Reference; + if let Some(d) = m.resolve_duration(s.duration, reference && !self.refs.images.is_empty()) { + f.insert("duration_seconds".into(), json!(d)); + } + if m.sound_toggle { + f.insert("generate_audio".into(), json!(s.generate_audio)); + } + // Only models that list batch sizes take a count (older ones reject the field). + if !m.batch_sizes.is_empty() { + f.insert("video_batch_count".into(), json!(m.valid_batch(s.count))); + } + let token = |r: &RefMedia, i: usize| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}"))); + if reference { + for (key, list) in [("reference_image_media_tokens", &self.refs.images), ("reference_video_media_tokens", &self.refs.videos), ("reference_audio_media_tokens", &self.refs.audios)] { + let tokens: Vec = list.iter().enumerate().filter_map(|(i, r)| token(r, i)).collect(); + if !tokens.is_empty() { + f.insert(key.into(), json!(tokens)); + } + } + if estimate { + let millis = |kind| (self.refs.total_secs(kind) * 1000.0) as u32; + f.insert("estimate_only".into(), json!({ "total_input_video_duration_millis": millis(RefKind::Video), "total_input_audio_duration_millis": millis(RefKind::Audio) })); + } + } else { + if let Some(t) = self.refs.first_frame.as_ref().and_then(|r| token(r, 0)) { + f.insert("start_frame_image_media_token".into(), json!(t)); + } + if let Some(t) = self.refs.last_frame.as_ref().filter(|_| m.end_frame).and_then(|r| token(r, 1)) { + f.insert("end_frame_image_media_token".into(), json!(t)); + } + } + f + } + + /// Model-change reconciliation: input mode, end frame and count. + fn reconcile(&mut self, m: &ModelInfo) { + let wanted = match self.settings.input_mode { + InputMode::Reference if !m.supports_reference_mode() => InputMode::Keyframe, + InputMode::Keyframe if !m.start_frame && m.supports_reference_mode() => InputMode::Reference, + mode => mode, + }; + if wanted != self.settings.input_mode { + switch_mode(&mut self.settings, &mut self.refs, wanted); + } + if !m.end_frame && self.refs.last_frame.is_some() { + self.refs.last_frame = None; + } + self.settings.count = m.valid_batch(self.settings.count); + } + + fn needs_start_frame(&self, m: &ModelInfo) -> bool { + let keyframe = self.settings.input_mode == InputMode::Keyframe; + let required = (keyframe && m.start_frame_required) || (!m.text_to_video && m.start_frame); + required && !self.has_image_input() + } + + fn has_image_input(&self) -> bool { + match self.settings.input_mode { + InputMode::Keyframe => self.refs.first_frame.is_some(), + InputMode::Reference => !self.refs.images.is_empty(), + } + } + + /// The first empty keyframe slot, if any. + fn free_keyframe(&self, model: Option<&ModelInfo>) -> Option { + if self.refs.first_frame.is_none() { + Some(ImageSlot::FirstFrame) + } else if self.refs.last_frame.is_none() && model.is_some_and(|m| m.end_frame) { + Some(ImageSlot::LastFrame) + } else { + None + } + } + + /// `@Image1…`, `@Video1…`, `@Audio1…` for the attached references. + fn mentions(&self) -> Vec { + let mut items = editor::mention_items(MentionKind::Image, self.refs.images.iter().map(|r| r.preview.clone())); + items.extend(editor::mention_items(MentionKind::Video, self.refs.videos.iter().map(|r| r.preview.clone()))); + items.extend(editor::mention_items(MentionKind::Audio, self.refs.audios.iter().map(|_| None))); + items + } + + /// "Make Video" from an image: it becomes the first frame (or the first reference). + pub fn set_start_image(&mut self, image: RefMedia) { + match self.settings.input_mode { + InputMode::Keyframe => self.refs.first_frame = Some(image), + InputMode::Reference => self.refs.images = vec![image], + } + self.box_state.editor.focus_requested = true; + } + + pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) { + self.generating = false; + self.feed.add_enqueued(MediaKind::Video, job_tokens, meta, env.catalog); + env.toasts.success("Video generation enqueued!"); + } + + pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) { + self.generating = false; + env.toasts.error(if message.is_empty() { "Failed to start video generation. Please try again." } else { message }); + } + + /// Recreate: the original prompt, model, settings and references. + pub fn apply_prompt(&mut self, prompt: &Prompt) { + let s = &mut self.settings; + s.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default(); + if prompt.maybe_model_type.is_some() { + s.model = prompt.maybe_model_type.clone(); + } + if prompt.maybe_aspect_ratio.is_some() { + s.aspect_ratio = prompt.maybe_aspect_ratio.clone(); + } + if prompt.maybe_resolution.is_some() { + s.resolution = prompt.maybe_resolution.clone(); + } + if prompt.maybe_duration_seconds.is_some() { + s.duration = prompt.maybe_duration_seconds; + } + if let Some(audio) = prompt.maybe_generate_audio { + s.generate_audio = audio; + } + self.refs.clear(); + for c in prompt.maybe_context_images.iter().flatten() { + let kind = match c.semantic.as_str() { + "vid_ref" => RefKind::Video, + "audioref" => RefKind::Audio, + _ => RefKind::Image, + }; + let item = RefMedia::from_library(kind, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0); + match (c.semantic.as_str(), kind) { + ("vid_end_frame", _) => self.refs.last_frame = Some(item), + (_, RefKind::Image) if s.input_mode == InputMode::Keyframe && self.refs.first_frame.is_none() => self.refs.first_frame = Some(item), + (_, kind) => self.refs.list_mut(kind).push(item), + } + } + self.box_state.editor.focus_requested = true; + } +} + +/// The video-only pickers: aspect ratio through input mode (left toolbar after the model). +/// Returns a newly picked input mode (applied by the caller, since it moves references). +fn video_toolbar(ui: &mut Ui, m: &ModelInfo, s: &mut VideoSettings, with_refs: bool) -> Option { + let aspect = ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref()); + if let Some(v) = toolbar::aspect_ratio(ui, Id::new("video-aspect"), m, aspect.as_deref().unwrap_or("")) { + s.aspect_ratio = Some(v); + } + let resolution = ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref()); + if let Some(v) = toolbar::resolution(ui, Id::new("video-resolution"), m, resolution.as_deref().unwrap_or("")) { + s.resolution = Some(v); + } + if !m.bitrates.is_empty() { + let current = ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).unwrap_or_default(); + let options: Vec> = m.bitrates.iter().map(|v| PickOption::new(v.clone(), models::bitrate_label(v))).collect(); + if let Some(v) = pickers::option_picker(ui, Id::new("video-bitrate"), Trigger::Pill(Leading::None), "Bitrate", "Bitrate", ¤t, &options) { + s.bitrate = Some(v); + } + } + if !m.output_formats.is_empty() { + let current = ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).unwrap_or_default(); + let options: Vec> = m.output_formats.iter().map(|v| PickOption::new(v.clone(), v.to_uppercase())).collect(); + if let Some(v) = pickers::option_picker(ui, Id::new("video-format"), Trigger::Pill(Leading::None), "Output Format", "Output format", ¤t, &options) { + s.output_format = Some(v); + } + } + if let (Some((min, max)), Some(current)) = (m.duration_range(with_refs), m.resolve_duration(s.duration, with_refs)) { + if let Some(v) = pickers::duration_picker(ui, Id::new("video-duration"), u32::from(current), u32::from(min), u32::from(max)) { + s.duration = Some(v as u16); + } + } + if m.sound_toggle && pickers::toggle(ui, Id::new("video-sound"), if s.generate_audio { Icon::Volume } else { Icon::VolumeOff }, "Sound", s.generate_audio, if s.generate_audio { "Sound: ON" } else { "Sound: OFF" }) { + s.generate_audio = !s.generate_audio; + } + if m.start_frame && m.supports_reference_mode() { + let options = [PickOption::new(InputMode::Keyframe, "Keyframe").subtitle("First/Last frame"), PickOption::new(InputMode::Reference, "Omni Reference").subtitle("Multi-media ref")]; + return pickers::option_picker(ui, Id::new("video-mode"), Trigger::Pill(Leading::None), "Input Mode", "Input mode", &s.input_mode, &options); + } + None +} + +/// Switches input mode, carrying the first image across: keyframe → reference moves the first +/// frame to the front of the deck (and drops the last frame); reference → keyframe takes the +/// deck's first image as the first frame (and drops videos and audio). +fn switch_mode(s: &mut VideoSettings, refs: &mut References, mode: InputMode) { + match mode { + InputMode::Reference => { + if let Some(first) = refs.first_frame.take() { + refs.images.insert(0, first); + } + refs.last_frame = None; + }, + InputMode::Keyframe => { + if refs.first_frame.is_none() && !refs.images.is_empty() { + refs.first_frame = Some(refs.images.remove(0)); + } + refs.videos.clear(); + refs.audios.clear(); + }, + } + s.input_mode = mode; +} + +fn deck_limits(m: &ModelInfo, library: bool) -> DeckLimits { + DeckLimits { max_images: m.image_refs_max, max_videos: m.video_refs_max, max_video_secs: m.video_refs_max_secs.map(f32::from), max_audios: m.audio_refs_max, max_audio_secs: m.audio_refs_max_secs.map(f32::from), library } +} + +fn free_slots(refs: &References, kind: RefKind, l: &DeckLimits) -> usize { + let max = match kind { + RefKind::Image => l.max_images, + RefKind::Video => l.max_videos, + RefKind::Audio => l.max_audios, + }; + max.saturating_sub(refs.list(kind).len()) +} + +fn max_message(kind: RefKind, max: usize, secs: Option) -> String { + let noun = match kind { + RefKind::Image => "image reference", + RefKind::Video => "video", + RefKind::Audio => "audio clip", + }; + let plural = if max == 1 { "" } else { "s" }; + match secs { + Some(s) => format!("Max {max} {noun}{plural} / {}s total", s.round() as i64), + None => format!("Max {max} {noun}{plural}"), + } +} + +fn frames_full_message(model: Option<&ModelInfo>) -> &'static str { + if model.is_some_and(|m| m.end_frame) { + "First and last frames are already set" + } else { + "The first frame is already set" + } +} diff --git a/artcraft-native/src/pages/mod.rs b/artcraft-native/src/pages/mod.rs new file mode 100644 index 0000000000..a20581c603 --- /dev/null +++ b/artcraft-native/src/pages/mod.rs @@ -0,0 +1,5 @@ +pub mod common; +pub mod create_image; +pub mod create_video; +pub mod other; +pub mod toolbar; diff --git a/artcraft-native/src/pages/other.rs b/artcraft-native/src/pages/other.rs new file mode 100644 index 0000000000..106ae11f52 --- /dev/null +++ b/artcraft-native/src/pages/other.rs @@ -0,0 +1,129 @@ +//! The pages around the create tools: Home, the Library, and a placeholder for tools that are +//! still web-only. + +use egui::{Color32, Id, Rect, Sense, Ui, pos2, vec2}; + +use crate::feed::grid::{self, FeedAction, FeedView}; +use crate::feed::store::FeedStore; +use crate::feed::types::MediaKind; +use crate::pages::common::{AppRequest, Env, FeedKey}; +use crate::shell::{Page, WEB_APP_URL}; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::widgets; + +/// Home: what ArtCraft does and where to start. Returns a page to open. +pub fn home(ui: &mut Ui, signed_in: bool, requests: &mut Vec) -> Option { + let mut open = None; + let area = ui.max_rect(); + let width = (area.width() - 64.0).min(960.0); + let mut col = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(area.center().x - width / 2.0, area.top() + 56.0), vec2(width, area.height() - 56.0)))); + let ui = &mut col; + widgets::hud(ui, "01 / ArtCraft", theme::FAINT); + ui.add_space(10.0); + ui.label(widgets::heading("Capable tools for artists.", 52.0)); + ui.add_space(10.0); + ui.label(egui::RichText::new("Generate images and videos with the best models, steer them with references and keyframes, and keep everything in your library.").size(17.0).color(theme::MUTED)); + ui.add_space(28.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 16.0; + let card_w = (width - 16.0) / 2.0; + for (page, icon, title, text) in [(Page::CreateImage, Icon::Image, "Create Image", "Describe anything. See it in seconds."), (Page::CreateVideo, Icon::Video, "Create Video", "Describe a scene. See it in motion.")] { + if card(ui, card_w, icon, title, text) { + open = Some(page); + } + } + }); + if !signed_in { + ui.add_space(24.0); + if widgets::button(ui, Some(Icon::LogIn), "Sign in to start", widgets::Kind::Primary, 0.0, 44.0).clicked() { + requests.push(AppRequest::SignIn); + } + } + open +} + +fn card(ui: &mut Ui, width: f32, icon: Icon, title: &str, text: &str) -> bool { + let (rect, resp) = ui.allocate_exact_size(vec2(width, 180.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect(rect, 0.0, if hovered { Color32::from_rgba_unmultiplied(242, 241, 238, 8) } else { Color32::from_rgba_unmultiplied(242, 241, 238, 4) }, egui::Stroke::new(1.0, if hovered { theme::LINE_STRONG } else { theme::LINE }), egui::StrokeKind::Inside); + widgets::corner_marks(p, rect); + icons::paint(p, Rect::from_min_size(rect.min + vec2(24.0, 24.0), vec2(26.0, 26.0)), icon, theme::INK); + let body = Rect::from_min_max(pos2(rect.left() + 24.0, rect.top() + 76.0), pos2(rect.right() - 24.0, rect.bottom() - 16.0)); + let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); + inner.label(widgets::heading(title, 26.0)); + inner.add_space(4.0); + inner.label(egui::RichText::new(text).size(14.0).color(theme::MUTED)); + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.right() - 28.0, rect.top() + 36.0), vec2(16.0, 16.0)), Icon::ChevronRight, if hovered { theme::INK } else { theme::FAINT }); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +/// The Library: everything generated, newest first. +pub fn library(ui: &mut Ui, env: &mut Env<'_>, feed: &mut FeedStore) { + let area = ui.max_rect(); + if !env.signed_in { + placeholder(ui, area, "Library", "Sign in to see everything you've generated.", None); + return; + } + if !feed.library_started() { + env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library)); + } + if !feed.has_content() && !feed.loading { + placeholder(ui, area, "Library", "Your generations will appear here.", None); + return; + } + let view = FeedView { id: Id::new("library-feed"), mode: env.view_mode, pending: &[], failed: &[], items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: true, bottom_padding: 24.0 }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0)).with_min_y(area.top() + 2.0))); + for action in grid::show(&mut child, &view, env.cache, env.ratios, env.catalog) { + match action { + FeedAction::Open(token) => { + let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind); + feed.last_viewed = Some(token.clone()); + env.requests.push(AppRequest::Open { kind, token }); + }, + FeedAction::ToggleSelect(token) => { + if !feed.selected.remove(&token) { + feed.selected.insert(token); + } + }, + FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library)), + FeedAction::Recreate(token) => { + let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind); + env.requests.push(AppRequest::Recreate { kind, token }); + }, + FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }), + FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)), + FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])), + FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)), + FeedAction::DismissFailed(_) => {}, + } + } + if feed.selecting { + crate::pages::common::selection_bar(ui, area, 24.0, feed, MediaKind::Image, env.requests); + } +} + +/// A tool that hasn't come to the native app yet. +pub fn coming_soon(ui: &mut Ui, page: Page) { + let area = ui.max_rect(); + placeholder(ui, area, page.label(), "This tool is coming to the native app. You can use it in the ArtCraft web app in the meantime.", Some(format!("{WEB_APP_URL}{}", page.web_path()))); +} + +/// The bordered, corner-marked splash used for empty and placeholder pages. +fn placeholder(ui: &mut Ui, area: Rect, title: &str, text: &str, web_url: Option) { + let width = (area.width() - 64.0).min(768.0); + let section = Rect::from_center_size(area.center(), vec2(width, if web_url.is_some() { 240.0 } else { 180.0 })); + ui.painter().rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside); + widgets::corner_marks(ui.painter(), section); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 44.0)))); + child.label(widgets::heading(title, 44.0)); + child.add_space(10.0); + child.label(egui::RichText::new(text).size(15.0).color(theme::MUTED)); + if let Some(url) = web_url { + child.add_space(20.0); + if widgets::button(&mut child, Some(Icon::ExternalLink), "Open in web app", widgets::Kind::Primary, 0.0, 40.0).clicked() { + child.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + } +} diff --git a/artcraft-native/src/pages/toolbar.rs b/artcraft-native/src/pages/toolbar.rs new file mode 100644 index 0000000000..da9e4169e7 --- /dev/null +++ b/artcraft-native/src/pages/toolbar.rs @@ -0,0 +1,57 @@ +//! Toolbar pickers both create pages use, each shown only when the model offers a choice. + +use egui::{Id, Ui}; + +use crate::models::{self, ModelInfo}; +use crate::prompt_box::pickers::{self, OptionLead, PickOption, Trigger}; +use crate::ui::icons::Icon; +use crate::ui::widgets::Leading; + +/// The aspect ratio picker (`AspectRatioPicker`). `current` is the effective value. +pub fn aspect_ratio(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option { + if model.aspect_ratios.is_empty() { + return None; + } + let options: Vec> = model.aspect_ratios.iter().map(|v| PickOption::new(v.clone(), models::aspect_label(v)).lead(aspect_lead(v))).collect(); + let lead = match models::aspect_proportion(current) { + Some(ratio) => Leading::Aspect(Some(ratio)), + None => Leading::Icon(Icon::Wand), + }; + pickers::option_picker(ui, id, Trigger::Pill(lead), "Aspect Ratio", "Aspect Ratio", ¤t.to_owned(), &options) +} + +fn aspect_lead(value: &str) -> OptionLead { + match models::aspect_proportion(value) { + Some(ratio) => OptionLead::Aspect(Some(ratio)), + None => OptionLead::Icon(Icon::Wand), + } +} + +/// The resolution picker (`ResolutionPicker`), its pill captioned SD or HD. +pub fn resolution(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option { + if model.resolutions.is_empty() { + return None; + } + let options: Vec> = model.resolutions.iter().map(|v| PickOption::new(v.clone(), models::resolution_label(v))).collect(); + let caption = if models::resolution_is_hd(current) { "HD" } else { "SD" }; + pickers::option_picker(ui, id, Trigger::Pill(Leading::Caption(caption)), "Resolution", "Resolution", ¤t.to_owned(), &options) +} + +/// The quality picker (`QualityPicker`). +pub fn quality(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option { + if model.qualities.is_empty() { + return None; + } + let options: Vec> = model.qualities.iter().map(|v| PickOption::new(v.clone(), models::quality_label(v))).collect(); + pickers::option_picker(ui, id, Trigger::Pill(Leading::Icon(Icon::Gem)), "Quality", "Quality", ¤t.to_owned(), &options) +} + +/// The number of generations (`GenerationCountPicker`): a quiet copy-icon button. +pub fn count(ui: &mut Ui, id: Id, model: &ModelInfo, current: u16, noun: &str) -> Option { + let options: Vec> = model.batch_options().into_iter().map(|n| PickOption::new(n, n.to_string())).collect(); + if options.len() <= 1 { + ui.add_enabled_ui(false, |ui| crate::ui::widgets::ghost(ui, Icon::Copy, Some(¤t.to_string()), "Number of generations", false)); + return None; + } + pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &format!("No. of {noun}"), "Number of generations", ¤t, &options) +} diff --git a/artcraft-native/src/prompt_box/deck.rs b/artcraft-native/src/prompt_box/deck.rs new file mode 100644 index 0000000000..f33db02bee --- /dev/null +++ b/artcraft-native/src/prompt_box/deck.rs @@ -0,0 +1,529 @@ +//! The reference widgets left of the prompt: the fanned reference deck (images, videos, audio) +//! and the tilted first/last keyframe slots. + +use egui::emath::Rot2; +use egui::load::SizedTexture; +use egui::{Align2, Color32, CornerRadius, FontId, Id, Order, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, Vec2, pos2, vec2}; + +use crate::backend::media_cache::{Lookup, MediaCache}; +use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References}; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::widgets; + +/// `w-14`. +const CARD: f32 = 56.0; +/// Rotations of the collapsed fan's cards (`FAN_TRANSFORMS`), with their x offsets. +const FAN: [(f32, f32); 3] = [(-8.0, 0.0), (2.0, 12.0), (9.0, 24.0)]; +const OPEN_DELAY: f64 = 0.17; +const CLOSE_DELAY: f64 = 0.15; +const MENU_DELAY: f64 = 0.1; + +/// Something the user asked the deck to do; the prompt box carries it out. +#[derive(Clone, Debug, PartialEq)] +pub enum DeckAction { + /// Open the file picker for these kinds into `slot` (images only use the slot). + Upload { + kinds: Vec, + slot: ImageSlot, + }, + /// Open the library picker for `kind` into `slot`. + Library { + kind: RefKind, + slot: ImageSlot, + }, + Remove(u64), + ClearAll, + ReorderImages { + from: usize, + to: usize, + }, + Preview(u64), + SwapFrames, +} + +/// What the deck may offer, from the model's limits. +#[derive(Clone, Debug)] +pub struct DeckLimits { + pub max_images: usize, + pub max_videos: usize, + pub max_video_secs: Option, + pub max_audios: usize, + pub max_audio_secs: Option, + /// Whether "From library" is offered (always, once signed in). + pub library: bool, +} + +impl DeckLimits { + fn kinds(&self) -> Vec { + [(RefKind::Image, self.max_images), (RefKind::Video, self.max_videos), (RefKind::Audio, self.max_audios)].into_iter().filter(|(_, max)| *max > 0).map(|(k, _)| k).collect() + } + + fn max(&self, kind: RefKind) -> usize { + match kind { + RefKind::Image => self.max_images, + RefKind::Video => self.max_videos, + RefKind::Audio => self.max_audios, + } + } + + fn max_secs(&self, kind: RefKind) -> Option { + match kind { + RefKind::Image => None, + RefKind::Video => self.max_video_secs, + RefKind::Audio => self.max_audio_secs, + } + } +} + +/// The fanned reference deck. Returns the user's actions this frame. +pub fn reference_deck(ui: &mut Ui, id: Id, refs: &References, limits: &DeckLimits, cache: &mut MediaCache, always_expanded: bool) -> Vec { + let mut actions = Vec::new(); + let items: Vec<&RefMedia> = refs.images.iter().chain(&refs.videos).chain(&refs.audios).collect(); + let open_kinds: Vec = limits.kinds().into_iter().filter(|k| refs.list(*k).len() < limits.max(*k)).collect(); + if items.is_empty() && open_kinds.is_empty() { + return actions; + } + let menu = add_menu_entries(refs, limits, &open_kinds, ImageSlot::Reference); + + if always_expanded { + expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions); + return actions; + } + + if items.is_empty() { + let resp = add_tile(ui, id.with("empty"), "Reference", -6.0); + if resp.clicked() { + actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference }); + } + hover_menu(ui, id.with("empty-menu"), &resp, &menu, &mut actions); + return actions; + } + + // Collapsed fan: footprint tracks the card count plus the + button's overhang. + let shown = items.len().min(3); + let stack_w = (shown - 1) as f32 * 12.0 + CARD; + let (rect, _) = ui.allocate_exact_size(vec2(stack_w + 12.0, CARD), Sense::hover()); + let stack = Rect::from_min_size(rect.min, vec2(stack_w, CARD)); + let expanded = hover_latch(ui, id.with("expand"), stack, OPEN_DELAY); + let painter = ui.painter().clone(); + if !expanded.open { + for (i, item) in items.iter().take(3).enumerate().rev() { + let (deg, dx) = FAN[i]; + let card = Rect::from_min_size(stack.min + vec2(dx, 0.0), vec2(CARD, CARD)); + paint_card(ui, &painter, card, deg, item, cache, false); + } + if items.len() > 3 { + let badge_text = items.len().to_string(); + let badge = Rect::from_center_size(pos2(stack.right() + 2.0, stack.top() + 2.0), vec2(8.0 + 7.0 * badge_text.len() as f32, 18.0)); + painter.rect_filled(badge, CornerRadius::same(9), theme::ACCENT); + painter.text(badge.center(), Align2::CENTER_CENTER, badge_text, FontId::new(10.0, theme::semibold()), Color32::WHITE); + } + if !open_kinds.is_empty() { + let plus = Rect::from_min_size(pos2(stack.right() - 12.0, stack.bottom() - 20.0), vec2(24.0, 24.0)); + let resp = ui.interact(plus, id.with("plus"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + let hovered = resp.hovered(); + painter.circle(plus.center(), if hovered { 13.0 } else { 12.0 }, widgets::blend(theme::CONTROLS, if hovered { theme::WASH_HOVER } else { Color32::TRANSPARENT }), Stroke::new(1.0, theme::LINE)); + icons::paint(&painter, plus.shrink(6.0), Icon::Plus, theme::INK); + if resp.clicked() { + actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference }); + } + hover_menu(ui, id.with("plus-menu"), &resp, &menu, &mut actions); + } + } + if expanded.open { + // The panel overlays the deck from its bottom-left corner, growing up and right. + let area = egui::Area::new(id.with("panel")).order(Order::Foreground).pivot(Align2::LEFT_BOTTOM).fixed_pos(stack.left_bottom()).show(ui.ctx(), |ui| { + widgets::popover_frame().inner_margin(8).show(ui, |ui| { + ui.set_max_width(ui.ctx().content_rect().width() * 0.5); + expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions); + }); + }); + expanded.keep_open_while(ui, area.response.rect.union(stack)); + } + actions +} + +/// The first and last keyframe slots (the last only when `show_last`). +pub fn keyframe_cards(ui: &mut Ui, id: Id, refs: &References, show_last: bool, library: bool, cache: &mut MediaCache) -> Vec { + let mut actions = Vec::new(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + slot_card(ui, id.with("first"), refs.first_frame.as_ref(), "First frame", if show_last { -6.0 } else { 0.0 }, ImageSlot::FirstFrame, library, cache, &mut actions); + if show_last { + let both = refs.first_frame.is_some() && refs.last_frame.is_some(); + let (rect, _) = ui.allocate_exact_size(vec2(8.0, CARD), Sense::hover()); + let swap = Rect::from_center_size(rect.center() - vec2(0.0, 6.0), vec2(20.0, 20.0)); + let resp = ui.interact(swap, id.with("swap"), if both { Sense::click() } else { Sense::hover() }); + let hovered = resp.hovered() && both; + let p = ui.painter(); + p.circle( + swap.center(), + if hovered { 11.0 } else { 10.0 }, + Color32::from_black_alpha(if hovered { + 204 + } else if both { + 153 + } else { + 102 + }), + Stroke::new(1.0, theme::fade(Color32::WHITE, if both { 0.2 } else { 0.1 })), + ); + icons::paint(p, swap.shrink(5.0), Icon::ArrowLeftRight, theme::fade(Color32::WHITE, if both { 1.0 } else { 0.5 })); + if both && resp.on_hover_text("Swap frames").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + actions.push(DeckAction::SwapFrames); + } + slot_card(ui, id.with("last"), refs.last_frame.as_ref(), "Last frame", 6.0, ImageSlot::LastFrame, library, cache, &mut actions); + } + }); + actions +} + +/// One labelled keyframe slot: a dashed add card, or the frame once set. +#[allow(clippy::too_many_arguments)] +fn slot_card(ui: &mut Ui, id: Id, item: Option<&RefMedia>, label: &str, tilt: f32, slot: ImageSlot, library: bool, cache: &mut MediaCache, actions: &mut Vec) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + match item { + Some(item) => { + let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click()); + let hovered = resp.hovered(); + let painter = ui.painter().clone(); + paint_card(ui, &painter, rect, if hovered { 0.0 } else { tilt }, item, cache, hovered); + if item.status == RefStatus::Ready { + if remove_button(ui, id.with("x"), rect, hovered) { + actions.push(DeckAction::Remove(item.id)); + } else if resp.on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() { + actions.push(DeckAction::Preview(item.id)); + } + } + let (label_rect, _) = ui.allocate_exact_size(vec2(CARD, 10.0), Sense::hover()); + ui.painter().text(label_rect.center(), Align2::CENTER_CENTER, label, FontId::new(9.0, theme::medium()), theme::MUTED); + }, + None => { + let resp = add_tile(ui, id, label, tilt); + if resp.clicked() { + actions.push(DeckAction::Upload { kinds: vec![RefKind::Image], slot }); + } + { + let menu = vec![MenuGroup { kind: RefKind::Image, hint: None, slot, library }]; + hover_menu(ui, id.with("menu"), &resp, &menu, actions); + } + }, + } + }); +} + +/// The expanded deck: a header with "Clear all", the cards (reorderable images) and a + tile. +#[allow(clippy::too_many_arguments)] +fn expanded_panel(ui: &mut Ui, id: Id, refs: &References, items: &[&RefMedia], open_kinds: &[RefKind], menu: &[MenuGroup], cache: &mut MediaCache, actions: &mut Vec) { + if items.len() > 1 { + ui.horizontal(|ui| { + let n = items.len(); + ui.label(egui::RichText::new(format!("{n} reference{}", if n == 1 { "" } else { "s" })).size(11.0).family(theme::medium()).color(theme::MUTED)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let resp = ui.add(egui::Button::new(egui::RichText::new("Clear all").size(11.0)).frame(false)); + if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + actions.push(DeckAction::ClearAll); + } + }); + }); + } + let drag_id = id.with("dragging"); + let mut dragging: Option = ui.data(|d| d.get_temp(drag_id)); + let mut image_rects = Vec::with_capacity(refs.images.len()); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + for item in items { + let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click_and_drag()); + let hovered = resp.hovered(); + let painter = ui.painter().clone(); + let image_index = refs.images.iter().position(|r| r.id == item.id); + if let Some(i) = image_index { + image_rects.push((i, rect)); + if resp.drag_started() && refs.images.len() > 1 { + dragging = Some(i); + } + } + paint_card(ui, &painter, rect, 0.0, item, cache, hovered && dragging.is_none()); + if dragging.is_some() && dragging == image_index { + painter.rect_filled(rect, theme::RADIUS, Color32::from_black_alpha(128)); + } + if item.status == RefStatus::Ready && dragging.is_none() { + if remove_button(ui, id.with(("x", item.id)), rect, hovered) { + actions.push(DeckAction::Remove(item.id)); + } else if resp.clicked() { + actions.push(DeckAction::Preview(item.id)); + } + } + } + if !open_kinds.is_empty() { + let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click()); + paint_dashed_tile(ui.painter(), rect, 0.0, resp.hovered(), None); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + actions.push(DeckAction::Upload { kinds: open_kinds.to_vec(), slot: ImageSlot::Reference }); + } + hover_menu(ui, id.with("panel-menu"), &resp, menu, actions); + } + }); + // Drag to reorder images: drop onto another image's card. + if let Some(from) = dragging { + let released = ui.input(|i| i.pointer.any_released()); + if released { + if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { + if let Some((to, _)) = image_rects.iter().find(|(_, r)| r.contains(pos)) { + if *to != from { + actions.push(DeckAction::ReorderImages { from, to: *to }); + } + } + } + dragging = None; + } else { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + } + ui.data_mut(|d| match dragging { + Some(i) => { + d.insert_temp(drag_id, i); + }, + None => d.remove::(drag_id), + }); +} + +/// A 56 px card: the thumbnail (cover-fit), a kind badge for video/audio, a duration strip, and a +/// spinner while uploading. `tilt` is in degrees. +fn paint_card(ui: &Ui, painter: &egui::Painter, rect: Rect, tilt: f32, item: &RefMedia, cache: &mut MediaCache, hovered: bool) { + let rot = Rot2::from_angle(tilt.to_radians()); + let corners = rotated_corners(rect, rot); + painter.add(Shape::convex_polygon(corners.to_vec(), theme::fade(theme::CONTROLS, 0.95), Stroke::NONE)); + let texture = item.preview.as_deref().and_then(|key| match cache.get(ui.ctx(), key) { + Lookup::Ready(t) => Some(t), + _ => None, + }); + match texture { + Some(texture) => { + let uv = cover_uv(texture.size_vec2()); + let image = egui::Image::from_texture(SizedTexture::from_handle(&texture)).uv(uv).rotate(rot.angle(), Vec2::splat(0.5)); + let image = if item.status == RefStatus::Uploading { image.tint(Color32::from_gray(140)) } else { image }; + image.paint_at(ui, rect.shrink(2.0)); + }, + None => { + let icon = match item.kind { + RefKind::Image => Icon::Image, + RefKind::Video => Icon::Video, + RefKind::Audio => Icon::Music, + }; + icons::paint(painter, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), icon, theme::MUTED); + }, + } + if item.kind != RefKind::Image && tilt == 0.0 { + let badge = Rect::from_min_size(rect.min + vec2(3.0, 3.0), vec2(16.0, 16.0)); + painter.rect_filled(badge, 0.0, Color32::from_black_alpha(153)); + icons::paint(painter, badge.shrink(3.0), if item.kind == RefKind::Video { Icon::Video } else { Icon::Music }, Color32::WHITE); + if item.duration_secs > 0.0 { + let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 14.0), rect.max); + painter.rect_filled(strip, 0.0, Color32::from_black_alpha(178)); + painter.text(strip.center(), Align2::CENTER_CENTER, format!("{}s", item.duration_secs.round() as i64), FontId::new(10.0, theme::semibold()), Color32::WHITE); + } + } + if item.status == RefStatus::Uploading { + icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(24.0, 24.0)), Color32::WHITE); + } + let border = theme::fade(Color32::WHITE, if hovered { 0.8 } else { 0.3 }); + painter.add(Shape::closed_line(corners.to_vec(), Stroke::new(2.0, border))); +} + +/// The hover-revealed round × in a card's top-right corner. Returns whether it was clicked. +fn remove_button(ui: &mut Ui, id: Id, card: Rect, card_hovered: bool) -> bool { + let rect = Rect::from_min_size(pos2(card.right() - 22.0, card.top() + 2.0), vec2(20.0, 20.0)); + let resp = ui.interact(rect, id, Sense::click()); + if !(card_hovered || resp.hovered()) { + return false; + } + let fill = if resp.hovered() { theme::fade(theme::DANGER, 0.7) } else { Color32::from_black_alpha(128) }; + ui.painter().circle_filled(rect.center(), 10.0, fill); + icons::paint(ui.painter(), rect.shrink(5.0), Icon::X, Color32::WHITE); + resp.on_hover_text("Remove").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +/// The dashed "+" card that adds a reference (straightens and grows on hover). +fn add_tile(ui: &mut Ui, id: Id, label: &str, tilt: f32) -> Response { + let (rect, _) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::hover()); + let resp = ui.interact(rect, id, Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); + let hovered = resp.hovered(); + paint_dashed_tile(ui.painter(), if hovered { rect.expand(1.4) } else { rect }, if hovered { 0.0 } else { tilt }, hovered, Some(label)); + resp +} + +fn paint_dashed_tile(painter: &egui::Painter, rect: Rect, tilt: f32, hovered: bool, label: Option<&str>) { + let rot = Rot2::from_angle(tilt.to_radians()); + let corners = rotated_corners(rect, rot); + let fill = theme::fade(theme::CONTROLS, if hovered { 0.6 } else { 0.4 }); + painter.add(Shape::convex_polygon(corners.to_vec(), fill, Stroke::NONE)); + let mut path = corners.to_vec(); + path.push(corners[0]); + painter.extend(Shape::dashed_line(&path, Stroke::new(2.0, theme::LINE_DASHED), 4.0, 3.0)); + let c = rect.center(); + match label { + Some(label) => { + icons::paint(painter, Rect::from_center_size(c - vec2(0.0, 6.0), vec2(18.0, 18.0)), Icon::Plus, theme::fade(theme::INK, 0.8)); + painter.text(c + vec2(0.0, 12.0), Align2::CENTER_CENTER, label, FontId::new(8.5, theme::medium()), theme::fade(theme::INK, 0.7)); + }, + None => icons::paint(painter, Rect::from_center_size(c, vec2(20.0, 20.0)), Icon::Plus, theme::fade(theme::INK, 0.8)), + } +} + +fn rotated_corners(rect: Rect, rot: Rot2) -> [Pos2; 4] { + let c = rect.center(); + [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| c + rot * (p - c)) +} + +/// The UV rect that crops a texture of `size` to a centred square (`object-cover`). +pub fn cover_uv(size: Vec2) -> Rect { + if size.x <= 0.0 || size.y <= 0.0 { + return Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)); + } + if size.x > size.y { + let f = size.y / size.x; + Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) + } else { + let f = size.x / size.y; + Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) + } +} + +/// One kind's group in an add menu: its "Upload" / "From library" rows and a usage hint. +struct MenuGroup { + kind: RefKind, + hint: Option, + slot: ImageSlot, + library: bool, +} + +fn add_menu_entries(refs: &References, limits: &DeckLimits, open_kinds: &[RefKind], slot: ImageSlot) -> Vec { + open_kinds + .iter() + .map(|&kind| { + let count = format!("{}/{}", refs.list(kind).len(), limits.max(kind)); + let hint = match limits.max_secs(kind) { + Some(max) => format!("{count} \u{b7} {}/{}s", refs.total_secs(kind).round() as i64, max.round() as i64), + None => count, + }; + MenuGroup { kind, hint: Some(hint), slot, library: limits.library } + }) + .collect() +} + +/// The add menu that appears above a trigger after a short hover (the webapp's `DeckAddMenu`). +fn hover_menu(ui: &Ui, id: Id, trigger: &Response, groups: &[MenuGroup], actions: &mut Vec) { + // A lone "Upload" is what clicking does already: no menu (the webapp's `enabledActions > 1`). + let entries: usize = groups.iter().map(|g| 1 + usize::from(g.library)).sum(); + if entries <= 1 { + return; + } + let latch = hover_latch(ui, id, trigger.rect, MENU_DELAY); + if !latch.open { + return; + } + let show_groups = groups.len() > 1; + let area = egui::Area::new(id.with("area")).order(Order::Tooltip).pivot(Align2::CENTER_BOTTOM).fixed_pos(trigger.rect.center_top() - vec2(0.0, 2.0)).show(ui.ctx(), |ui| { + widgets::popover_frame().inner_margin(6).show(ui, |ui| { + ui.set_width(192.0); + ui.spacing_mut().item_spacing.y = 0.0; + for group in groups { + if show_groups { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(group.kind.noun().to_uppercase()).size(10.0).family(theme::semibold()).color(theme::FAINT)); + if let Some(hint) = &group.hint { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(hint).size(10.0).family(theme::medium()).color(theme::FAINT)); + }); + } + }); + } + if menu_entry(ui, Icon::Upload, "Upload") { + actions.push(DeckAction::Upload { kinds: vec![group.kind], slot: group.slot }); + latch.close(ui); + } + if group.library && menu_entry(ui, Icon::Library, if group.slot == ImageSlot::Reference { "From library" } else { "Pick from library" }) { + actions.push(DeckAction::Library { kind: group.kind, slot: group.slot }); + latch.close(ui); + } + } + }); + }); + latch.keep_open_while(ui, area.response.rect.union(trigger.rect)); +} + +fn menu_entry(ui: &mut Ui, icon: Icon, label: &str) -> bool { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 17.0, rect.center().y), vec2(14.0, 14.0)), icon, theme::fade(theme::INK, 0.6)); + p.text(pos2(rect.left() + 34.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(13.0, theme::medium()), theme::INK); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +/// Open-on-hover with a delay, staying open while the pointer is over the trigger or what it +/// opened (plus a grace period), like the webapp's hover menus. +struct HoverLatch { + id: Id, + open: bool, +} + +#[derive(Clone, Copy, Default)] +struct LatchState { + hover_since: Option, + left_at: Option, + open: bool, +} + +fn hover_latch(ui: &Ui, id: Id, trigger: Rect, delay: f64) -> HoverLatch { + let now = ui.input(|i| i.time); + let pointer = ui.input(|i| i.pointer.hover_pos()); + let over = pointer.is_some_and(|p| trigger.contains(p)); + let mut st: LatchState = ui.data(|d| d.get_temp(id).unwrap_or_default()); + if over { + st.left_at = None; + let since = *st.hover_since.get_or_insert(now); + if !st.open { + if now - since >= delay { + st.open = true; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(delay - (now - since))); + } + } + } else { + st.hover_since = None; + } + ui.data_mut(|d| d.insert_temp(id, st)); + HoverLatch { id, open: st.open } +} + +impl HoverLatch { + /// Keeps the latch open while the pointer is inside `region`; closes it `CLOSE_DELAY` after + /// the pointer leaves. + fn keep_open_while(&self, ui: &Ui, region: Rect) { + let now = ui.input(|i| i.time); + let inside = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| region.expand(4.0).contains(p)); + let mut st: LatchState = ui.data(|d| d.get_temp(self.id).unwrap_or_default()); + if inside { + st.left_at = None; + } else { + let left = *st.left_at.get_or_insert(now); + if now - left >= CLOSE_DELAY { + st = LatchState::default(); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(CLOSE_DELAY)); + } + } + ui.data_mut(|d| d.insert_temp(self.id, st)); + } + + fn close(&self, ui: &Ui) { + ui.data_mut(|d| d.insert_temp(self.id, LatchState::default())); + } +} diff --git a/artcraft-native/src/prompt_box/editor.rs b/artcraft-native/src/prompt_box/editor.rs new file mode 100644 index 0000000000..42714c1b7b --- /dev/null +++ b/artcraft-native/src/prompt_box/editor.rs @@ -0,0 +1,414 @@ +//! The prompt text field: auto-grows to ~4 lines then scrolls, expands or resizes on demand, +//! highlights `@Image1`-style mentions and offers them in an autocomplete dropdown, and shows a +//! length counter when the model has a limit. + +use egui::text::{CCursor, CCursorRange, LayoutJob}; +use egui::{Align2, Color32, FontFamily, FontId, Id, Key, KeyboardShortcut, Modifiers, Order, Rect, Sense, Stroke, TextBuffer, TextFormat, Ui, pos2, vec2}; + +use crate::theme; +use crate::ui::icons::{self, Icon}; + +/// `text-md`. +const FONT_SIZE: f32 = 16.0; +/// `min-h-[2.5em]`. +const MIN_HEIGHT: f32 = FONT_SIZE * 2.5; +/// `max-h-[5.5em]` while collapsed. +const COLLAPSED_MAX_HEIGHT: f32 = FONT_SIZE * 5.5; +/// `max-h-[500px]`. +const MAX_HEIGHT: f32 = 500.0; +/// `pr-8`: room for the fullscreen button. +const RIGHT_GUTTER: f32 = 32.0; + +const IMAGE_COLORS: [Color32; 5] = [Color32::from_rgb(96, 165, 250), Color32::from_rgb(251, 146, 60), Color32::from_rgb(167, 139, 250), Color32::from_rgb(52, 211, 153), Color32::from_rgb(251, 113, 133)]; +const VIDEO_COLORS: [Color32; 3] = [Color32::from_rgb(250, 204, 21), Color32::from_rgb(245, 158, 11), Color32::from_rgb(74, 222, 128)]; +const AUDIO_COLORS: [Color32; 2] = [Color32::from_rgb(192, 132, 252), Color32::from_rgb(232, 121, 249)]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MentionKind { + Image, + Video, + Audio, +} + +/// Something the prompt can reference by name, e.g. `@Image2`. +#[derive(Clone, Debug)] +pub struct MentionItem { + pub label: String, + pub kind: MentionKind, + /// The 1-based index within its kind (picks the colour). + pub index: usize, + /// Media cache key of a thumbnail, if there is one. + pub preview: Option, +} + +impl MentionItem { + pub fn color(&self) -> Color32 { + let i = self.index.saturating_sub(1); + match self.kind { + MentionKind::Image => IMAGE_COLORS[i % IMAGE_COLORS.len()], + MentionKind::Video => VIDEO_COLORS[i % VIDEO_COLORS.len()], + MentionKind::Audio => AUDIO_COLORS[i % AUDIO_COLORS.len()], + } + } +} + +/// Per-editor UI state that outlives a frame. +#[derive(Default)] +pub struct EditorState { + /// The chevron's "expanded" mode: a tall, fixed-height field. + pub expanded: bool, + /// A height the user dragged the resize grip to. + pub manual_height: Option, + mention: Option, + /// Focus the field on the next frame (autofocus, after inserting a mention, after closing + /// fullscreen). + pub focus_requested: bool, +} + +struct MentionQuery { + /// Char index of the `@`. + anchor: usize, + /// What's typed after the `@` (without it). + filter: String, + selected: usize, +} + +pub struct EditorOptions<'a> { + pub id: Id, + pub placeholder: &'a str, + pub enter_to_generate: bool, + pub mentions: &'a [MentionItem], + /// `Some` shows an `n / max` counter; `Some(None)` shows `n / ∞`. + pub max_length: Option>, + /// Fill a fixed height (fullscreen mode) instead of auto-growing. + pub fixed_height: Option, + /// Draw the fullscreen button in the top right. + pub fullscreen_button: bool, + /// Draw the resize grip in the bottom right. + pub resizable: bool, +} + +#[derive(Default)] +pub struct EditorOutput { + pub submit: bool, + pub open_fullscreen: bool, + pub focused: bool, +} + +/// Draws the editor in the available width. +pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &EditorOptions<'_>, thumbnails: &mut dyn FnMut(&Ui, &str) -> Option) -> EditorOutput { + let mut out = EditorOutput::default(); + let id = opts.id; + let had_focus = ui.memory(|m| m.has_focus(id)); + let filtered = filtered_mentions(state, opts.mentions); + + // Keys the field mustn't see: dropdown navigation, and Enter when it generates. + if had_focus { + if !filtered.is_empty() { + handle_dropdown_keys(ui, id, prompt, state, &filtered); + } else if opts.enter_to_generate && consume_plain_enter(ui) { + out.submit = true; + } + } + + let width = ui.available_width(); + let height = editor_height(ui, state, opts); + let (outer, _) = ui.allocate_exact_size(vec2(width, height.unwrap_or(MIN_HEIGHT)), Sense::hover()); + let text_width = (width - RIGHT_GUTTER).max(40.0); + + let mentions = opts.mentions; + let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, wrap_width: f32| { + let mut job = highlight(buf.as_str(), mentions); + job.wrap.max_width = wrap_width; + ui.fonts_mut(|f| f.layout_job(job)) + }; + + let return_key = if opts.enter_to_generate { KeyboardShortcut::new(Modifiers::SHIFT, Key::Enter) } else { KeyboardShortcut::new(Modifiers::NONE, Key::Enter) }; + let max_h = height.unwrap_or(COLLAPSED_MAX_HEIGHT); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(outer.min, vec2(width, max_h)))); + let scroll = egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(max_h).auto_shrink([false, true]); + let scroll = if height.is_some() { scroll.min_scrolled_height(max_h) } else { scroll }; + let output = scroll + .show(&mut child, |ui| { + let min_h = height.unwrap_or(MIN_HEIGHT); + egui::TextEdit::multiline(prompt).id(id).frame(egui::Frame::NONE).font(FontId::new(FONT_SIZE, FontFamily::Proportional)).hint_text(egui::RichText::new(opts.placeholder).color(theme::MUTED).size(FONT_SIZE)).desired_width(text_width).desired_rows(1).min_size(vec2(text_width, min_h)).return_key(return_key).layouter(&mut layouter).show(ui) + }) + .inner; + let used_h = child.min_rect().height().clamp(MIN_HEIGHT, max_h); + // Grow the reserved space to what the field actually used (auto-grow). + if height.is_none() && used_h > outer.height() { + ui.allocate_exact_size(vec2(width, used_h - outer.height()), Sense::hover()); + } + let field_rect = Rect::from_min_size(outer.min, vec2(width, if height.is_some() { max_h } else { used_h })); + + if state.focus_requested { + state.focus_requested = false; + ui.memory_mut(|m| m.request_focus(id)); + let end = CCursor::new(prompt.chars().count()); + let mut st = output.state.clone(); + st.cursor.set_char_range(Some(CCursorRange::one(end))); + st.store(ui.ctx(), id); + } + out.focused = output.response.response.has_focus(); + + if output.response.response.changed() || (out.focused && output.cursor_range.is_some()) { + update_mention_query(state, prompt, output.cursor_range, opts.mentions); + } + if !out.focused && !had_focus { + state.mention = None; + } + + if opts.fullscreen_button { + let button = Rect::from_min_size(pos2(field_rect.right() - 24.0, field_rect.top()), vec2(24.0, 24.0)); + let resp = ui.interact(button, id.with("fullscreen"), Sense::click()).on_hover_text("Focus mode").on_hover_cursor(egui::CursorIcon::PointingHand); + let p = ui.painter(); + p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH }); + icons::paint(p, button.shrink(6.0), Icon::Maximize, if resp.hovered() { theme::INK } else { theme::DIM }); + out.open_fullscreen = resp.clicked(); + } + + if opts.resizable { + resize_grip(ui, id, field_rect, state); + } + + if let Some(limit) = opts.max_length { + let count = prompt.chars().count(); + let over = limit.is_some_and(|max| count > max); + let text = match limit { + Some(max) => format!("{count} / {max}"), + None => format!("{count} / \u{221e}"), + }; + let color = if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT }; + ui.painter().text(pos2(field_rect.right() - 16.0, field_rect.bottom() + 4.0), Align2::RIGHT_BOTTOM, text, FontId::new(10.0, FontFamily::Monospace), color); + } + + if !filtered.is_empty() && out.focused { + mention_dropdown(ui, id, field_rect, prompt, state, &filtered, thumbnails); + } + out +} + +/// Whether the prompt exceeds `limit` characters. +pub fn is_over_limit(prompt: &str, limit: Option) -> bool { + limit.is_some_and(|max| prompt.chars().count() > max) +} + +/// The height the field takes: `None` auto-grows (collapsed), `Some` is fixed. +fn editor_height(ui: &Ui, state: &EditorState, opts: &EditorOptions<'_>) -> Option { + if let Some(h) = opts.fixed_height { + return Some(h); + } + if let Some(h) = state.manual_height { + return Some(h.clamp(MIN_HEIGHT, MAX_HEIGHT)); + } + // `clamp(120px, calc(100vh - 700px), 500px)`. + state.expanded.then(|| (ui.ctx().content_rect().height() - 700.0).clamp(120.0, MAX_HEIGHT)) +} + +/// Consumes a plain (unshifted) Enter press; returns whether there was one. +fn consume_plain_enter(ui: &Ui) -> bool { + ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if !modifiers.shift && !modifiers.command && !modifiers.alt)); + before != i.events.len() + }) +} + +fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem]) { + let n = filtered.len(); + let (down, up, accept, escape) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Tab), i.consume_key(Modifiers::NONE, Key::Escape))); + let Some(query) = state.mention.as_mut() else { + return; + }; + if down { + query.selected = (query.selected + 1) % n; + } + if up { + query.selected = (query.selected + n - 1) % n; + } + if escape { + state.mention = None; + return; + } + if accept { + let label = filtered[query.selected.min(n - 1)].label.clone(); + insert_mention(ui.ctx(), id, prompt, state, &label); + } +} + +/// Opens, updates or closes the dropdown from the text before the cursor. +fn update_mention_query(state: &mut EditorState, prompt: &str, cursor: Option, mentions: &[MentionItem]) { + // Keep the highlighted row while the same query stays open (arrow keys move it). + let previous = state.mention.take(); + if mentions.is_empty() { + return; + } + let Some(cursor) = cursor else { + return; + }; + let at = cursor.primary.index.0; + let before: String = prompt.chars().take(at).collect(); + let Some(anchor_byte) = before.rfind('@') else { + return; + }; + let after = &before[anchor_byte + 1..]; + if after.contains(char::is_whitespace) { + return; + } + let anchor = before[..anchor_byte].chars().count(); + let selected = previous.filter(|q| q.anchor == anchor && q.filter == after).map_or(0, |q| q.selected); + state.mention = Some(MentionQuery { anchor, filter: after.to_owned(), selected }); +} + +fn filtered_mentions<'a>(state: &EditorState, mentions: &'a [MentionItem]) -> Vec<&'a MentionItem> { + let Some(query) = &state.mention else { + return Vec::new(); + }; + let needle = format!("@{}", query.filter).to_lowercase(); + mentions.iter().filter(|m| m.label.to_lowercase().contains(&needle)).collect() +} + +/// Replaces `@filter` before the cursor with `label` and a space, and puts the cursor after it. +fn insert_mention(ctx: &egui::Context, id: Id, prompt: &mut String, state: &mut EditorState, label: &str) { + let Some(query) = state.mention.take() else { + return; + }; + let chars: Vec = prompt.chars().collect(); + let end = (query.anchor + 1 + query.filter.chars().count()).min(chars.len()); + let before: String = chars[..query.anchor].iter().collect(); + let after: String = chars[end..].iter().collect(); + *prompt = format!("{before}{label} {after}"); + if let Some(mut st) = egui::TextEdit::load_state(ctx, id) { + st.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(query.anchor + label.chars().count() + 1)))); + st.store(ctx, id); + } +} + +fn mention_dropdown(ui: &Ui, id: Id, field: Rect, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem], thumbnails: &mut dyn FnMut(&Ui, &str) -> Option) { + let selected = state.mention.as_ref().map_or(0, |q| q.selected); + let row_h = 44.0; + let h = 28.0 + row_h * filtered.len() as f32; + let rect = Rect::from_min_size(pos2(field.left(), field.top() - h - 4.0), vec2(256.0, h)); + let mut picked = None; + let mut hovered = None; + egui::Area::new(id.with("mentions")).order(Order::Foreground).fixed_pos(rect.min).show(ui.ctx(), |ui| { + let p = ui.painter(); + p.rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside); + p.text(pos2(rect.left() + 12.0, rect.top() + 14.0), Align2::LEFT_CENTER, "MENTIONS", FontId::new(11.0, theme::mono_bold()), theme::fade(theme::INK, 0.5)); + for (i, item) in filtered.iter().enumerate() { + let row = Rect::from_min_size(pos2(rect.left(), rect.top() + 28.0 + row_h * i as f32), vec2(rect.width(), row_h)); + let resp = ui.interact(row, id.with(("mention", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, i == selected, &item.label)); + if resp.hovered() { + hovered = Some(i); + } + let p = ui.painter(); + if i == selected { + p.rect_filled(row, 0.0, theme::WASH_HOVER); + } + let tile = Rect::from_min_size(pos2(row.left() + 12.0, row.center().y - 16.0), vec2(32.0, 32.0)); + p.rect(tile, 0.0, theme::fade(Color32::BLACK, 0.2), theme::hairline(), egui::StrokeKind::Inside); + match item.preview.as_deref().and_then(|key| thumbnails(ui, key)) { + Some(tex) => { + egui::Image::new(&tex).maintain_aspect_ratio(false).paint_at(ui, tile.shrink(1.0)); + }, + None => { + let icon = match item.kind { + MentionKind::Image => Icon::Image, + MentionKind::Video => Icon::Video, + MentionKind::Audio => Icon::Music, + }; + icons::paint(ui.painter(), tile.shrink(9.0), icon, theme::fade(Color32::WHITE, 0.6)); + }, + } + ui.painter().text(pos2(tile.right() + 10.0, row.center().y), Align2::LEFT_CENTER, &item.label, FontId::new(14.0, theme::medium()), item.color()); + if resp.clicked() { + picked = Some(item.label.clone()); + } + } + }); + if let (Some(i), Some(q)) = (hovered, state.mention.as_mut()) { + q.selected = i; + } + if let Some(label) = picked { + insert_mention(ui.ctx(), id, prompt, state, &label); + ui.memory_mut(|m| m.request_focus(id)); + } +} + +/// The three-line grip that resizes the field (`.promptbox-resize-wrap::after`). +fn resize_grip(ui: &mut Ui, id: Id, field: Rect, state: &mut EditorState) { + let grip = Rect::from_min_size(pos2(field.right() - 12.0, field.bottom() - 12.0), vec2(12.0, 12.0)); + let resp = ui.interact(grip, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeVertical); + if resp.dragged() { + let current = state.manual_height.unwrap_or(field.height()); + state.manual_height = Some((current + resp.drag_delta().y).clamp(MIN_HEIGHT, MAX_HEIGHT)); + state.expanded = false; + } + let ink = theme::fade(Color32::WHITE, if resp.hovered() || resp.dragged() { 0.8 } else { 0.5 }); + let p = ui.painter(); + let c = grip.right_bottom() - vec2(2.0, 2.0); + for k in [3.0, 6.0, 9.0] { + p.line_segment([c - vec2(k, 0.0), c - vec2(0.0, k)], Stroke::new(1.0, ink)); + } +} + +/// The prompt as a layout job with each known mention coloured and bold. +fn highlight(text: &str, mentions: &[MentionItem]) -> LayoutJob { + let plain = TextFormat { font_id: FontId::new(FONT_SIZE, FontFamily::Proportional), color: theme::INK, ..Default::default() }; + let mut job = LayoutJob::default(); + if mentions.is_empty() || !text.contains('@') { + job.append(text, 0.0, plain); + return job; + } + let mut sorted: Vec<&MentionItem> = mentions.iter().collect(); + sorted.sort_by_key(|m| std::cmp::Reverse(m.label.len())); + let bytes = text.as_bytes(); + let (mut start, mut i) = (0, 0); + while i < text.len() { + let hit = (bytes[i] == b'@').then(|| sorted.iter().find(|m| text.get(i..i + m.label.len()).is_some_and(|s| s.eq_ignore_ascii_case(&m.label)))).flatten(); + match hit { + Some(m) => { + job.append(&text[start..i], 0.0, plain.clone()); + let end = i + m.label.len(); + job.append(&text[i..end], 0.0, TextFormat { font_id: FontId::new(FONT_SIZE, theme::semibold()), color: m.color(), ..Default::default() }); + start = end; + i = end; + }, + None => i += text[i..].chars().next().map_or(1, char::len_utf8), + } + } + job.append(&text[start..], 0.0, plain); + job +} + +/// Mention labels for `n` items of `kind` (`@Image1`, `@Image2`, …). +pub fn mention_items(kind: MentionKind, previews: impl Iterator>) -> Vec { + let name = match kind { + MentionKind::Image => "Image", + MentionKind::Video => "Video", + MentionKind::Audio => "Audio", + }; + previews.enumerate().map(|(i, preview)| MentionItem { label: format!("@{name}{}", i + 1), kind, index: i + 1, preview }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colours_known_mentions_case_insensitively() { + let mentions = mention_items(MentionKind::Image, [None, None].into_iter()); + let job = highlight("put @image2 next to @Image1, not @Image3", &mentions); + let coloured: Vec<&str> = job.sections.iter().filter(|s| s.format.color != theme::INK).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect(); + assert_eq!(coloured, ["@image2", "@Image1"]); + assert_eq!(mentions[1].color(), IMAGE_COLORS[1]); + } + + #[test] + fn counts_characters_not_bytes_for_the_limit() { + assert!(!is_over_limit("\u{65e5}\u{672c}\u{8a9e}", Some(3))); + assert!(is_over_limit("\u{65e5}\u{672c}\u{8a9e}!", Some(3))); + assert!(!is_over_limit("anything", None)); + } +} diff --git a/artcraft-native/src/prompt_box/mod.rs b/artcraft-native/src/prompt_box/mod.rs new file mode 100644 index 0000000000..92e1afe38a --- /dev/null +++ b/artcraft-native/src/prompt_box/mod.rs @@ -0,0 +1,346 @@ +//! The prompt box shared by the create pages (`PromptBox` in the webapp): the reference deck or +//! keyframe slots beside the prompt, a toolbar of page-specific pickers, clear-all, the credit +//! cost and the generate button; plus drag & drop, pasted images and the focus-mode modal. + +pub mod deck; +pub mod editor; +pub mod model_selector; +pub mod pickers; +pub mod refs; +#[cfg(test)] +mod ui_tests; + +use std::path::PathBuf; + +use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::backend::media_cache::MediaCache; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::widgets; +use deck::{DeckAction, DeckLimits}; +use editor::{EditorOptions, EditorState, MentionItem}; +use refs::{RefKind, References}; + +/// The box's widest (`max-w-5xl`). +pub const MAX_WIDTH: f32 = 1024.0; + +/// What sits left of the prompt. +pub enum DeckMode { + None, + /// The fanned reference deck. + References(DeckLimits), + /// First (and maybe last) frame slots. + Keyframes { + show_last: bool, + library: bool, + }, +} + +/// The page's settings for one frame of the box. +pub struct PromptBoxProps<'a> { + pub id: Id, + pub placeholder: &'a str, + pub enter_to_generate: bool, + pub mentions: &'a [MentionItem], + /// `Some` shows the length counter (`Some(None)` = unlimited). + pub max_length: Option>, + pub deck: DeckMode, + /// Which kinds a drop or paste may add right now. + pub accepts: &'a [RefKind], + /// The cost of generating, when known. + pub credits: Option, + pub generate_enabled: bool, + pub generating: bool, + /// Shown on the generate button. + pub generate_tooltip: &'a str, + /// A red note left of the generate button ("Starting frame required"). + pub warning: Option<&'a str>, + /// An info strip above the prompt. + pub banner: Option<&'a str>, +} + +/// Something the page should do. +#[derive(Debug)] +pub enum PromptBoxAction { + Generate, + Deck(DeckAction), + ClearAll, + DroppedFiles(Vec), + PastedImage(Vec), +} + +/// UI state that outlives a frame. +#[derive(Default)] +pub struct PromptBoxState { + pub editor: EditorState, + pub fullscreen: bool, + confirm_clear: bool, + focused: bool, +} + +/// Draws the box in `ui` (which should be as wide as the box). `toolbar_left` holds the model +/// selector and pickers; `toolbar_right` the count picker. Both may be drawn twice in a frame +/// (box and focus mode), each under its own id. +#[allow(clippy::too_many_arguments)] +pub fn show(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), toolbar_right: &mut dyn FnMut(&mut Ui)) -> Vec { + let mut actions = Vec::new(); + let frame = egui::Frame::new().fill(theme::fade(theme::CONTROLS, 0.95)).stroke(Stroke::new(1.0, if state.focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(16); + let response = frame.show(ui, |ui| { + if let Some(banner) = props.banner { + info_banner(ui, banner); + } + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 12.0; + deck_widget(ui, props, refs, cache, false, &mut actions); + ui.vertical(|ui| { + let out = editor_ui(ui, prompt, state, props, cache, None); + state.focused = out.focused; + if out.submit { + actions.push(PromptBoxAction::Generate); + } + if out.open_fullscreen { + state.fullscreen = true; + } + }); + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + toolbar_left(ui); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + generate_cluster(ui, props, &mut actions); + clear_all_button(ui, prompt, refs, state, &mut actions); + toolbar_right(ui); + if let Some(warning) = props.warning { + warning_label(ui, warning); + } + }); + }); + }); + let rect = response.response.rect; + expand_toggle(ui, props.id, rect, state); + drop_target(ui, props, rect, &mut actions); + paste_images(ui, props, &mut actions); + if state.fullscreen { + focus_mode(ui.ctx(), prompt, refs, state, props, cache, toolbar_left, &mut actions); + } + if state.confirm_clear { + confirm_clear(ui.ctx(), props.id, state, &mut actions); + } + actions +} + +fn editor_ui(ui: &mut Ui, prompt: &mut String, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, fixed_height: Option) -> editor::EditorOutput { + let fullscreen = fixed_height.is_some(); + let opts = EditorOptions { id: if fullscreen { props.id.with("focus-editor") } else { props.id.with("editor") }, placeholder: props.placeholder, enter_to_generate: props.enter_to_generate, mentions: props.mentions, max_length: if fullscreen { None } else { props.max_length }, fixed_height, fullscreen_button: !fullscreen, resizable: !fullscreen }; + let ctx = ui.ctx().clone(); + editor::show(ui, prompt, &mut state.editor, &opts, &mut |_, key| match cache.get(&ctx, key) { + crate::backend::media_cache::Lookup::Ready(t) => Some(t), + _ => None, + }) +} + +fn deck_widget(ui: &mut Ui, props: &PromptBoxProps<'_>, refs: &References, cache: &mut MediaCache, always_expanded: bool, actions: &mut Vec) { + let deck_actions = match &props.deck { + DeckMode::None => Vec::new(), + DeckMode::References(limits) => deck::reference_deck(ui, props.id.with(("deck", always_expanded)), refs, limits, cache, always_expanded), + DeckMode::Keyframes { show_last, library } => deck::keyframe_cards(ui, props.id.with(("keyframes", always_expanded)), refs, *show_last, *library, cache), + }; + actions.extend(deck_actions.into_iter().map(PromptBoxAction::Deck)); +} + +/// The coins + cost and the round generate button (`GenerateIconButton`). +fn generate_cluster(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec) { + if widgets::generate_button(ui, props.generate_enabled && !props.generating, props.generating, props.generate_tooltip).clicked() { + actions.push(PromptBoxAction::Generate); + } + if let Some(credits) = props.credits { + let ink = theme::fade(theme::INK, if props.generate_enabled { 0.8 } else { 0.4 }); + let galley = ui.painter().layout_no_wrap(credits.to_string(), FontId::new(13.0, theme::semibold()), ink); + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 22.0, 36.0), Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 7.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, ink); + ui.painter().galley(pos2(rect.left() + 20.0, rect.center().y - galley.size().y / 2.0), galley, ink); + resp.on_hover_text(format!("{credits} credit{} cost", if credits == 1 { "" } else { "s" })); + } +} + +/// The eraser button and the hairline after it (`PromptClearAllButton`). +fn clear_all_button(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, actions: &mut Vec) { + let (rule, _) = ui.allocate_exact_size(vec2(1.0, 20.0), Sense::hover()); + ui.painter().rect_filled(rule, 0.0, theme::fade(theme::INK, 0.15)); + let clearable = !prompt.is_empty() || !refs.is_empty(); + let resp = ui.add_enabled_ui(clearable, |ui| widgets::ghost(ui, Icon::Eraser, None, "Clear all", false)).inner; + if resp.clicked() { + if refs.is_empty() { + prompt.clear(); + actions.push(PromptBoxAction::ClearAll); + } else { + state.confirm_clear = true; + } + } +} + +fn confirm_clear(ctx: &egui::Context, id: Id, state: &mut PromptBoxState, actions: &mut Vec) { + let mut choice = None; + let resp = widgets::modal(ctx, id.with("confirm-clear"), 420.0, |ui| { + ui.label(widgets::heading("Clear all?", 20.0)); + ui.add_space(8.0); + ui.label(egui::RichText::new("This clears the prompt and also removes the attached references.").color(theme::MUTED)); + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::button(ui, None, "Clear all", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() { + choice = Some(true); + } + if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() { + choice = Some(false); + } + }); + }); + if resp || choice.is_some() { + state.confirm_clear = false; + } + if choice == Some(true) { + actions.push(PromptBoxAction::ClearAll); + } +} + +/// The chevron centred on the box's bottom edge that toggles the tall editor. +fn expand_toggle(ui: &mut Ui, id: Id, rect: Rect, state: &mut PromptBoxState) { + let button = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 6.0), vec2(38.0, 16.0)); + let resp = ui.interact(button, id.with("expand"), Sense::click()).on_hover_text(if state.editor.expanded { "Collapse" } else { "Expand" }).on_hover_cursor(egui::CursorIcon::PointingHand); + let ink = theme::fade(Color32::WHITE, if resp.hovered() { 0.9 } else { 0.3 }); + icons::paint(ui.painter(), Rect::from_center_size(button.center(), vec2(12.0, 12.0)), if state.editor.expanded { Icon::ChevronUp } else { Icon::ChevronDown }, ink); + if resp.clicked() { + state.editor.expanded = !state.editor.expanded; + state.editor.manual_height = None; + } +} + +fn warning_label(ui: &mut Ui, text: &str) { + let pulse = (ui.input(|i| i.time) * 2.0).sin() as f32 * 0.25 + 0.75; + ui.label(egui::RichText::new(text).size(12.0).color(theme::BAD)); + let (rect, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + icons::paint(ui.painter(), rect, Icon::Info, theme::fade(theme::BAD, pulse)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(50)); +} + +fn info_banner(ui: &mut Ui, text: &str) { + egui::Frame::new().fill(theme::fade(theme::ACCENT, 0.12)).stroke(Stroke::new(1.0, theme::fade(theme::ACCENT, 0.35))).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + icons::icon(ui, Icon::Info, 14.0, theme::ACCENT_INK); + ui.label(egui::RichText::new(text).size(12.5).color(theme::INK)); + }); + }); + ui.add_space(10.0); +} + +/// Files dragged over the window: an overlay on the box, and the drop when released. +fn drop_target(ui: &mut Ui, props: &PromptBoxProps<'_>, rect: Rect, actions: &mut Vec) { + let (hovered, dropped) = ui.input(|i| (i.raw.hovered_files.clone(), i.raw.dropped_files.clone())); + if !dropped.is_empty() { + let paths: Vec = dropped.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect(); + if !paths.is_empty() { + actions.push(PromptBoxAction::DroppedFiles(paths)); + } + return; + } + if hovered.is_empty() { + return; + } + let kinds: Vec> = hovered.iter().map(|f| f.path.as_deref().and_then(RefKind::from_path)).collect(); + // Without paths (some platforms) we can't tell yet: assume it fits. + let accepted = kinds.iter().all(|k| k.is_none() || k.is_some_and(|k| props.accepts.contains(&k))) && !props.accepts.is_empty(); + let keyframes = matches!(props.deck, DeckMode::Keyframes { .. }); + let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, props.id.with("drop-overlay"))); + p.rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.85)); + let border = if accepted { theme::fade(Color32::WHITE, 0.6) } else { theme::fade(theme::BAD, 0.8) }; + icons::dashed_rect(&p, rect.shrink(1.0), Stroke::new(2.0, border), 6.0, 4.0); + let chip = Rect::from_center_size(rect.center() - vec2(0.0, 22.0), vec2(36.0, 36.0)); + p.rect(chip, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside); + icons::paint(&p, chip.shrink(9.0), if accepted { Icon::Image } else { Icon::AlertCircle }, if accepted { theme::INK } else { theme::BAD }); + let names: Vec<&str> = props + .accepts + .iter() + .map(|k| match k { + RefKind::Image => "Images", + RefKind::Video => "Videos", + RefKind::Audio => "Audio", + }) + .collect(); + let (title, subtitle) = if !accepted { + ("That file type isn't supported".to_owned(), format!("Accepts {} files", names.join(", ").to_lowercase())) + } else if keyframes { + ("Drop to set your frames".to_owned(), "Frames".to_owned()) + } else { + ("Drop to add references".to_owned(), names.join(", ")) + }; + p.text(rect.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, title, FontId::new(14.0, theme::semibold()), theme::INK); + p.text(rect.center() + vec2(0.0, 28.0), Align2::CENTER_CENTER, subtitle, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED); +} + +/// Ctrl/Cmd+V with an image on the clipboard. egui only forwards text pastes, so this watches for +/// the V key's release and reads the image itself. +fn paste_images(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec) { + if props.accepts.is_empty() { + return; + } + let released = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } if modifiers.command))); + if !released { + return; + } + let Ok(mut clipboard) = arboard::Clipboard::new() else { + return; + }; + let Ok(image) = clipboard.get_image() else { + return; + }; + let Some(rgba) = image::RgbaImage::from_raw(image.width as u32, image.height as u32, image.bytes.into_owned()) else { + return; + }; + let mut png = Vec::new(); + if image::DynamicImage::ImageRgba8(rgba).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() { + actions.push(PromptBoxAction::PastedImage(png)); + } +} + +/// Focus mode: a large editor in a modal, with the deck and toolbar at hand. +#[allow(clippy::too_many_arguments)] +fn focus_mode(ctx: &egui::Context, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), actions: &mut Vec) { + let height = ctx.content_rect().height() * 0.7; + let mut done = false; + let closed = widgets::modal(ctx, props.id.with("focus-mode"), 896.0, |ui| { + ui.set_height(height); + ui.label(widgets::heading("Prompt", 22.0)); + ui.add_space(10.0); + let editor_h = (height - 160.0 - if refs.is_empty() { 0.0 } else { 80.0 }).max(120.0); + let out = editor_ui(ui, prompt, state, props, cache, Some(editor_h)); + if out.submit { + actions.push(PromptBoxAction::Generate); + } + if let Some(limit) = props.max_length { + let count = prompt.chars().count(); + let over = editor::is_over_limit(prompt, limit); + let text = limit.map_or_else(|| format!("{count} / \u{221e}"), |max| format!("{count} / {max}")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + ui.label(egui::RichText::new(text).size(11.0).monospace().color(if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT })); + }); + } + ui.push_id("focus-deck", |ui| deck_widget(ui, props, refs, cache, true, actions)); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.push_id("focus-toolbar", |ui| ui.horizontal(|ui| toolbar_left(ui))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::button(ui, None, "Done", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() { + done = true; + } + ui.push_id("focus-clear", |ui| clear_all_button(ui, prompt, refs, state, actions)); + }); + }); + }); + if closed || done { + state.fullscreen = false; + state.editor.focus_requested = true; + } +} diff --git a/artcraft-native/src/prompt_box/model_selector.rs b/artcraft-native/src/prompt_box/model_selector.rs new file mode 100644 index 0000000000..d444428299 --- /dev/null +++ b/artcraft-native/src/prompt_box/model_selector.rs @@ -0,0 +1,130 @@ +//! The embedded model selector (`ClassyModelSelector variant="embedded"`): a pill with the +//! maker's logo and the model's name, opening "Select Model". Long lists group by family, each +//! family opening a flyout of its variants on hover. + +use egui::{Align2, Id, Order, Popup, Rect, Ui, pos2}; + +use crate::models::{self, ModelInfo}; +use crate::prompt_box::pickers; +use crate::ui::widgets::{self, Leading, RowLead}; +use crate::ui::{creator_icons, icons}; + +const PANEL_WIDTH: f32 = 280.0; +const FLYOUT_WIDTH: f32 = 300.0; + +/// Draws the selector; returns the id of a newly picked model. +pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option { + let popup_id = ui.make_persistent_id(id).with("popup"); + let open = Popup::is_id_open(ui.ctx(), popup_id); + let label = selected.map_or("Select model", |m| m.name.as_str()); + let logo = selected.map(|m| creator_icons::texture(ui.ctx(), &m.creator)); + let resp = ui.push_id(id, |ui| { + ui.set_max_width(192.0 + 40.0); + match &logo { + Some(t) => widgets::pill(ui, Leading::Logo(t), label, open), + None => widgets::pill(ui, Leading::Icon(icons::Icon::Sparkles), label, open), + } + }); + let resp = resp.inner.on_hover_text("Model"); + let mut picked = None; + let hovered_family_id = id.with("family"); + pickers::popover(&resp, popup_id, |ui| { + ui.set_width(PANEL_WIDTH); + widgets::menu_header(ui, "Select Model"); + let max_h = ui.ctx().content_rect().height() * 0.6; + egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| match models::grouped(models) { + Some(families) => { + let mut hovered: Option = ui.data(|d| d.get_temp(hovered_family_id)); + for family in &families { + let contains_selected = selected.is_some_and(|s| family.models.iter().any(|m| m.id == s.id)); + let first = family.models[0]; + let tex = creator_icons::texture(ui.ctx(), &first.creator); + let subtitle = if contains_selected { selected.map(|s| s.name.as_str()) } else { None }; + let is_open = hovered.as_deref() == Some(family.name.as_str()); + if family.models.len() == 1 { + let row = widgets::menu_row(ui, RowLead::Logo(&tex), &first.name, non_empty(&first.description), contains_selected, false, false); + if row.hovered() { + hovered = None; + } + if row.clicked() { + picked = Some(first.id.clone()); + } + continue; + } + let row = widgets::menu_row(ui, RowLead::Logo(&tex), &family.name, subtitle, false, true, is_open); + if row.hovered() || row.clicked() { + hovered = Some(family.name.clone()); + } + if hovered.as_deref() == Some(family.name.as_str()) { + if let Some(id) = flyout(ui, id, row.rect, &family.name, &family.models, selected, show_badges) { + picked = Some(id); + } + } + } + ui.data_mut(|d| d.insert_temp(hovered_family_id, hovered)); + }, + None => { + for m in models { + if model_row(ui, m, selected, show_badges) { + picked = Some(m.id.clone()); + } + } + }, + }); + }); + if picked.is_some() { + Popup::close_id(ui.ctx(), popup_id); + ui.data_mut(|d| d.remove::>(hovered_family_id)); + } + picked.filter(|p| selected.is_none_or(|s| &s.id != p)) +} + +/// A family's variants, to the right of its row. +fn flyout(ui: &Ui, id: Id, row: Rect, family: &str, list: &[&ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option { + let mut picked = None; + egui::Area::new(id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| { + widgets::popover_frame().show(ui, |ui| { + ui.set_width(FLYOUT_WIDTH); + widgets::menu_header(ui, family); + for m in list { + if model_row(ui, m, selected, show_badges) { + picked = Some(m.id.clone()); + } + } + }); + }); + picked +} + +/// One model: logo tile, name, description (and capability badges for video). +fn model_row(ui: &mut Ui, m: &ModelInfo, selected: Option<&ModelInfo>, show_badges: bool) -> bool { + let tex = creator_icons::texture(ui.ctx(), &m.creator); + let is_selected = selected.is_some_and(|s| s.id == m.id); + let badges = if show_badges { capability_badges(m) } else { String::new() }; + let subtitle = match (m.description.is_empty(), badges.is_empty()) { + (true, true) => None, + (false, true) => Some(m.description.clone()), + (true, false) => Some(badges), + (false, false) => Some(format!("{} \u{b7} {badges}", m.description)), + }; + widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), is_selected, false, false).clicked() +} + +/// The video picker's capability badges ("Audio Support", "Start/End", "Reference"). +fn capability_badges(m: &ModelInfo) -> String { + let mut badges = Vec::new(); + if m.sound_toggle { + badges.push("Audio Support"); + } + if m.start_frame && m.end_frame { + badges.push("Start/End"); + } + if m.supports_reference_mode() { + badges.push("Reference"); + } + badges.join(" \u{b7} ") +} + +fn non_empty(s: &str) -> Option<&str> { + (!s.is_empty()).then_some(s) +} diff --git a/artcraft-native/src/prompt_box/pickers.rs b/artcraft-native/src/prompt_box/pickers.rs new file mode 100644 index 0000000000..5b09b7581a --- /dev/null +++ b/artcraft-native/src/prompt_box/pickers.rs @@ -0,0 +1,128 @@ +//! Toolbar pickers: a pill (or quiet icon button) that opens a popover above it with a titled +//! option list, the duration slider, and on/off toggles. + +use egui::{Popup, PopupCloseBehavior}; +use egui::{Id, RectAlign, Response, Ui}; + +use crate::theme; +use crate::ui::icons::Icon; +use crate::ui::widgets::{self, Leading, RowLead}; + +/// What an option shows at its left. +#[derive(Clone, Debug, PartialEq)] +pub enum OptionLead { + None, + Icon(Icon), + Aspect(Option<(f32, f32)>), +} + +#[derive(Clone, Debug)] +pub struct PickOption { + pub value: T, + pub label: String, + pub subtitle: Option, + pub lead: OptionLead, +} + +impl PickOption { + pub fn new(value: T, label: impl Into) -> Self { + Self { value, label: label.into(), subtitle: None, lead: OptionLead::None } + } + + pub fn lead(mut self, lead: OptionLead) -> Self { + self.lead = lead; + self + } + + pub fn subtitle(mut self, subtitle: impl Into) -> Self { + self.subtitle = Some(subtitle.into()); + self + } +} + +/// How a picker's trigger looks. +pub enum Trigger<'a> { + /// A toolbar pill with a lead and the selected label. + Pill(Leading<'a>), + /// A quiet 36 px icon button followed by the selected label (the count picker). + Ghost(Icon), +} + +/// A picker whose popover lists `options`; returns the newly chosen value. +pub fn option_picker(ui: &mut Ui, id: Id, trigger: Trigger<'_>, title: &str, tooltip: &str, current: &T, options: &[PickOption]) -> Option { + let selected = options.iter().find(|o| o.value == *current); + let label = selected.map_or("", |o| o.label.as_str()); + // Scoped to the parent, so the same picker in the box and in focus mode stay separate. + let popup_id = ui.make_persistent_id(id).with("popup"); + let open = Popup::is_id_open(ui.ctx(), popup_id); + let resp = ui.push_id(id, |ui| match trigger { + Trigger::Pill(lead) => widgets::pill(ui, lead, label, open), + Trigger::Ghost(icon) => widgets::ghost(ui, icon, Some(label), tooltip, open), + }); + let resp = resp.inner.on_hover_text(tooltip); + let mut picked = None; + popover(&resp, popup_id, |ui| { + ui.set_min_width(180.0); + widgets::menu_header(ui, title); + for option in options { + let lead = match option.lead { + OptionLead::None => RowLead::None, + OptionLead::Icon(icon) => RowLead::Icon(icon), + OptionLead::Aspect(ratio) => RowLead::Aspect(ratio), + }; + let is_selected = option.value == *current; + if widgets::menu_row(ui, lead, &option.label, option.subtitle.as_deref(), is_selected, false, false).clicked() { + picked = Some(option.value.clone()); + } + } + }); + if picked.is_some() { + Popup::close_id(ui.ctx(), popup_id); + } + picked.filter(|v| v != current) +} + +/// Opens a popover above `anchor` (toggled by clicking it) and fills it with `content`. +pub fn popover(anchor: &Response, popup_id: Id, content: impl FnOnce(&mut Ui)) { + Popup::from_toggle_button_response(anchor).id(popup_id).align(RectAlign::TOP_START).align_alternatives(&[RectAlign::BOTTOM_START, RectAlign::TOP_END]).gap(6.0).frame(widgets::popover_frame()).close_behavior(PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + content(ui); + }); +} + +/// The duration popover: a clock pill reading `5s`, opening a 1-second-step slider between the +/// model's minimum and maximum. Returns the new value. +pub fn duration_picker(ui: &mut Ui, id: Id, current: u32, min: u32, max: u32) -> Option { + let popup_id = ui.make_persistent_id(id).with("popup"); + let open = Popup::is_id_open(ui.ctx(), popup_id); + let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(Icon::Clock), &format!("{current}s"), open)).inner.on_hover_text("Duration"); + let mut value = current; + popover(&resp, popup_id, |ui| { + ui.set_width(240.0); + widgets::menu_header(ui, "Duration"); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.style_mut().spacing.slider_width = 210.0; + ui.add(egui::Slider::new(&mut value, min..=max).step_by(1.0).suffix("s").show_value(false)); + }); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("{min}s")).size(11.0).color(theme::FAINT)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("{max}s")).size(11.0).color(theme::FAINT)); + ui.centered_and_justified(|ui| { + ui.label(egui::RichText::new(format!("{value}s")).size(13.0).family(theme::semibold()).color(theme::INK)); + }); + }); + }); + }); + (value != current).then_some(value) +} + +/// An on/off toolbar toggle (sound): a pill that's highlighted while on. +pub fn toggle(ui: &mut Ui, id: Id, icon: Icon, label: &str, on: bool, tooltip: &str) -> bool { + let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(icon), label, on)).inner.on_hover_text(tooltip); + resp.clicked() +} diff --git a/artcraft-native/src/prompt_box/refs.rs b/artcraft-native/src/prompt_box/refs.rs new file mode 100644 index 0000000000..76421728c2 --- /dev/null +++ b/artcraft-native/src/prompt_box/refs.rs @@ -0,0 +1,169 @@ +//! Reference media attached to a prompt: images, videos and audio clips, either uploaded from +//! disk or picked from the library. + +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RefKind { + Image, + Video, + Audio, +} + +impl RefKind { + pub fn noun(self) -> &'static str { + match self { + RefKind::Image => "image", + RefKind::Video => "video", + RefKind::Audio => "audio", + } + } + + /// Which kind a file is, by extension (the webapp's drop routing lists). + pub fn from_path(path: &std::path::Path) -> Option { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "avif" => Some(RefKind::Image), + "mp4" | "mov" | "webm" | "mkv" | "avi" | "m4v" => Some(RefKind::Video), + "mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "opus" => Some(RefKind::Audio), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RefStatus { + Uploading, + Ready, +} + +/// One attached reference. +#[derive(Clone, Debug)] +pub struct RefMedia { + pub id: u64, + pub kind: RefKind, + pub status: RefStatus, + /// The server's media file token, once uploaded (or picked from the library). + pub token: Option, + /// Media cache key of the thumbnail (a CDN URL or a `local://` key). + pub preview: Option, + /// The full-size URL, for the preview modal. + pub full_url: Option, + /// Seconds, for videos and audio (counts toward the model's total-duration limit). + pub duration_secs: f32, +} + +impl RefMedia { + pub fn uploading(kind: RefKind, preview: Option) -> Self { + Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Uploading, token: None, preview, full_url: None, duration_secs: 0.0 } + } + + pub fn from_library(kind: RefKind, token: String, preview: Option, full_url: Option, duration_secs: f32) -> Self { + Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Ready, token: Some(token), preview, full_url, duration_secs } + } +} + +/// Where an image goes: the reference deck, or one of the two keyframe slots. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ImageSlot { + Reference, + FirstFrame, + LastFrame, +} + +/// Everything attached to one prompt box. +#[derive(Clone, Debug, Default)] +pub struct References { + pub images: Vec, + pub videos: Vec, + pub audios: Vec, + pub first_frame: Option, + pub last_frame: Option, +} + +impl References { + pub fn is_empty(&self) -> bool { + self.images.is_empty() && self.videos.is_empty() && self.audios.is_empty() && self.first_frame.is_none() && self.last_frame.is_none() + } + + pub fn clear(&mut self) { + *self = Self::default(); + } + + pub fn list(&self, kind: RefKind) -> &Vec { + match kind { + RefKind::Image => &self.images, + RefKind::Video => &self.videos, + RefKind::Audio => &self.audios, + } + } + + pub fn list_mut(&mut self, kind: RefKind) -> &mut Vec { + match kind { + RefKind::Image => &mut self.images, + RefKind::Video => &mut self.videos, + RefKind::Audio => &mut self.audios, + } + } + + pub fn total_secs(&self, kind: RefKind) -> f32 { + self.list(kind).iter().map(|r| r.duration_secs).sum() + } + + pub fn any_uploading(&self) -> bool { + let slots = self.first_frame.iter().chain(self.last_frame.iter()); + self.images.iter().chain(&self.videos).chain(&self.audios).chain(slots).any(|r| r.status == RefStatus::Uploading) + } + + /// Finds a reference anywhere by id. + pub fn find_mut(&mut self, id: u64) -> Option<&mut RefMedia> { + let slots = self.first_frame.iter_mut().chain(self.last_frame.iter_mut()); + self.images.iter_mut().chain(self.videos.iter_mut()).chain(self.audios.iter_mut()).chain(slots).find(|r| r.id == id) + } + + /// Removes a reference anywhere by id. + pub fn remove(&mut self, id: u64) { + for list in [&mut self.images, &mut self.videos, &mut self.audios] { + list.retain(|r| r.id != id); + } + if self.first_frame.as_ref().is_some_and(|r| r.id == id) { + self.first_frame = None; + } + if self.last_frame.as_ref().is_some_and(|r| r.id == id) { + self.last_frame = None; + } + } + + pub fn swap_frames(&mut self) { + std::mem::swap(&mut self.first_frame, &mut self.last_frame); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn routes_files_by_extension() { + assert_eq!(RefKind::from_path(Path::new("a/B.PNG")), Some(RefKind::Image)); + assert_eq!(RefKind::from_path(Path::new("clip.mov")), Some(RefKind::Video)); + assert_eq!(RefKind::from_path(Path::new("beat.m4a")), Some(RefKind::Audio)); + assert_eq!(RefKind::from_path(Path::new("notes.txt")), None); + } + + #[test] + fn swapping_frames_and_removing_by_id() { + let mut refs = References::default(); + let (a, b) = (RefMedia::from_library(RefKind::Image, "a".into(), None, None, 0.0), RefMedia::from_library(RefKind::Image, "b".into(), None, None, 0.0)); + let a_id = a.id; + refs.first_frame = Some(a); + refs.last_frame = Some(b); + refs.swap_frames(); + assert_eq!(refs.first_frame.as_ref().and_then(|r| r.token.as_deref()), Some("b")); + refs.remove(a_id); + assert!(refs.last_frame.is_none()); + } +} diff --git a/artcraft-native/src/prompt_box/ui_tests.rs b/artcraft-native/src/prompt_box/ui_tests.rs new file mode 100644 index 0000000000..7baa196ad8 --- /dev/null +++ b/artcraft-native/src/prompt_box/ui_tests.rs @@ -0,0 +1,143 @@ +//! Drives the prompt box headlessly (egui_kittest): generate gating, Enter-to-generate, the +//! pickers, @-mentions and clear-all. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::backend::media_cache::MediaCache; +use crate::models::ModelInfo; +use crate::pages::toolbar; +use crate::prompt_box::deck::DeckLimits; +use crate::prompt_box::editor::{MentionKind, mention_items}; +use crate::prompt_box::refs::{RefKind, References}; +use crate::prompt_box::{DeckMode, PromptBoxProps, PromptBoxState, show}; + +struct State { + // Keeps the media cache's runtime alive. + _rt: tokio::runtime::Runtime, + cache: MediaCache, + prompt: String, + refs: References, + box_state: PromptBoxState, + model: ModelInfo, + aspect: String, + enter_to_generate: bool, + with_mentions: bool, + actions: Vec, + /// The harness draws one frame while building: fonts are installed then and used from the next. + fonts_ready: bool, +} + +fn state() -> State { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("runtime"); + let cache = MediaCache::new(rt.handle().clone(), reqwest::Client::new()); + let model = ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), aspect_ratios: vec!["square".into(), "wide_sixteen_by_nine".into()], image_refs_max: 4, batch_default: 1, ..Default::default() }; + State { _rt: rt, cache, prompt: String::new(), refs: References::default(), box_state: PromptBoxState::default(), model, aspect: "square".into(), enter_to_generate: false, with_mentions: false, actions: Vec::new(), fonts_ready: false } +} + +fn build(state: State) -> Harness<'static, State> { + let mut harness = Harness::builder().with_size(egui::vec2(1100.0, 600.0)).build_ui_state( + |ui, s: &mut State| { + if !s.fonts_ready { + crate::theme::apply(ui.ctx()); + s.fonts_ready = true; + return; + } + let mentions = if s.with_mentions { mention_items(MentionKind::Image, [None, None].into_iter()) } else { Vec::new() }; + let props = PromptBoxProps { id: egui::Id::new("test-box"), placeholder: "Describe what you want in the image...", enter_to_generate: s.enter_to_generate, mentions: &mentions, max_length: Some(Some(1000)), deck: DeckMode::References(DeckLimits { max_images: 4, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: false }), accepts: &[RefKind::Image], credits: Some(15), generate_enabled: !s.prompt.trim().is_empty(), generating: false, generate_tooltip: "Generate", warning: None, banner: None }; + let (model, aspect) = (&s.model, &mut s.aspect); + let mut left = |ui: &mut egui::Ui| { + if let Some(v) = toolbar::aspect_ratio(ui, egui::Id::new("test-aspect"), model, aspect) { + *aspect = v; + } + }; + let mut right = |_: &mut egui::Ui| {}; + let actions = show(ui, &mut s.prompt, &s.refs, &mut s.box_state, &props, &mut s.cache, &mut left, &mut right); + s.actions.extend(actions.iter().map(|a| format!("{a:?}"))); + }, + state, + ); + harness.run(); + harness +} + +fn editor<'h>(harness: &'h Harness<'_, State>) -> egui_kittest::Node<'h> { + harness.get_by_role(Role::MultilineTextInput) +} + +#[test] +fn generate_needs_a_prompt() { + let mut harness = build(state()); + harness.get_by_label("Generate").click(); + harness.run(); + assert!(harness.state().actions.is_empty(), "disabled without a prompt"); + + harness.state_mut().prompt = "a red knight".into(); + harness.run(); + harness.get_by_label("Generate").click(); + harness.run(); + assert_eq!(harness.state().actions, ["Generate"]); +} + +#[test] +fn enter_generates_only_when_the_setting_is_on() { + let mut harness = build(state()); + editor(&harness).focus(); + harness.run(); + editor(&harness).type_text("hello"); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(harness.state().prompt, "hello\n"); + assert!(harness.state().actions.is_empty()); + + let mut s = state(); + s.enter_to_generate = true; + let mut harness = build(s); + editor(&harness).focus(); + harness.run(); + editor(&harness).type_text("hello"); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(harness.state().prompt, "hello"); + assert_eq!(harness.state().actions, ["Generate"]); +} + +#[test] +fn aspect_ratio_picker_opens_and_picks() { + let mut harness = build(state()); + harness.get_by_label("Square").click(); + harness.run(); + harness.get_by_label("16:9 (Wide)").click(); + harness.run(); + assert_eq!(harness.state().aspect, "wide_sixteen_by_nine"); + assert!(harness.query_by_label("16:9 (Wide)").is_some(), "the pill now shows the pick"); +} + +#[test] +fn typing_at_offers_mentions_and_enter_inserts_one() { + let mut s = state(); + s.with_mentions = true; + let mut harness = build(s); + editor(&harness).focus(); + harness.run(); + editor(&harness).type_text("put @Im"); + harness.run(); + assert!(harness.query_by_label("@Image2").is_some(), "dropdown lists the mentions"); + harness.key_press(egui::Key::ArrowDown); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(harness.state().prompt, "put @Image2 "); + assert!(harness.state().actions.is_empty(), "Enter picked a mention, it didn't generate"); +} + +#[test] +fn clear_all_empties_the_prompt() { + let mut s = state(); + s.prompt = "something".into(); + let mut harness = build(s); + harness.get_by_label("Clear all").click(); + harness.run(); + assert_eq!(harness.state().prompt, ""); + assert_eq!(harness.state().actions, ["ClearAll"]); +} diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs new file mode 100644 index 0000000000..6b6c91354f --- /dev/null +++ b/artcraft-native/src/shell.rs @@ -0,0 +1,395 @@ +//! The app frame from the webapp: a floating sidebar (`app-sidebar`) and the top bar with +//! breadcrumbs, feed toggles, credits and the account menu (`topbar`). + +use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2}; + +use crate::feed::grid::ViewMode; +use crate::theme; +use crate::ui::icons::{self, Icon}; +use crate::ui::widgets; + +const SIDEBAR_WIDTH: f32 = 232.0; +const SIDEBAR_INSET: f32 = 8.0; +const TOPBAR_HEIGHT: f32 = 57.0; +const ROW_HEIGHT: f32 = 32.0; +pub const DISCORD_URL: &str = "https://discord.gg/artcraft"; +pub const GITHUB_URL: &str = "https://github.com/storytold/artcraft"; +pub const TUTORIALS_URL: &str = "https://www.youtube.com/@OfficialArtCraftStudios"; +pub const WEB_APP_URL: &str = "https://app.getartcraft.com"; + +/// Every page in the navigation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Page { + Home, + #[default] + CreateImage, + CreateVideo, + CreateAudio, + Object3d, + World3d, + EditImage, + Stage3d, + VideoEditor, + RemoveBackground, + FrameExtractor, + Library, +} + +impl Page { + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::CreateImage => "Image", + Page::CreateVideo => "Video", + Page::CreateAudio => "Audio", + Page::Object3d => "3D Object", + Page::World3d => "3D World", + Page::EditImage => "Edit Image", + Page::Stage3d => "3D Stage", + Page::VideoEditor => "Edit Video", + Page::RemoveBackground => "BG Remove", + Page::FrameExtractor => "Frame Extract", + Page::Library => "Library", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::CreateImage => Icon::Image, + Page::CreateVideo => Icon::Video, + Page::CreateAudio => Icon::Music, + Page::Object3d | Page::Stage3d => Icon::Box, + Page::World3d => Icon::Globe, + Page::EditImage => Icon::Pencil, + Page::VideoEditor | Page::FrameExtractor => Icon::Film, + Page::RemoveBackground => Icon::Wand, + Page::Library => Icon::Library, + } + } + + /// The breadcrumb trail (`Create › Image`). + pub fn crumbs(self) -> (&'static str, &'static str) { + match self { + Page::Home => ("ArtCraft", "Home"), + Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Object3d | Page::World3d => ("Create", self.label()), + Page::Library => ("Assets", "Library"), + _ => ("Studio", self.label()), + } + } + + /// A page by its web-app style slug (`create-image`), for `ARTCRAFT_PAGE`. + pub fn from_slug(slug: &str) -> Option { + Some(match slug { + "home" => Page::Home, + "create-image" | "image" => Page::CreateImage, + "create-video" | "video" => Page::CreateVideo, + "library" => Page::Library, + _ => return None, + }) + } + + /// Pages the native app has; the rest open a "coming soon" page. + pub fn is_ready(self) -> bool { + matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::Library) + } + + /// The same tool in the web app. + pub fn web_path(self) -> &'static str { + match self { + Page::CreateAudio => "/create-audio", + Page::Object3d => "/create-object", + Page::World3d => "/create-world", + Page::EditImage => "/pagedraw", + Page::Stage3d => "/pagescene", + Page::VideoEditor => "/video-editor", + Page::RemoveBackground => "/create-vfx", + Page::FrameExtractor => "/frame-extractor", + Page::CreateVideo => "/create-video", + Page::Library => "/library", + _ => "/create-image", + } + } +} + +const SECTIONS: [(&str, &[Page]); 3] = [("Create", &[Page::CreateImage, Page::CreateVideo, Page::CreateAudio, Page::Object3d, Page::World3d]), ("Studio", &[Page::EditImage, Page::Stage3d, Page::VideoEditor, Page::RemoveBackground, Page::FrameExtractor]), ("Assets", &[Page::Library])]; + +/// Who's signed in, for the sidebar and top bar. +pub struct Account<'a> { + pub username: Option<&'a str>, + pub display_name: Option<&'a str>, + pub credits: Option, +} + +/// What the user did in the frame. +#[derive(Debug, PartialEq)] +pub enum ShellAction { + Navigate(Page), + ToggleSidebar, + SignIn, + SignOut, + OpenSettings, + SetViewMode(ViewMode), + ToggleSelect, +} + +/// The sidebar. Returns the user's action, if any. +pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>, logo: &egui::TextureHandle) -> Option { + let mut action = None; + let rect = ui.max_rect().shrink(SIDEBAR_INSET); + ui.painter().rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside); + let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0)))); + let ui = &mut inner; + ui.spacing_mut().item_spacing.y = 2.0; + + ui.horizontal(|ui| { + let h = 20.0; + let w = h * logo.size_vec2().x / logo.size_vec2().y.max(1.0); + ui.add(egui::Image::new(logo).fit_to_exact_size(vec2(w, h))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() { + action = Some(ShellAction::ToggleSidebar); + } + }); + }); + ui.add_space(6.0); + if nav_row(ui, Page::Home, page == Page::Home) { + action = Some(ShellAction::Navigate(Page::Home)); + } + for (title, pages) in SECTIONS { + ui.add_space(10.0); + widgets::section_label(ui, title, theme::SIDEBAR_INK); + for &p in pages { + if nav_row(ui, p, page == p) { + action = Some(ShellAction::Navigate(p)); + } + } + } + ui.add_space(10.0); + widgets::section_label(ui, "Resources", theme::SIDEBAR_INK); + for (label, icon, url) in [("Tutorials", Icon::Play, TUTORIALS_URL), ("Discord", Icon::Globe, DISCORD_URL), ("GitHub", Icon::ExternalLink, GITHUB_URL)] { + if link_row(ui, label, icon) { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + } + + // The account sits at the bottom, like the webapp's download button. + let bottom = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 58.0), pos2(rect.right() - 10.0, rect.bottom() - 10.0)); + let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(bottom)); + match account.username { + Some(username) => { + let resp = account_row(&mut foot, account.display_name.unwrap_or(username), account.credits); + if resp.clicked() { + action = Some(ShellAction::OpenSettings); + } + }, + None => { + if widgets::button(&mut foot, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, bottom.width(), 40.0).clicked() { + action = Some(ShellAction::SignIn); + } + }, + } + action +} + +/// The sidebar's full width (including its inset), for the panel. +pub fn sidebar_width() -> f32 { + SIDEBAR_WIDTH + SIDEBAR_INSET * 2.0 +} + +fn nav_row(ui: &mut Ui, page: Page, selected: bool) -> bool { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if selected { + p.rect_filled(rect, theme::RADIUS, theme::WASH_SELECTED); + p.rect_filled(Rect::from_min_size(pos2(rect.left() - 8.0, rect.top() + 7.0), vec2(2.0, 18.0)), 0.0, Color32::WHITE); + } else if hovered { + p.rect_filled(rect, theme::RADIUS, theme::WASH); + } + let ink = if selected || hovered { Color32::WHITE } else { theme::SIDEBAR_INK }; + let ready = page.is_ready(); + let ink = if ready { ink } else { theme::fade(ink, 0.7) }; + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), page.icon(), ink); + let font = FontId::new(14.0, if selected { theme::medium() } else { egui::FontFamily::Proportional }); + p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, page.label(), font, ink); + if !ready { + widgets::badge(p, ui, pos2(rect.right() - 6.0, rect.center().y), "Soon", theme::FAINT); + } + resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +fn link_row(ui: &mut Ui, label: &str, icon: Icon) -> bool { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + if hovered { + p.rect_filled(rect, theme::RADIUS, theme::WASH); + } + let ink = if hovered { Color32::WHITE } else { theme::SIDEBAR_INK }; + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), icon, ink); + p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(14.0, egui::FontFamily::Proportional), ink); + icons::paint(p, Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(11.0, 11.0)), Icon::ExternalLink, theme::FAINT); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +fn account_row(ui: &mut Ui, name: &str, credits: Option) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); + let hovered = resp.hovered(); + let p = ui.painter(); + p.rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside); + let avatar = Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(28.0, 28.0)); + p.circle_filled(avatar.center(), 14.0, theme::ACCENT); + let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(); + p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE); + p.text(pos2(avatar.right() + 10.0, rect.center().y - 8.0), Align2::LEFT_CENTER, name, FontId::new(13.5, theme::medium()), theme::INK); + let sub = credits.map_or_else(|| "Account".to_owned(), |c| format!("{} credits", group_thousands(c))); + p.text(pos2(avatar.right() + 10.0, rect.center().y + 9.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED); + icons::paint(p, Rect::from_center_size(pos2(rect.right() - 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Settings, theme::DIM); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Settings") +} + +/// The top bar. `feed_toggles` shows the select and grid/list toggles (create pages). +pub fn topbar(ui: &mut Ui, page: Page, account: &Account<'_>, feed_toggles: Option<(ViewMode, bool)>, sidebar_open: bool) -> Option { + let mut action = None; + let rect = ui.max_rect(); + ui.painter().rect_filled(rect, 0.0, theme::PANEL); + ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, theme::hairline()); + let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(20.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let ui = &mut bar; + ui.spacing_mut().item_spacing.x = 8.0; + if !sidebar_open && widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() { + action = Some(ShellAction::ToggleSidebar); + } + let (section, leaf) = page.crumbs(); + widgets::hud(ui, section, theme::MUTED); + let (chev, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + icons::paint(ui.painter(), chev, Icon::ChevronRight, theme::FAINT); + let galley = widgets::caps(ui, leaf, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::PANEL); + let (crumb, _) = ui.allocate_exact_size(galley.size() + vec2(12.0, 8.0), Sense::hover()); + ui.painter().rect_filled(crumb, 0.0, theme::INK); + ui.painter().galley(crumb.center() - galley.size() / 2.0, galley, theme::PANEL); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + match account.username { + Some(_) => { + if let Some(a) = account_menu(ui, account) { + action = Some(a); + } + if let Some(credits) = account.credits { + let resp = credits_pill(ui, credits); + if resp.clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing"))); + } + } + }, + None => { + if widgets::button(ui, None, "Sign in", widgets::Kind::Primary, 0.0, 32.0).clicked() { + action = Some(ShellAction::SignIn); + } + }, + } + if let Some((mode, selecting)) = feed_toggles { + if let Some(m) = view_toggle(ui, mode) { + action = Some(ShellAction::SetViewMode(m)); + } + if segmented(ui, &[(Icon::SquareCheck, if selecting { "Exit selection" } else { "Select items" }, selecting)]).is_some() { + action = Some(ShellAction::ToggleSelect); + } + } + }); + action +} + +pub fn topbar_height() -> f32 { + TOPBAR_HEIGHT +} + +fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response { + let text = group_thousands(credits); + let galley = ui.painter().layout_no_wrap(text, FontId::new(13.0, theme::semibold()), theme::INK); + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 40.0, 30.0), Sense::click()); + let hovered = resp.hovered(); + ui.painter().rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, theme::INK); + ui.painter().galley(pos2(rect.left() + 28.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK); + resp.on_hover_text("Your credit balance \u{b7} Buy credits").on_hover_cursor(egui::CursorIcon::PointingHand) +} + +fn account_menu(ui: &mut Ui, account: &Account<'_>) -> Option { + let name = account.display_name.or(account.username).unwrap_or("Account"); + let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click()); + ui.painter().circle(rect.center(), 15.0, theme::ACCENT, Stroke::new(if resp.hovered() { 2.0 } else { 0.0 }, Color32::WHITE)); + let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + let mut action = None; + egui::Popup::menu(&resp).frame(widgets::popover_frame()).align(egui::RectAlign::BOTTOM_END).show(|ui| { + ui.set_min_width(200.0); + ui.label(egui::RichText::new(name).size(13.0).family(theme::semibold())); + if let Some(u) = account.username { + ui.label(egui::RichText::new(format!("@{u}")).size(11.5).color(theme::MUTED)); + } + ui.add_space(4.0); + if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::Settings), "Settings", None, false, false, false).clicked() { + action = Some(ShellAction::OpenSettings); + } + if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::LogOut), "Log out", None, false, false, false).clicked() { + action = Some(ShellAction::SignOut); + } + }); + action +} + +/// The grid / list toggle (`GalleryViewToggle`). +fn view_toggle(ui: &mut Ui, mode: ViewMode) -> Option { + // Right-to-left: list first so grid ends up on the left. + let picked = segmented(ui, &[(Icon::List, "List view", mode == ViewMode::List), (Icon::LayoutGrid, "Grid view", mode == ViewMode::Grid)])?; + Some(if picked == 0 { ViewMode::List } else { ViewMode::Grid }) +} + +/// A segmented group of 24 px icon buttons; returns the clicked index. +fn segmented(ui: &mut Ui, buttons: &[(Icon, &str, bool)]) -> Option { + let w = 4.0 + 26.0 * buttons.len() as f32; + let (rect, _) = ui.allocate_exact_size(vec2(w, 30.0), Sense::hover()); + ui.painter().rect(rect, theme::RADIUS, Color32::from_white_alpha(10), Stroke::new(1.0, Color32::from_white_alpha(20)), egui::StrokeKind::Inside); + let mut clicked = None; + for (i, (icon, tip, active)) in buttons.iter().enumerate() { + let b = Rect::from_min_size(pos2(rect.left() + 3.0 + 26.0 * i as f32, rect.top() + 3.0), vec2(24.0, 24.0)); + let resp = ui.interact(b, Id::new(("segmented", *tip)), Sense::click()).on_hover_text(*tip).on_hover_cursor(egui::CursorIcon::PointingHand); + if *active || resp.hovered() { + ui.painter().rect_filled(b, theme::RADIUS, theme::WASH_HOVER); + } + icons::paint(ui.painter(), b.shrink(5.0), *icon, if *active { Color32::WHITE } else { theme::DIM }); + if resp.clicked() { + clicked = Some(i); + } + } + clicked +} + +/// `1234567` → `1,234,567` (`toLocaleString`). +pub fn group_thousands(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn groups_thousands_like_to_locale_string() { + assert_eq!(group_thousands(5663), "5,663"); + assert_eq!(group_thousands(1_234_567), "1,234,567"); + assert_eq!(group_thousands(15), "15"); + } +} diff --git a/artcraft-native/src/theme.rs b/artcraft-native/src/theme.rs new file mode 100644 index 0000000000..340d4e1175 --- /dev/null +++ b/artcraft-native/src/theme.rs @@ -0,0 +1,191 @@ +//! Colours, type and shapes of the ArtCraft webapp's brutalist system +//! (`artcraft-services/frontend/apps/artcraft-webapp`): near-black surfaces, white/15 hairlines, +//! Archivo display type, Inter body text, Geist Mono labels and 3 px corners on anything +//! pressable. Large surfaces (panels, cards, media tiles) stay square. + +use std::sync::Arc; + +use egui::epaint::text::VariationCoords; +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals}; + +const fn hex(rgb: u32) -> Color32 { + let [_, r, g, b] = rgb.to_be_bytes(); + Color32::from_rgb(r, g, b) +} + +const fn alpha(rgb: u32, a: u8) -> Color32 { + let [_, r, g, b] = rgb.to_be_bytes(); + Color32::from_rgba_unmultiplied_const(r, g, b, a) +} + +/// `ui-background`: the page behind the feed. +pub const BG: Color32 = hex(0x121316); +/// `ui-panel`: the top bar, modals and the lightbox. +pub const PANEL: Color32 = hex(0x101014); +/// `ui-controls`: the sidebar, the prompt box and toolbar buttons. +pub const CONTROLS: Color32 = hex(0x1e1f22); +/// `ui-sunken`: wells and inputs. +pub const SUNKEN: Color32 = hex(0x060607); + +/// `base-fg`: body text. +pub const INK: Color32 = hex(0xf2f1ee); +pub const INK_STRONG: Color32 = Color32::WHITE; +/// `base-fg/60`: secondary text and placeholders. +pub const MUTED: Color32 = alpha(0xf2f1ee, 153); +/// `base-fg/50`: idle toolbar icons. +pub const DIM: Color32 = alpha(0xf2f1ee, 128); +/// `base-fg/40`: counters and captions. +pub const FAINT: Color32 = alpha(0xf2f1ee, 102); +/// The sidebar's link colour (`white/72`). +pub const SIDEBAR_INK: Color32 = alpha(0xffffff, 184); + +/// `white/15`: every hairline. +pub const LINE: Color32 = alpha(0xffffff, 38); +/// `white/25`: dashed drop targets. +pub const LINE_DASHED: Color32 = alpha(0xffffff, 64); +/// `base-fg/40`: outlined buttons and corner marks. +pub const LINE_STRONG: Color32 = alpha(0xf2f1ee, 102); +/// `white/5`: quiet fills. +pub const WASH: Color32 = alpha(0xffffff, 13); +/// `white/8`: the selected sidebar row. +pub const WASH_SELECTED: Color32 = alpha(0xffffff, 20); +/// `white/10`: hovered menu rows. +pub const WASH_HOVER: Color32 = alpha(0xffffff, 26); + +/// The brand blue (`primary`). +pub const ACCENT: Color32 = hex(0x2d81ff); +/// `primary-400`: progress bars and selection rings. +pub const ACCENT_400: Color32 = hex(0x59a7ff); +/// `ui-accent-ink`: blue text and focus rings. +pub const ACCENT_INK: Color32 = hex(0x74aaff); + +/// The inverted primary button (white block, near-black text). +pub const INVERT_BG: Color32 = Color32::WHITE; +pub const INVERT_FG: Color32 = hex(0x0b0b0c); + +/// `red-400`: failure text. +pub const BAD: Color32 = hex(0xf87171); +/// `red`: destructive fills. +pub const DANGER: Color32 = hex(0xd33242); +/// The "BETA" / "NEW" sidebar badges. +pub const BADGE_BETA: Color32 = hex(0xffc14d); + +/// 3 px: buttons, inputs, menus and the prompt box. +pub const RADIUS: CornerRadius = CornerRadius::same(3); + +/// Height of toolbar buttons (`py-1.5` + 14 px text + hairline). +pub const CONTROL_H: f32 = 34.0; +/// The `.hud-label`: 11 px Geist Mono, upper case, 0.15 em apart. +pub const HUD_SIZE: f32 = 11.0; +pub const HUD_SPACING: f32 = HUD_SIZE * 0.15; +/// Section labels and mono buttons are tracked a little tighter (0.12 em). +pub const LABEL_SPACING: f32 = HUD_SIZE * 0.12; + +pub fn hairline() -> Stroke { + Stroke::new(1.0, LINE) +} + +/// `c` at `a` (0–1) of its opacity. +pub fn fade(c: Color32, a: f32) -> Color32 { + c.gamma_multiply(a.clamp(0.0, 1.0)) +} + +/// The shadow under menus, modals and toasts. +pub fn shadow() -> egui::epaint::Shadow { + egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(140) } +} + +/// Inter 500. +pub fn medium() -> FontFamily { + FontFamily::Name("medium".into()) +} + +/// Inter 600. +pub fn semibold() -> FontFamily { + FontFamily::Name("semibold".into()) +} + +/// Archivo 620 at 118 % width: `.font-display` headings. +pub fn display() -> FontFamily { + FontFamily::Name("display".into()) +} + +/// Geist Mono 500: HUD labels. +pub fn mono() -> FontFamily { + FontFamily::Name("mono".into()) +} + +/// Geist Mono 600–700: section labels and mono buttons. +pub fn mono_bold() -> FontFamily { + FontFamily::Name("mono-bold".into()) +} + +fn visuals() -> Visuals { + let mut v = Visuals::dark(); + v.panel_fill = BG; + v.window_fill = PANEL; + v.extreme_bg_color = SUNKEN; + v.text_edit_bg_color = Some(Color32::TRANSPARENT); + v.faint_bg_color = CONTROLS; + v.code_bg_color = SUNKEN; + v.window_stroke = hairline(); + v.window_corner_radius = RADIUS; + v.menu_corner_radius = RADIUS; + v.window_shadow = shadow(); + v.popup_shadow = shadow(); + v.override_text_color = Some(INK); + v.hyperlink_color = ACCENT_INK; + v.selection.bg_fill = ACCENT; + v.selection.stroke = Stroke::new(1.0, Color32::WHITE); + v.text_cursor.stroke = Stroke::new(1.5, Color32::WHITE); + v.warn_fg_color = BADGE_BETA; + v.error_fg_color = BAD; + v.striped = false; + for (w, fill, stroke, fg) in [(&mut v.widgets.noninteractive, CONTROLS, LINE, MUTED), (&mut v.widgets.inactive, CONTROLS, LINE, INK), (&mut v.widgets.hovered, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.active, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.open, WASH_HOVER, LINE_STRONG, INK_STRONG)] { + w.bg_fill = fill; + w.weak_bg_fill = fill; + w.bg_stroke = Stroke::new(1.0, stroke); + w.corner_radius = RADIUS; + w.fg_stroke = Stroke::new(1.0, fg); + w.expansion = 0.0; + } + v +} + +/// Installs the fonts, colours and spacing. The app is dark only, like the webapp. +pub fn apply(ctx: &egui::Context) { + install_fonts(ctx); + ctx.set_visuals_of(egui::Theme::Dark, visuals()); + ctx.set_theme(egui::ThemePreference::Dark); + ctx.all_styles_mut(|style| { + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(12.0, 6.0); + style.spacing.interact_size.y = CONTROL_H; + style.spacing.menu_margin = egui::Margin::same(4); + style.spacing.window_margin = egui::Margin::same(16); + style.spacing.scroll = egui::style::ScrollStyle { bar_width: 8.0, floating: true, ..egui::style::ScrollStyle::thin() }; + style.text_styles = [(TextStyle::Heading, FontId::new(24.0, display())), (TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)), (TextStyle::Button, FontId::new(14.0, medium())), (TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)), (TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace))].into(); + }); +} + +/// A font registered under `name`: its file, its variation coordinates and its family. +type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily); + +/// Registers the three variable fonts, each at the weights (and width) the webapp uses. +fn install_fonts(ctx: &egui::Context) { + const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Variable.ttf"); + const ARCHIVO: &[u8] = include_bytes!("../assets/fonts/Archivo-Variable.ttf"); + const GEIST_MONO: &[u8] = include_bytes!("../assets/fonts/GeistMono-Variable.ttf"); + let mut fonts = FontDefinitions::default(); + // egui's own fonts stay behind ours, for symbols and emoji ours don't have. + let fallback = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let faces: [Face<'_>; 7] = [("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional), ("Inter-Medium", INTER, &[(*b"wght", 500.0), (*b"opsz", 14.0)], medium()), ("Inter-SemiBold", INTER, &[(*b"wght", 600.0), (*b"opsz", 14.0)], semibold()), ("Archivo-Display", ARCHIVO, &[(*b"wght", 620.0), (*b"wdth", 118.0)], display()), ("GeistMono", GEIST_MONO, &[(*b"wght", 400.0)], FontFamily::Monospace), ("GeistMono-Medium", GEIST_MONO, &[(*b"wght", 500.0)], mono()), ("GeistMono-SemiBold", GEIST_MONO, &[(*b"wght", 650.0)], mono_bold())]; + for (name, bytes, coords, family) in faces { + let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() }; + fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak))); + let mut stack = vec![name.to_owned()]; + stack.extend(fallback.iter().cloned()); + fonts.families.insert(family, stack); + } + ctx.set_fonts(fonts); +} diff --git a/artcraft-native/src/ui/creator_icons.rs b/artcraft-native/src/ui/creator_icons.rs new file mode 100644 index 0000000000..f15bb11606 --- /dev/null +++ b/artcraft-native/src/ui/creator_icons.rs @@ -0,0 +1,73 @@ +//! Model makers' logos (the Tauri app's `/resources/images/services` SVGs), rasterized once into +//! white-on-transparent textures so they can be tinted like the webapp's `icon-auto-contrast`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use egui::{ColorImage, TextureHandle, TextureOptions}; + +/// Rasterized side, in pixels (crisp up to ~32 pt at 2x). +const RASTER_SIDE: u32 = 64; + +macro_rules! service_svg { + ($name:literal) => { + include_bytes!(concat!("../../../frontend/apps/artcraft/app/public/resources/images/services/", $name, ".svg")) + }; +} + +/// The logo for a `ModelCreator` (snake_case, as the model listing sends it). +fn svg_for_creator(creator: &str) -> &'static [u8] { + match creator { + "alibaba" => service_svg!("alibaba"), + "artcraft" => service_svg!("artcraft"), + "black_forest_labs" | "blackforestlabs" | "bfl" => service_svg!("blackforestlabs"), + "bytedance" => service_svg!("bytedance"), + "fal" => service_svg!("fal"), + "google" => service_svg!("google"), + "grok" | "xai" => service_svg!("grok"), + "higgsfield" => service_svg!("higgsfield"), + "kling" | "kuaishou" => service_svg!("kling"), + "krea" => service_svg!("krea"), + "midjourney" => service_svg!("midjourney"), + "minimax" | "hailuo" => service_svg!("minimax"), + "open_ai" | "openai" => service_svg!("openai"), + "openart" | "open_art" => service_svg!("openart"), + "recraft" => service_svg!("recraft"), + "replicate" => service_svg!("replicate"), + "suno" => service_svg!("suno"), + "tencent" => service_svg!("tencent"), + "tensor_art" | "tensorart" => service_svg!("tensorart"), + "vidu" => service_svg!("vidu"), + "world_labs" | "worldlabs" => service_svg!("worldlabs"), + _ => service_svg!("generic"), + } +} + +#[derive(Clone, Default)] +struct Cache(Arc>>); + +/// A white logo for `creator`, to be tinted when drawn. +pub fn texture(ctx: &egui::Context, creator: &str) -> TextureHandle { + let cache: Cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("creator-icons")).clone()); + let mut map = cache.0.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(texture) = map.get(creator) { + return texture.clone(); + } + let image = rasterize_mask(svg_for_creator(creator)).unwrap_or_else(|| ColorImage::filled([1, 1], egui::Color32::TRANSPARENT)); + let texture = ctx.load_texture(format!("creator-{creator}"), image, TextureOptions::LINEAR); + map.insert(creator.to_owned(), texture.clone()); + texture +} + +/// Renders an SVG and keeps only its coverage: every pixel becomes white at the original alpha. +fn rasterize_mask(svg: &[u8]) -> Option { + let tree = resvg::usvg::Tree::from_data(svg, &resvg::usvg::Options::default()).ok()?; + let size = tree.size(); + let scale = RASTER_SIDE as f32 / size.width().max(size.height()); + let mut pixmap = resvg::tiny_skia::Pixmap::new(RASTER_SIDE, RASTER_SIDE)?; + let dx = (RASTER_SIDE as f32 - size.width() * scale) / 2.0; + let dy = (RASTER_SIDE as f32 - size.height() * scale) / 2.0; + resvg::render(&tree, resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy), &mut pixmap.as_mut()); + let pixels = pixmap.pixels().iter().map(|p| egui::Color32::from_white_alpha(p.alpha())).collect(); + Some(ColorImage::new([RASTER_SIDE as usize, RASTER_SIDE as usize], pixels)) +} diff --git a/artcraft-native/src/ui/icons.rs b/artcraft-native/src/ui/icons.rs new file mode 100644 index 0000000000..7467aafd4b --- /dev/null +++ b/artcraft-native/src/ui/icons.rs @@ -0,0 +1,370 @@ +//! Line icons drawn on Lucide's 24-unit grid (the webapp's icon set), so they stay crisp at any +//! size without an icon font. + +use egui::{Color32, Painter, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, vec2}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Icon { + AlertCircle, + ArrowLeftRight, + ArrowUp, + Box, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Clock, + Coins, + Copy, + Download, + Eraser, + Eye, + ExternalLink, + Film, + Folder, + Globe, + Gem, + Home, + Image, + Info, + LayoutGrid, + Link, + Library, + List, + LogIn, + LogOut, + Maximize, + Music, + PanelLeft, + Pencil, + Play, + Plus, + RotateCw, + Settings, + Sparkles, + SquareCheck, + Trash, + Upload, + Video, + Volume, + VolumeOff, + Wand, + X, +} + +/// Maps Lucide's 24-unit grid onto a rect. +struct Grid { + origin: Pos2, + scale: f32, + stroke: Stroke, +} + +impl Grid { + fn new(rect: Rect, color: Color32) -> Self { + let side = rect.width().min(rect.height()); + let origin = rect.center() - vec2(side, side) / 2.0; + let scale = side / 24.0; + // Lucide's 2-unit stroke, a touch lighter so small icons don't clog. + Self { origin, scale, stroke: Stroke::new((1.75 * scale).max(1.1), color) } + } + + fn p(&self, x: f32, y: f32) -> Pos2 { + self.origin + vec2(x, y) * self.scale + } + + fn line(&self, painter: &Painter, pts: &[(f32, f32)]) { + let pts: Vec = pts.iter().map(|&(x, y)| self.p(x, y)).collect(); + painter.add(Shape::line(pts, self.stroke)); + } + + fn closed(&self, painter: &Painter, pts: &[(f32, f32)]) { + let pts: Vec = pts.iter().map(|&(x, y)| self.p(x, y)).collect(); + painter.add(Shape::closed_line(pts, self.stroke)); + } + + fn rect(&self, painter: &Painter, x: f32, y: f32, w: f32, h: f32, r: f32) { + let rect = Rect::from_min_size(self.p(x, y), vec2(w, h) * self.scale); + painter.rect_stroke(rect, r * self.scale, self.stroke, egui::StrokeKind::Middle); + } + + fn circle(&self, painter: &Painter, x: f32, y: f32, r: f32) { + painter.circle_stroke(self.p(x, y), r * self.scale, self.stroke); + } + + fn dot(&self, painter: &Painter, x: f32, y: f32, r: f32) { + painter.circle_filled(self.p(x, y), r * self.scale, self.stroke.color); + } + + /// An arc round (cx, cy), angles in degrees clockwise from 3 o'clock. + fn arc(&self, painter: &Painter, cx: f32, cy: f32, r: f32, from: f32, to: f32) { + let steps = 24; + let pts: Vec = (0..=steps) + .map(|i| { + let a = (from + (to - from) * i as f32 / steps as f32).to_radians(); + self.p(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + painter.add(Shape::line(pts, self.stroke)); + } +} + +/// Paints `icon` centred in `rect`. +pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { + let g = Grid::new(rect, color); + let p = painter; + match icon { + Icon::AlertCircle => { + g.circle(p, 12.0, 12.0, 10.0); + g.line(p, &[(12.0, 8.0), (12.0, 12.0)]); + g.dot(p, 12.0, 16.0, 1.1); + }, + Icon::ArrowLeftRight => { + g.line(p, &[(8.0, 3.0), (4.0, 7.0), (8.0, 11.0)]); + g.line(p, &[(4.0, 7.0), (20.0, 7.0)]); + g.line(p, &[(16.0, 21.0), (20.0, 17.0), (16.0, 13.0)]); + g.line(p, &[(20.0, 17.0), (4.0, 17.0)]); + }, + Icon::ArrowUp => { + g.line(p, &[(5.0, 12.0), (12.0, 5.0), (19.0, 12.0)]); + g.line(p, &[(12.0, 19.0), (12.0, 5.0)]); + }, + Icon::Box => { + g.closed(p, &[(12.0, 2.0), (21.0, 7.0), (21.0, 17.0), (12.0, 22.0), (3.0, 17.0), (3.0, 7.0)]); + g.line(p, &[(3.0, 7.0), (12.0, 12.0), (21.0, 7.0)]); + g.line(p, &[(12.0, 12.0), (12.0, 22.0)]); + }, + Icon::Check => g.line(p, &[(20.0, 6.0), (9.0, 17.0), (4.0, 12.0)]), + Icon::ChevronDown => g.line(p, &[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)]), + Icon::ChevronLeft => g.line(p, &[(15.0, 18.0), (9.0, 12.0), (15.0, 6.0)]), + Icon::ChevronRight => g.line(p, &[(9.0, 18.0), (15.0, 12.0), (9.0, 6.0)]), + Icon::ChevronUp => g.line(p, &[(18.0, 15.0), (12.0, 9.0), (6.0, 15.0)]), + Icon::Clock => { + g.circle(p, 12.0, 12.0, 10.0); + g.line(p, &[(12.0, 6.0), (12.0, 12.0), (16.0, 14.0)]); + }, + Icon::Coins => { + g.circle(p, 8.0, 8.0, 6.0); + g.arc(p, 15.5, 15.5, 6.0, -100.0, 190.0); + g.line(p, &[(7.0, 6.0), (8.0, 5.0), (8.0, 11.0)]); + }, + Icon::Copy => { + g.rect(p, 8.0, 8.0, 14.0, 14.0, 2.0); + g.line(p, &[(4.0, 16.0), (4.0, 4.0), (16.0, 4.0)]); + }, + Icon::Download => { + g.line(p, &[(12.0, 3.0), (12.0, 15.0)]); + g.line(p, &[(7.0, 10.0), (12.0, 15.0), (17.0, 10.0)]); + g.line(p, &[(4.0, 21.0), (20.0, 21.0)]); + }, + Icon::Eraser => { + g.closed(p, &[(7.0, 21.0), (3.5, 17.5), (14.0, 7.0), (21.0, 14.0), (14.0, 21.0)]); + g.line(p, &[(22.0, 21.0), (7.0, 21.0)]); + g.line(p, &[(9.0, 12.0), (16.0, 19.0)]); + }, + Icon::ExternalLink => { + g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]); + g.line(p, &[(10.0, 14.0), (21.0, 3.0)]); + g.line(p, &[(18.0, 13.0), (18.0, 19.0), (16.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 8.0), (5.0, 6.0), (11.0, 6.0)]); + }, + Icon::Eye => { + g.closed(p, &[(2.0, 12.0), (5.0, 7.5), (9.0, 5.2), (12.0, 4.8), (15.0, 5.2), (19.0, 7.5), (22.0, 12.0), (19.0, 16.5), (15.0, 18.8), (12.0, 19.2), (9.0, 18.8), (5.0, 16.5)]); + g.circle(p, 12.0, 12.0, 3.0); + }, + Icon::Gem => { + g.closed(p, &[(6.0, 3.0), (18.0, 3.0), (22.0, 9.0), (12.0, 22.0), (2.0, 9.0)]); + g.line(p, &[(2.0, 9.0), (22.0, 9.0)]); + g.line(p, &[(11.0, 3.0), (8.0, 9.0), (12.0, 22.0), (16.0, 9.0), (13.0, 3.0)]); + }, + Icon::Info => { + g.circle(p, 12.0, 12.0, 10.0); + g.line(p, &[(12.0, 16.0), (12.0, 12.0)]); + g.dot(p, 12.0, 8.0, 1.1); + }, + Icon::Link => { + g.line(p, &[(10.0, 13.0), (11.0, 14.0), (14.0, 14.4), (16.5, 13.0), (20.0, 9.5), (20.6, 6.0), (18.0, 3.4), (14.5, 4.0), (12.8, 5.7)]); + g.line(p, &[(14.0, 11.0), (13.0, 10.0), (10.0, 9.6), (7.5, 11.0), (4.0, 14.5), (3.4, 18.0), (6.0, 20.6), (9.5, 20.0), (11.2, 18.3)]); + }, + Icon::LogIn => { + g.line(p, &[(15.0, 3.0), (19.0, 3.0), (21.0, 5.0), (21.0, 19.0), (19.0, 21.0), (15.0, 21.0)]); + g.line(p, &[(10.0, 17.0), (15.0, 12.0), (10.0, 7.0)]); + g.line(p, &[(15.0, 12.0), (3.0, 12.0)]); + }, + Icon::SquareCheck => { + g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0); + g.line(p, &[(8.0, 12.0), (11.0, 15.0), (16.0, 9.0)]); + }, + Icon::Film => { + g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0); + g.line(p, &[(7.0, 3.0), (7.0, 21.0)]); + g.line(p, &[(17.0, 3.0), (17.0, 21.0)]); + g.line(p, &[(3.0, 12.0), (21.0, 12.0)]); + g.line(p, &[(3.0, 7.5), (7.0, 7.5)]); + g.line(p, &[(17.0, 7.5), (21.0, 7.5)]); + g.line(p, &[(3.0, 16.5), (7.0, 16.5)]); + g.line(p, &[(17.0, 16.5), (21.0, 16.5)]); + }, + Icon::Folder => g.closed(p, &[(2.0, 5.0), (3.0, 4.0), (9.0, 4.0), (11.0, 7.0), (21.0, 7.0), (22.0, 8.0), (22.0, 19.0), (21.0, 20.0), (3.0, 20.0), (2.0, 19.0)]), + Icon::Globe => { + g.circle(p, 12.0, 12.0, 10.0); + g.line(p, &[(2.0, 12.0), (22.0, 12.0)]); + g.arc(p, 2.0, 12.0, 14.14, -45.0, 45.0); + g.arc(p, 22.0, 12.0, 14.14, 135.0, 225.0); + }, + Icon::Home => { + g.closed(p, &[(3.0, 10.0), (12.0, 3.0), (21.0, 10.0), (21.0, 21.0), (3.0, 21.0)]); + g.line(p, &[(9.0, 21.0), (9.0, 14.0), (15.0, 14.0), (15.0, 21.0)]); + }, + Icon::Image => { + g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0); + g.circle(p, 9.0, 9.0, 2.0); + g.line(p, &[(21.0, 15.0), (16.0, 10.0), (5.0, 21.0)]); + }, + Icon::LayoutGrid => { + for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { + g.rect(p, x, y, 7.0, 7.0, 1.0); + } + }, + Icon::Library => { + g.line(p, &[(16.0, 6.0), (20.0, 20.0)]); + g.line(p, &[(12.0, 6.0), (12.0, 20.0)]); + g.line(p, &[(8.0, 8.0), (8.0, 20.0)]); + g.line(p, &[(4.0, 4.0), (4.0, 20.0)]); + }, + Icon::List => { + for y in [6.0, 12.0, 18.0] { + g.line(p, &[(8.0, y), (21.0, y)]); + g.dot(p, 3.5, y, 1.1); + } + }, + Icon::LogOut => { + g.line(p, &[(9.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 5.0), (5.0, 3.0), (9.0, 3.0)]); + g.line(p, &[(16.0, 17.0), (21.0, 12.0), (16.0, 7.0)]); + g.line(p, &[(21.0, 12.0), (9.0, 12.0)]); + }, + Icon::Maximize => { + g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]); + g.line(p, &[(9.0, 21.0), (3.0, 21.0), (3.0, 15.0)]); + g.line(p, &[(21.0, 3.0), (14.0, 10.0)]); + g.line(p, &[(3.0, 21.0), (10.0, 14.0)]); + }, + Icon::Music => { + g.line(p, &[(9.0, 18.0), (9.0, 5.0), (21.0, 3.0), (21.0, 16.0)]); + g.circle(p, 6.0, 18.0, 3.0); + g.circle(p, 18.0, 16.0, 3.0); + }, + Icon::PanelLeft => { + g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0); + g.line(p, &[(9.0, 3.0), (9.0, 21.0)]); + }, + Icon::Pencil => { + g.closed(p, &[(17.0, 3.0), (21.0, 7.0), (7.5, 20.5), (2.0, 22.0), (3.5, 16.5)]); + g.line(p, &[(15.0, 5.0), (19.0, 9.0)]); + }, + Icon::Play => { + let pts = [g.p(7.0, 4.0), g.p(20.0, 12.0), g.p(7.0, 20.0)]; + p.add(Shape::convex_polygon(pts.to_vec(), color, Stroke::NONE)); + }, + Icon::Plus => { + g.line(p, &[(5.0, 12.0), (19.0, 12.0)]); + g.line(p, &[(12.0, 5.0), (12.0, 19.0)]); + }, + Icon::RotateCw => { + g.arc(p, 12.0, 12.0, 9.0, -60.0, 250.0); + g.line(p, &[(21.0, 3.0), (21.0, 8.5), (15.5, 8.5)]); + }, + Icon::Settings => { + g.circle(p, 12.0, 12.0, 3.0); + g.circle(p, 12.0, 12.0, 7.5); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + g.line(p, &[(12.0 + 7.5 * a.cos(), 12.0 + 7.5 * a.sin()), (12.0 + 10.0 * a.cos(), 12.0 + 10.0 * a.sin())]); + } + }, + Icon::Sparkles => { + let star = |cx: f32, cy: f32, r: f32| { + let k = r * 0.28; + g.closed(p, &[(cx, cy - r), (cx + k, cy - k), (cx + r, cy), (cx + k, cy + k), (cx, cy + r), (cx - k, cy + k), (cx - r, cy), (cx - k, cy - k)]); + }; + star(10.0, 12.0, 8.0); + star(19.0, 5.0, 3.0); + }, + Icon::Trash => { + g.line(p, &[(3.0, 6.0), (21.0, 6.0)]); + g.line(p, &[(19.0, 6.0), (19.0, 20.0), (17.0, 22.0), (7.0, 22.0), (5.0, 20.0), (5.0, 6.0)]); + g.line(p, &[(8.0, 6.0), (8.0, 4.0), (10.0, 2.0), (14.0, 2.0), (16.0, 4.0), (16.0, 6.0)]); + }, + Icon::Upload => { + g.line(p, &[(12.0, 15.0), (12.0, 3.0)]); + g.line(p, &[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)]); + g.line(p, &[(4.0, 21.0), (20.0, 21.0)]); + }, + Icon::Video => { + g.rect(p, 2.0, 6.0, 14.0, 12.0, 2.0); + g.closed(p, &[(16.0, 10.5), (22.0, 7.0), (22.0, 17.0), (16.0, 13.5)]); + }, + Icon::Volume | Icon::VolumeOff => { + g.closed(p, &[(11.0, 5.0), (6.0, 9.0), (2.0, 9.0), (2.0, 15.0), (6.0, 15.0), (11.0, 19.0)]); + if icon == Icon::Volume { + g.arc(p, 12.0, 12.0, 4.0, -50.0, 50.0); + g.arc(p, 12.0, 12.0, 8.0, -50.0, 50.0); + } else { + g.line(p, &[(16.0, 9.0), (22.0, 15.0)]); + g.line(p, &[(22.0, 9.0), (16.0, 15.0)]); + } + }, + Icon::Wand => { + g.line(p, &[(3.0, 21.0), (15.0, 9.0)]); + g.line(p, &[(15.0, 4.0), (15.0, 6.0)]); + g.line(p, &[(18.0, 9.0), (20.0, 9.0)]); + g.line(p, &[(18.5, 5.5), (17.0, 7.0)]); + g.line(p, &[(11.0, 5.0), (12.0, 6.0)]); + g.line(p, &[(19.0, 13.0), (18.0, 12.0)]); + }, + Icon::X => { + g.line(p, &[(18.0, 6.0), (6.0, 18.0)]); + g.line(p, &[(6.0, 6.0), (18.0, 18.0)]); + }, + } +} + +/// Allocates a `size` square and paints `icon` in it. +pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + paint(ui.painter(), rect, icon, color); + resp +} + +/// The outline of an aspect ratio `w:h`, fitted inside `rect` (the webapp's `AspectRatioIcon`). +/// `None` draws the dashed "auto" frame. +pub fn paint_aspect(painter: &Painter, rect: Rect, ratio: Option<(f32, f32)>, color: Color32) { + let side = rect.width().min(rect.height()) * 0.8; + let stroke = Stroke::new(1.4, color); + match ratio { + Some((w, h)) if w > 0.0 && h > 0.0 => { + let size = if w >= h { vec2(side, side * h / w) } else { vec2(side * w / h, side) }; + let r = Rect::from_center_size(rect.center(), size); + painter.rect_stroke(r, 1.5, stroke, egui::StrokeKind::Middle); + }, + _ => { + let r = Rect::from_center_size(rect.center(), vec2(side, side)); + let shapes = Shape::dashed_line(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], stroke, 2.5, 2.0); + painter.extend(shapes); + }, + } +} + +/// A spinner arc, like Lucide's `LoaderCircle` with `animate-spin`. +pub fn paint_spinner(ui: &Ui, rect: Rect, color: Color32) { + let t = ui.input(|i| i.time) as f32; + let g = Grid::new(rect, color); + let start = t * 360.0; + g.arc(ui.painter(), 12.0, 12.0, 9.0, start, start + 270.0); + ui.ctx().request_repaint(); +} + +/// A dashed outline round `rect` (drop targets and empty reference slots). +pub fn dashed_rect(painter: &Painter, rect: Rect, stroke: Stroke, dash: f32, gap: f32) { + let pts = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom(), rect.left_top()]; + painter.extend(Shape::dashed_line(&pts, stroke, dash, gap)); +} diff --git a/artcraft-native/src/ui/mod.rs b/artcraft-native/src/ui/mod.rs new file mode 100644 index 0000000000..40b7de6762 --- /dev/null +++ b/artcraft-native/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod creator_icons; +pub mod icons; +pub mod toast; +pub mod widgets; diff --git a/artcraft-native/src/ui/toast.rs b/artcraft-native/src/ui/toast.rs new file mode 100644 index 0000000000..221bc94323 --- /dev/null +++ b/artcraft-native/src/ui/toast.rs @@ -0,0 +1,97 @@ +//! Toasts: brief success and error notes stacked at the top right (the webapp's `toast`). + +use std::time::Duration; + +use egui::{Align2, Color32, FontId, Id, Order, Rect, Sense, pos2, vec2}; + +use crate::theme; +use crate::ui::icons::{self, Icon}; + +const DEFAULT_DURATION: Duration = Duration::from_secs(4); +const MAX_WIDTH: f32 = 448.0; +/// Below the top bar, like the webapp's `top-16 right-5`. +const TOP_OFFSET: f32 = 64.0; +const RIGHT_OFFSET: f32 = 20.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToastKind { + Success, + Error, +} + +struct Toast { + id: u64, + kind: ToastKind, + message: String, + expires_at: f64, +} + +#[derive(Default)] +pub struct Toasts { + next_id: u64, + items: Vec, + pending: Vec<(ToastKind, String)>, +} + +impl Toasts { + pub fn success(&mut self, message: impl Into) { + self.pending.push((ToastKind::Success, message.into())); + } + + pub fn error(&mut self, message: impl Into) { + self.pending.push((ToastKind::Error, message.into())); + } + + /// Draws the stack over everything else and drops expired toasts. + pub fn show(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + for (kind, message) in self.pending.drain(..) { + // The same message twice in a row (e.g. a held Enter key) shows once, with a fresh timer. + self.items.retain(|t| !(t.kind == kind && t.message == message)); + self.next_id += 1; + self.items.push(Toast { id: self.next_id, kind, message, expires_at: now + DEFAULT_DURATION.as_secs_f64() }); + } + self.items.retain(|t| t.expires_at > now); + if self.items.is_empty() { + return; + } + let screen = ctx.content_rect(); + let mut y = screen.top() + TOP_OFFSET; + let mut dismissed = None; + for toast in &self.items { + let area = egui::Area::new(Id::new(("toast", toast.id))).order(Order::Tooltip).pivot(Align2::RIGHT_TOP).fixed_pos(pos2(screen.right() - RIGHT_OFFSET, y)).interactable(true); + let inner = area.show(ctx, |ui| { + let galley = ui.painter().layout(toast.message.clone(), FontId::new(14.0, egui::FontFamily::Proportional), theme::fade(Color32::WHITE, 0.9), MAX_WIDTH - 80.0); + let h = galley.size().y.max(24.0) + 20.0; + let w = 16.0 + 24.0 + 12.0 + galley.size().x + 12.0 + 12.0 + 16.0; + let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + let p = ui.painter(); + p.rect(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.97), theme::hairline(), egui::StrokeKind::Inside); + let badge = Rect::from_min_size(pos2(rect.left() + 16.0, rect.center().y - 12.0), vec2(24.0, 24.0)); + let (fill, icon) = match toast.kind { + ToastKind::Success => (Color32::from_rgb(0x22, 0xc5, 0x5e), Icon::Check), + ToastKind::Error => (Color32::from_rgb(0xef, 0x44, 0x44), Icon::AlertCircle), + }; + p.rect_filled(badge, 0.0, fill); + icons::paint(p, badge.shrink(6.0), icon, Color32::WHITE); + p.galley(pos2(badge.right() + 12.0, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let close = Rect::from_center_size(pos2(rect.right() - 22.0, rect.center().y), vec2(16.0, 16.0)); + let close_resp = ui.interact(close, Id::new(("toast-close", toast.id)), Sense::click()); + let ink = if close_resp.hovered() { theme::fade(Color32::WHITE, 0.8) } else { theme::fade(Color32::WHITE, 0.4) }; + icons::paint(p, close.shrink(2.0), Icon::X, ink); + close_resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() + }); + if inner.inner { + dismissed = Some(toast.id); + } + y += inner.response.rect.height() + 8.0; + } + if let Some(id) = dismissed { + self.items.retain(|t| t.id != id); + } + // Wake up to expire the oldest toast on time. + if let Some(next) = self.items.iter().map(|t| t.expires_at).reduce(f64::min) { + ctx.request_repaint_after(Duration::from_secs_f64((next - now).max(0.0))); + } + } +} diff --git a/artcraft-native/src/ui/widgets.rs b/artcraft-native/src/ui/widgets.rs new file mode 100644 index 0000000000..c08ebf8d9d --- /dev/null +++ b/artcraft-native/src/ui/widgets.rs @@ -0,0 +1,354 @@ +//! Building blocks in the webapp's brutalist style: mono HUD labels, 3 px toolbar pills, the round +//! generate button, popover menus and corner registration marks. + +use std::sync::Arc; + +use egui::{Align2, Color32, CornerRadius, FontId, Frame, Galley, Margin, Painter, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; + +use crate::theme; +use crate::ui::icons::{self, Icon}; + +/// `text` laid out upper case in `family`, `spacing` points between letters. +pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc { + let format = egui::TextFormat { font_id: FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() }; + ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format)) +} + +/// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced. +pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response { + let galley = caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color); + let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover()); + ui.painter().galley(rect.min, galley, color); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + resp +} + +/// A sidebar section or menu heading: 11 px Geist Mono 600, upper case. +pub fn section_label(ui: &mut Ui, text: &str, color: Color32) -> Response { + let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, color); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover()); + let pos = pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley, color); + resp +} + +/// A small badge after a sidebar link ("BETA", "NEW"): 9 px mono in `color` on a tinted chip. +pub fn badge(painter: &Painter, ui: &Ui, right_center: egui::Pos2, text: &str, color: Color32) { + let galley = caps(ui, text, 9.0, theme::mono_bold(), 0.9, color); + let size = galley.size() + vec2(10.0, 4.0); + let rect = Rect::from_min_size(pos2(right_center.x - size.x, right_center.y - size.y / 2.0), size); + painter.rect(rect, CornerRadius::same(2), theme::fade(color, 0.12), Stroke::new(1.0, theme::fade(color, 0.4)), egui::StrokeKind::Inside); + painter.galley(rect.center() - galley.size() / 2.0, galley, color); +} + +/// What a [`pill`] shows before its label. +pub enum Leading<'a> { + None, + Icon(Icon), + /// An aspect ratio outline (`None` = the dashed "auto" frame). + Aspect(Option<(f32, f32)>), + /// A small caption in front of the label ("SD", "HD"). + Caption(&'a str), + /// A model maker's logo. + Logo(&'a egui::TextureHandle), +} + +/// A toolbar pill (`bg-ui-controls border-white/15 rounded-[3px] px-3 py-1.5 text-sm font-medium`), +/// as used for the model, aspect ratio, resolution and duration pickers. +pub fn pill(ui: &mut Ui, leading: Leading<'_>, label: &str, open: bool) -> Response { + let enabled = ui.is_enabled(); + let font = FontId::new(14.0, theme::medium()); + let galley = ui.painter().layout_no_wrap(label.to_owned(), font, theme::INK); + let caption = match leading { + Leading::Caption(c) => Some(caps(ui, c, 9.0, theme::mono_bold(), 0.6, theme::DIM)), + _ => None, + }; + let lead_w = match (&leading, &caption) { + (Leading::None, _) => 0.0, + (_, Some(c)) => c.size().x + 6.0, + _ => 14.0 + 7.0, + }; + let size = vec2(12.0 + lead_w + galley.size().x + 12.0, theme::CONTROL_H); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered() && enabled; + let p = ui.painter(); + let fill = if open || hovered { blend(theme::CONTROLS, theme::WASH_HOVER) } else { theme::CONTROLS }; + let stroke = if open { theme::LINE_STRONG } else { theme::LINE }; + p.rect(rect, theme::RADIUS, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); + let ink = if enabled { theme::INK } else { theme::FAINT }; + let mut x = rect.left() + 12.0; + let lead_rect = Rect::from_center_size(pos2(x + 7.0, rect.center().y), vec2(14.0, 14.0)); + match leading { + Leading::None => {}, + Leading::Icon(icon) => icons::paint(p, lead_rect, icon, ink), + Leading::Aspect(ratio) => icons::paint_aspect(p, lead_rect, ratio, ink), + Leading::Logo(texture) => { + egui::Image::new(texture).tint(ink).paint_at(ui, lead_rect); + }, + Leading::Caption(_) => { + if let Some(c) = caption { + p.galley(pos2(x, rect.center().y - c.size().y / 2.0), c, theme::DIM); + } + }, + } + x += lead_w; + p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, ink); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + if enabled { + resp.on_hover_cursor(egui::CursorIcon::PointingHand) + } else { + resp + } +} + +/// A transparent toolbar button with an icon and an optional trailing label +/// (`text-base-fg/50`, `hover:bg-white/5`): the count picker and clear-all. +pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bool) -> Response { + let enabled = ui.is_enabled(); + let galley = label.map(|l| ui.painter().layout_no_wrap(l.to_owned(), FontId::new(14.0, theme::medium()), theme::INK)); + let w = match &galley { + Some(g) => 10.0 + 15.0 + 6.0 + g.size().x + 10.0, + None => 36.0, + }; + let (rect, resp) = ui.allocate_exact_size(vec2(w, 36.0), Sense::click()); + let hovered = resp.hovered() && enabled; + let p = ui.painter(); + if hovered || active { + p.rect_filled(rect, theme::RADIUS, theme::WASH); + } + let ink = match (enabled, hovered || active) { + (false, _) => theme::fade(theme::DIM, 0.5), + (true, true) => theme::INK, + (true, false) => theme::DIM, + }; + match galley { + Some(g) => { + let icon_rect = Rect::from_center_size(pos2(rect.left() + 10.0 + 7.5, rect.center().y), vec2(15.0, 15.0)); + icons::paint(p, icon_rect, icon, ink); + p.galley_with_override_text_color(pos2(icon_rect.right() + 6.0, rect.center().y - g.size().y / 2.0), g, ink); + }, + None => icons::paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, ink), + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); + let resp = resp.on_hover_text(tip); + if enabled { + resp.on_hover_cursor(egui::CursorIcon::PointingHand) + } else { + resp + } +} + +/// A small square icon button for overlays (`h-7 w-7`, `hover:bg-white/15`). +pub fn overlay_icon(ui: &mut Ui, icon: Icon, tip: &str) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + let hovered = resp.hovered(); + if hovered { + ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(38)); + } + let ink = if hovered { Color32::WHITE } else { Color32::from_white_alpha(217) }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(14.0, 14.0)), icon, ink); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// The round blue generate button (`GenerateIconButton`): an up arrow, a spinner while `loading`. +pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), if enabled { Sense::click() } else { Sense::hover() }); + let hovered = resp.hovered() && enabled; + let fill = match (enabled, hovered) { + (false, _) => theme::fade(theme::ACCENT, 0.4), + (true, true) => theme::ACCENT_400, + (true, false) => theme::ACCENT, + }; + ui.painter().circle_filled(rect.center(), 18.0, fill); + let ink = if enabled { Color32::WHITE } else { theme::fade(Color32::WHITE, 0.5) }; + let inner = Rect::from_center_size(rect.center(), vec2(18.0, 18.0)); + if loading { + icons::paint_spinner(ui, inner, ink); + } else { + icons::paint(ui.painter(), inner, Icon::ArrowUp, ink); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, "Generate")); + let resp = resp.on_hover_text(tip); + if enabled { + resp.on_hover_cursor(egui::CursorIcon::PointingHand) + } else { + resp + } +} + +/// Which of the site's block button variants. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// A white block with near-black text; fades a little on hover. + Primary, + /// Outlined in `base-fg/40`; inverts on hover. + Secondary, + /// The brand blue. + Accent, + /// Solid red. + Danger, +} + +/// A block button with a mono upper-case label (`SIGN UP TO CREATE`, `DOWNLOAD`), at least `width` +/// wide and `height` tall. +pub fn button(ui: &mut Ui, icon: Option, text: &str, kind: Kind, width: f32, height: f32) -> Response { + let enabled = ui.is_enabled(); + let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::INK); + let icon_w = if icon.is_some() { 13.0 + 8.0 } else { 0.0 }; + let size = vec2((galley.size().x + icon_w + 40.0).max(width), height); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let hovered = resp.hovered() && enabled; + let (fill, stroke, ink) = match kind { + Kind::Primary => (theme::fade(theme::INVERT_BG, if hovered { 0.88 } else { 1.0 }), Stroke::NONE, theme::INVERT_FG), + Kind::Accent => (if hovered { theme::ACCENT_400 } else { theme::ACCENT }, Stroke::NONE, Color32::WHITE), + Kind::Danger => (theme::fade(theme::DANGER, if hovered { 0.85 } else { 1.0 }), Stroke::NONE, Color32::WHITE), + Kind::Secondary if hovered => (theme::INVERT_BG, Stroke::NONE, theme::INVERT_FG), + Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, theme::LINE_STRONG), theme::INK), + }; + let dim = |c: Color32| if enabled { c } else { theme::fade(c, 0.5) }; + let p = ui.painter(); + p.rect(rect, theme::RADIUS, dim(fill), Stroke::new(stroke.width, dim(stroke.color)), egui::StrokeKind::Inside); + let content_w = icon_w + galley.size().x; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = icon { + icons::paint(p, Rect::from_center_size(pos2(x + 6.5, rect.center().y), vec2(13.0, 13.0)), icon, dim(ink)); + x += icon_w; + } + p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, dim(ink)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text)); + if enabled { + resp.on_hover_cursor(egui::CursorIcon::PointingHand) + } else { + resp + } +} + +/// Fine registration marks at a panel's corners (the landing page's section corners). +pub fn corner_marks(painter: &Painter, rect: Rect) { + let stroke = Stroke::new(1.0, theme::LINE_STRONG); + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.hline(c.x - 5.5..=c.x + 5.5, c.y, stroke); + painter.vline(c.x, c.y - 5.5..=c.y + 5.5, stroke); + } +} + +/// The frame of popovers and menus: the controls surface, a hairline and a soft shadow. +pub fn popover_frame() -> Frame { + Frame::new().fill(theme::CONTROLS).stroke(theme::hairline()).corner_radius(theme::RADIUS).shadow(theme::shadow()).inner_margin(Margin::same(4)) +} + +/// A popover's heading ("SELECT MODEL", "ASPECT RATIO") with a hairline under it. +pub fn menu_header(ui: &mut Ui, text: &str) { + let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::fade(theme::INK, 0.5)); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + ui.painter().galley(pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK); +} + +/// What a [`menu_row`] shows at its left. +pub enum RowLead<'a> { + None, + Icon(Icon), + Aspect(Option<(f32, f32)>), + /// A 36 px tile with a model maker's logo (white texture, tinted). + Logo(&'a egui::TextureHandle), +} + +/// One option in a popover: a lead, a title, an optional muted subtitle, and a check when +/// `selected` (or a chevron when `submenu`). Highlights on hover or when `highlighted`. +pub fn menu_row(ui: &mut Ui, lead: RowLead<'_>, title: &str, subtitle: Option<&str>, selected: bool, submenu: bool, highlighted: bool) -> Response { + let enabled = ui.is_enabled(); + let h = if subtitle.is_some() { 48.0 } else { 34.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + let on = (resp.hovered() && enabled) || highlighted; + let p = ui.painter(); + if on { + p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER); + } + let ink = if enabled { theme::INK } else { theme::FAINT }; + let mut x = rect.left() + 8.0; + match lead { + RowLead::None => {}, + RowLead::Icon(icon) => { + icons::paint(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(15.0, 15.0)), icon, ink); + x += 26.0; + }, + RowLead::Aspect(ratio) => { + icons::paint_aspect(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(16.0, 16.0)), ratio, ink); + x += 26.0; + }, + RowLead::Logo(texture) => { + let tile = Rect::from_center_size(pos2(x + 18.0, rect.center().y), vec2(36.0, 36.0)); + let (fill, border) = if selected { (theme::WASH_HOVER, Color32::WHITE) } else { (theme::WASH, theme::LINE) }; + p.rect(tile, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside); + egui::Image::new(texture).tint(ink).paint_at(ui, Rect::from_center_size(tile.center(), vec2(18.0, 18.0))); + x += 46.0; + }, + } + let title_font = FontId::new(13.5, theme::medium()); + match subtitle { + Some(sub) => { + p.text(pos2(x, rect.center().y - 8.0), Align2::LEFT_CENTER, title, title_font, ink); + p.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED); + }, + None => { + p.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, title, title_font, ink); + }, + } + let trail = Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(13.0, 13.0)); + if selected { + icons::paint(p, trail, Icon::Check, theme::INK); + } else if submenu { + icons::paint(p, trail, Icon::ChevronRight, theme::DIM); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, title)); + if enabled { + resp.on_hover_cursor(egui::CursorIcon::PointingHand) + } else { + resp + } +} + +/// Paints `fg` over `bg` (for hover fills on opaque surfaces). +pub fn blend(bg: Color32, fg: Color32) -> Color32 { + let a = fg.a() as f32 / 255.0; + let mix = |b: u8, f: u8| (b as f32 * (1.0 - a) + f as f32 * a).round() as u8; + let fg = fg.to_srgba_unmultiplied(); + Color32::from_rgb(mix(bg.r(), fg[0]), mix(bg.g(), fg[1]), mix(bg.b(), fg[2])) +} + +/// A thin square progress bar (`h-1 bg-white/10`, filled in `primary-400`). +pub fn progress_bar(painter: &Painter, rect: Rect, fraction: f32) { + painter.rect_filled(rect, CornerRadius::same(2), theme::WASH_HOVER); + let fill = Rect::from_min_size(rect.min, vec2(rect.width() * fraction.clamp(0.0, 1.0), rect.height())); + painter.rect_filled(fill, CornerRadius::same(2), theme::ACCENT_400); +} + +/// A heading in the display face (Archivo 620, tracked in like the site's `tracking-[-0.03em]`). +pub fn heading(text: &str, size: f32) -> egui::RichText { + egui::RichText::new(text).size(size).family(theme::display()).color(theme::INK).extra_letter_spacing(-0.03 * size) +} + +/// A square modal on the panel surface with a dimmed backdrop. Returns whether it asked to close +/// (backdrop click or Escape). +pub fn modal(ctx: &egui::Context, id: egui::Id, width: f32, content: impl FnOnce(&mut Ui)) -> bool { + let frame = Frame::new().fill(theme::PANEL).stroke(theme::hairline()).inner_margin(Margin::same(16)).shadow(theme::shadow()); + let resp = egui::Modal::new(id).frame(frame).backdrop_color(Color32::from_black_alpha(153)).show(ctx, |ui| { + ui.set_width(width.min(ctx.content_rect().width() - 48.0)); + content(ui); + }); + resp.should_close() +} + +/// A mono link with an ↗: muted, brighter on hover. Opens `url` in the browser. +pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response { + let galley = caps(ui, &format!("{text} \u{2197}"), theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::MUTED); + let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click()); + let color = if resp.hovered() { theme::INK_STRONG } else { theme::MUTED }; + ui.painter().galley_with_override_text_color(rect.min, galley, color); + let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } + resp +}