From 2cec30df31f7880830ef4188073d80af4d21c057 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Sun, 29 Sep 2024 19:57:27 -0700 Subject: [PATCH 1/2] MessageBox + App::execute Refs #131 --- Cargo.lock | 801 +++++++++++++++++++++++++++++++++++++---- Cargo.toml | 4 +- examples/messagebox.rs | 46 +++ src/app.rs | 23 +- src/debug.rs | 2 +- src/dialog.rs | 325 +++++++++++++++++ src/dialog/native.rs | 175 +++++++++ src/lib.rs | 1 + src/window.rs | 97 ++--- 9 files changed, 1362 insertions(+), 112 deletions(-) create mode 100644 examples/messagebox.rs create mode 100644 src/dialog.rs create mode 100644 src/dialog/native.rs diff --git a/Cargo.lock b/Cargo.lock index 5414f0b53..0f6383f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "appit" version = "0.4.0" -source = "git+https://github.com/khonsulabs/appit#b95df0cc5403e148bed15f95935f294be89afd86" +source = "git+https://github.com/khonsulabs/appit#662cebf193a9adc1b2f19cf048fb8a359f74059a" dependencies = [ "darkmode", "winit", @@ -171,7 +171,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -201,6 +201,182 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe7e0dd0ac5a401dc116ed9f9119cf9decc625600474cb41f0fc0a0050abc9a" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -218,7 +394,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -234,14 +410,14 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "av1-grain" @@ -326,6 +502,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -335,6 +520,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "built" version = "0.7.4" @@ -370,7 +568,7 @@ checksum = "0cc8b54b395f2fcfbb3d90c47b01c7f444d94d05bdeb775811dec868ac3bbc26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -419,9 +617,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.21" +version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "9540e661f81799159abee814118cc139a2004b3a3aa3ea37724a1b66530b90e0" dependencies = [ "jobserver", "libc", @@ -618,6 +816,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -658,6 +865,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.1.0" @@ -686,6 +903,7 @@ dependencies = [ "png", "pollster", "rand", + "rfd", "serde", "tokio", "tracing", @@ -705,7 +923,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -747,7 +965,17 @@ checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -804,6 +1032,33 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -845,6 +1100,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.72.0" @@ -867,11 +1143,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + [[package]] name = "fdeflate" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" +checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" dependencies = [ "simd-adler32", ] @@ -892,9 +1174,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide 0.8.0", @@ -965,7 +1247,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -974,6 +1256,99 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "0.4.3" @@ -1149,12 +1524,28 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "image" version = "0.25.2" @@ -1236,7 +1627,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1332,7 +1723,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.11.0" -source = "git+https://github.com/khonsulabs/kludgine#e3a51aeaa60ba0d262a7ffe35ccb01fbbc3f5f82" +source = "git+https://github.com/khonsulabs/kludgine#65065944782a3b1a646ed1c346d68cc0beeb642c" dependencies = [ "ahash", "alot", @@ -1369,9 +1760,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libdbus-sys" @@ -1513,7 +1904,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1560,6 +1951,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.29.0" @@ -1588,7 +1988,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", - "simd-adler32", ] [[package]] @@ -1598,6 +1997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1666,6 +2066,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -1716,7 +2129,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1767,7 +2180,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -1993,9 +2406,12 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" +dependencies = [ + "portable-atomic", +] [[package]] name = "orbclient" @@ -2006,6 +2422,16 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "overload" version = "0.1.1" @@ -2042,9 +2468,15 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -2063,7 +2495,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.4", + "redox_syscall 0.5.6", "smallvec", "windows-targets 0.52.6", ] @@ -2110,7 +2542,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2139,7 +2571,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2148,11 +2580,28 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "plotters" @@ -2174,15 +2623,15 @@ checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" [[package]] name = "png" -version = "0.17.13" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" dependencies = [ "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide 0.7.4", + "miniz_oxide 0.8.0", ] [[package]] @@ -2206,6 +2655,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2228,7 +2683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" dependencies = [ "proc-macro2", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2276,7 +2731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" dependencies = [ "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2331,7 +2786,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2472,23 +2927,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "355ae415ccd3a04315d3f8246e86d67689ea74d88d915576e1589a351062a13b" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "regex" -version = "1.10.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] @@ -2502,13 +2957,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -2519,9 +2974,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "renderdoc-sys" @@ -2529,6 +2984,28 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af382a047821a08aa6bfc09ab0d80ff48d45d8726f7cd8e44891f7cb4a4278e" +dependencies = [ + "ashpd", + "block2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rgb" version = "0.8.50" @@ -2643,18 +3120,40 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", +] + +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", ] [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2670,6 +3169,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -2825,9 +3333,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.77" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -2862,6 +3370,19 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2888,7 +3409,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -2985,9 +3506,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.21" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", @@ -3015,7 +3536,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3075,6 +3596,23 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -3105,6 +3643,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.2" @@ -3135,6 +3682,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "v_frame" version = "0.3.8" @@ -3202,7 +3767,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-shared", ] @@ -3236,7 +3801,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3812,9 +4377,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] @@ -3857,6 +4422,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -3888,6 +4463,68 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.79", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zeno" version = "0.2.3" @@ -3912,7 +4549,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.79", ] [[package]] @@ -3944,3 +4581,41 @@ checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.79", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] diff --git a/Cargo.toml b/Cargo.toml index c7849e9aa..eeca8714a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,14 @@ readme = "./README.md" rust-version = "1.80.0" [features] -default = ["tracing-output", "roboto-flex"] +default = ["tracing-output", "roboto-flex", "native-dialogs"] tracing-output = ["dep:tracing-subscriber"] roboto-flex = [] plotters = ["dep:plotters", "kludgine/plotters"] tokio = ["dep:tokio"] tokio-multi-thread = ["tokio", "tokio/rt-multi-thread"] serde = ["dep:serde", "figures/serde"] +native-dialogs = ["dep:rfd"] [dependencies] kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [ @@ -33,6 +34,7 @@ kempt = "0.2.1" intentional = "0.1.0" tracing = "0.1.40" tokio = { version = "1.40.0", optional = true, features = ["rt"] } +rfd = { version = "0.15.0", optional = true } tracing-subscriber = { version = "0.3", optional = true, features = [ "env-filter", diff --git a/examples/messagebox.rs b/examples/messagebox.rs new file mode 100644 index 000000000..583aea938 --- /dev/null +++ b/examples/messagebox.rs @@ -0,0 +1,46 @@ +use cushy::dialog::MessageBox; +use cushy::widget::MakeWidget; +use cushy::widgets::layers::Modal; +use cushy::window::PendingWindow; +use cushy::{App, Open}; + +#[cushy::main] +fn main(app: &mut App) -> cushy::Result { + let modal = Modal::new(); + let pending = PendingWindow::default(); + let window = pending.handle(); + + pending + .with_root( + "Show in Modal layer" + .into_button() + .on_click({ + let modal = modal.clone(); + move |_| { + example_message().open(&modal); + } + }) + .and("Show above window".into_button().on_click({ + move |_| { + example_message().open(&window); + } + })) + .and("Show in app".into_button().on_click({ + let app = app.clone(); + move |_| { + example_message().open(&app); + } + })) + .into_rows() + .centered() + .expand() + .and(modal) + .into_layers(), + ) + .open(app)?; + Ok(()) +} + +fn example_message() -> MessageBox { + MessageBox::message("This is a dialog").with_explanation("This is some explanation text") +} diff --git a/src/app.rs b/src/app.rs index 8ef018661..32be2b192 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use std::thread; use arboard::Clipboard; use kludgine::app::winit::error::EventLoopError; -use kludgine::app::{AppEvent, AsApplication, Monitors}; +use kludgine::app::{AppEvent, AsApplication, ExecutingApp, Monitors}; use parking_lot::{Mutex, MutexGuard}; use crate::fonts::FontCollection; @@ -392,6 +392,13 @@ pub struct App { } impl App { + pub(crate) fn standalone() -> Self { + Self { + app: None, + cushy: Cushy::default(), + } + } + /// Returns a snapshot of information about the monitors connected to this /// device. /// @@ -414,6 +421,20 @@ impl App { .as_ref() .and_then(kludgine::app::App::prevent_shutdown) } + + /// Executes `callback` on the main event loop thread. + /// + /// Returns true if the callback was able to be sent to be executed. The app + /// may still terminate before the callback is executed regardless of the + /// result of this function. The only way to know with certainty that + /// `callback` is executed is to have `callback` notify the caller of its + /// completion. + pub fn execute(&self, callback: Callback) -> bool + where + Callback: FnOnce(&ExecutingApp<'_, WindowCommand>) + Send + 'static, + { + self.app.as_ref().map_or(false, |app| app.execute(callback)) + } } /// A guard preventing an [`App`] from shutting down. diff --git a/src/debug.rs b/src/debug.rs index 1560f59d0..7f263ef04 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -133,7 +133,7 @@ impl Drop for DebugContext { } } -trait Observable: Send { +trait Observable: Send + Sync { fn label(&self) -> &str; // fn alive(&self) -> bool; fn widget(&self) -> &WidgetInstance; diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 000000000..f54eb9934 --- /dev/null +++ b/src/dialog.rs @@ -0,0 +1,325 @@ +//! Modal dialogs such as message boxes and file pickers. + +use std::marker::PhantomData; + +use crate::widget::{MakeWidget, SharedCallback}; +use crate::widgets::layers::Modal; + +#[cfg(feature = "native-dialogs")] +mod native; + +#[derive(Clone, Debug)] +struct MessageButtons { + kind: MessageButtonsKind, + affirmative: MessageButton, + negative: Option, + cancel: Option, +} + +#[derive(Clone, Debug, Copy)] +enum MessageButtonsKind { + YesNo, + OkCancel, +} + +/// A button in a [`MessageBox`]. +/// +/// This type implements [`From`] for several types: +/// +/// - `String`, `&str`: A button with the string's contents as the caption that +/// dismisses the message box. +/// - `FnMut()` implementors: A button with the default caption given its +/// context that invokes the closure when chosen. +/// +/// To create a button with a custom caption that invokes a closure when chosen, +/// use [`MessageButton::custom`]. +#[derive(Clone, Debug, Default)] +pub struct MessageButton { + callback: OptionalCallback, + caption: String, +} + +impl MessageButton { + /// Returns a button with a custom caption that invokes `on_click` when + /// selected. + pub fn custom(caption: impl Into, mut on_click: F) -> Self + where + F: FnMut() + Send + 'static, + { + Self { + callback: OptionalCallback(Some(SharedCallback::new(move |()| on_click()))), + caption: caption.into(), + } + } +} + +impl From for MessageButton { + fn from(value: String) -> Self { + Self { + callback: OptionalCallback::default(), + caption: value, + } + } +} + +impl From<&'_ String> for MessageButton { + fn from(value: &'_ String) -> Self { + Self::from(value.clone()) + } +} + +impl From<&'_ str> for MessageButton { + fn from(value: &'_ str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for MessageButton +where + F: FnMut() + Send + 'static, +{ + fn from(mut value: F) -> Self { + Self { + callback: OptionalCallback(Some(SharedCallback::new(move |()| value()))), + caption: String::new(), + } + } +} + +#[derive(Clone, Debug, Default)] +struct OptionalCallback(Option); + +impl OptionalCallback { + fn invoke(&self) { + if let Some(callback) = &self.0 { + callback.invoke(()); + } + } +} + +#[derive(Default, Clone, Eq, PartialEq, Copy, Debug)] +enum MessageLevel { + Error, + Warning, + #[default] + Info, +} + +/// A marker indicating a [`MessageBoxBuilder`] does not have a preference +/// between a yes/no/cancel or an ok/cancel configuration. +pub enum Undecided {} + +/// Specializes a [`MessageBoxBuilder`] for an Ok/Cancel dialog. +pub enum OkCancel {} + +/// Specializes a [`MessageBoxBuilder`] for a Yes/No dialog. +pub enum YesNoCancel {} + +/// A builder for a [`MessageBox`]. +#[must_use] +pub struct MessageBoxBuilder(MessageBox, PhantomData); + +impl MessageBoxBuilder { + fn new(message: MessageBox) -> MessageBoxBuilder { + Self(message, PhantomData) + } + + /// Sets the explanation text and returns self. + pub fn with_explanation(mut self, explanation: impl Into) -> Self { + self.0.description = explanation.into(); + self + } + + /// Displays this message as a warning. + /// + /// When using native dialogs, not all platforms support this stylization. + pub fn warning(mut self) -> Self { + self.0.level = MessageLevel::Warning; + self + } + + /// Displays this message as an error. + /// + /// When using native dialogs, not all platforms support this stylization. + pub fn error(mut self) -> Self { + self.0.level = MessageLevel::Error; + self + } + + /// Adds a cancel button and returns self. + pub fn with_cancel(mut self, cancel: impl Into) -> Self { + self.0.buttons.cancel = Some(cancel.into()); + self + } + + /// Returns the completed message box. + #[must_use] + pub fn finish(self) -> MessageBox { + self.0 + } +} + +impl MessageBoxBuilder { + /// Sets the yes button and returns self. + pub fn with_yes( + Self(mut message, _): Self, + yes: impl Into, + ) -> MessageBoxBuilder { + message.buttons.kind = MessageButtonsKind::YesNo; + message.buttons.affirmative = yes.into(); + MessageBoxBuilder(message, PhantomData) + } + + /// Sets the ok button and returns self. + pub fn with_ok( + Self(mut message, _): Self, + ok: impl Into, + ) -> MessageBoxBuilder { + message.buttons.affirmative = ok.into(); + MessageBoxBuilder(message, PhantomData) + } +} + +impl MessageBoxBuilder { + /// Sets the no button and returns self. + pub fn with_no(mut self, no: impl Into) -> Self { + self.0.buttons.negative = Some(no.into()); + self + } +} + +impl MessageBoxBuilder {} + +/// A dialog that displays a message. +#[derive(Debug, Clone)] +pub struct MessageBox { + level: MessageLevel, + title: String, + description: String, + buttons: MessageButtons, +} + +impl MessageBox { + fn new(title: String, kind: MessageButtonsKind) -> Self { + Self { + level: MessageLevel::default(), + title, + description: String::default(), + buttons: MessageButtons { + kind, + affirmative: MessageButton::default(), + negative: None, + cancel: None, + }, + } + } + + /// Returns a builder for a dialog displaying `message`. + pub fn build(message: impl Into) -> MessageBoxBuilder { + MessageBoxBuilder::new(Self::new(message.into(), MessageButtonsKind::OkCancel)) + } + + /// Returns a dialog displaying `message` with an `OK` button that dismisses + /// the dialog. + #[must_use] + pub fn message(message: impl Into) -> Self { + Self::build(message).finish() + } + + /// Sets the explanation text and returns self. + #[must_use] + pub fn with_explanation(mut self, explanation: impl Into) -> Self { + self.description = explanation.into(); + self + } + + /// Displays this message as a warning. + /// + /// When using native dialogs, not all platforms support this stylization. + #[must_use] + pub fn warning(mut self) -> Self { + self.level = MessageLevel::Warning; + self + } + + /// Displays this message as an error. + /// + /// When using native dialogs, not all platforms support this stylization. + #[must_use] + pub fn error(mut self) -> Self { + self.level = MessageLevel::Error; + self + } + + /// Adds a cancel button and returns self. + #[must_use] + pub fn with_cancel(mut self, cancel: impl Into) -> Self { + self.buttons.cancel = Some(cancel.into()); + self + } + + /// Opens this dialog in the given target. + /// + /// A target can be a [`Modal`] layer, a [`WindowHandle`], or an [`App`]. + pub fn open(&self, open_in: &impl OpenMessageBox) { + open_in.open_message_box(self); + } +} + +/// A type that can open a [`MessageBox`] as a modal dialog. +pub trait OpenMessageBox { + /// Opens `message` as a modal dialog. + fn open_message_box(&self, message: &MessageBox); +} + +fn coalesce_empty<'a>(s1: &'a str, s2: &'a str) -> &'a str { + if s1.is_empty() { + s2 + } else { + s1 + } +} + +impl OpenMessageBox for Modal { + fn open_message_box(&self, message: &MessageBox) { + let dialog = self.build_dialog( + message + .title + .as_str() + .h5() + .and(message.description.as_str()) + .into_rows(), + ); + let (default_affirmative, default_negative) = match &message.buttons.kind { + MessageButtonsKind::OkCancel => ("OK", None), + MessageButtonsKind::YesNo => ("Yes", Some("No")), + }; + let on_ok = message.buttons.affirmative.callback.clone(); + let mut dialog = dialog.with_default_button( + coalesce_empty(&message.buttons.affirmative.caption, default_affirmative), + move || on_ok.invoke(), + ); + if let (Some(negative), Some(default_negative)) = + (&message.buttons.negative, default_negative) + { + let on_negative = negative.callback.clone(); + dialog = dialog.with_button( + coalesce_empty(&negative.caption, default_negative), + move || { + on_negative.invoke(); + }, + ); + } + + if let Some(cancel) = &message.buttons.cancel { + let on_cancel = cancel.callback.clone(); + dialog + .with_cancel_button(coalesce_empty(&cancel.caption, "Cancel"), move || { + on_cancel.invoke(); + }) + .show(); + } else { + dialog.show(); + } + } +} diff --git a/src/dialog/native.rs b/src/dialog/native.rs new file mode 100644 index 000000000..5f2b2becf --- /dev/null +++ b/src/dialog/native.rs @@ -0,0 +1,175 @@ +use std::thread; + +use rfd::{MessageDialog, MessageDialogResult}; + +use super::{ + coalesce_empty, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, OpenMessageBox, +}; +use crate::window::WindowHandle; +use crate::App; + +impl MessageButtons { + fn as_rfd_buttons(&self) -> rfd::MessageButtons { + let cancel_is_custom = self + .cancel + .as_ref() + .map_or(false, |b| !b.caption.is_empty()); + match self.kind { + MessageButtonsKind::YesNo => { + let negative = self.negative.as_ref().expect("no button"); + if cancel_is_custom + || !self.affirmative.caption.is_empty() + || !negative.caption.is_empty() + { + if let Some(cancel) = &self.cancel { + rfd::MessageButtons::YesNoCancelCustom( + coalesce_empty(&self.affirmative.caption, "Yes").to_string(), + coalesce_empty(&negative.caption, "No").to_string(), + coalesce_empty(&cancel.caption, "Yes").to_string(), + ) + } else { + rfd::MessageButtons::OkCancelCustom( + coalesce_empty(&self.affirmative.caption, "Yes").to_string(), + coalesce_empty(&negative.caption, "No").to_string(), + ) + } + } else if self.cancel.is_some() { + rfd::MessageButtons::YesNoCancel + } else { + rfd::MessageButtons::YesNo + } + } + MessageButtonsKind::OkCancel => { + if let Some(cancel) = &self.cancel { + if !self.affirmative.caption.is_empty() || !cancel.caption.is_empty() { + rfd::MessageButtons::OkCancelCustom( + coalesce_empty(&self.affirmative.caption, "OK").to_string(), + coalesce_empty(&cancel.caption, "Cancel").to_string(), + ) + } else { + rfd::MessageButtons::OkCancel + } + } else if !self.affirmative.caption.is_empty() { + rfd::MessageButtons::OkCustom(self.affirmative.caption.clone()) + } else { + rfd::MessageButtons::Ok + } + } + } + } +} + +impl From for rfd::MessageLevel { + fn from(value: MessageLevel) -> Self { + match value { + MessageLevel::Error => rfd::MessageLevel::Error, + MessageLevel::Warning => rfd::MessageLevel::Warning, + MessageLevel::Info => rfd::MessageLevel::Info, + } + } +} + +impl OpenMessageBox for WindowHandle { + fn open_message_box(&self, message: &MessageBox) { + let message = message.clone(); + self.execute(move |context| { + // Get access to the winit handle from the window thread. + let winit = context.winit().cloned(); + // We can't utilize the window handle outside of the main thread + // with winit, so we now need to move execution to the event loop + // thread. + let Some(app) = context.app().cloned() else { + return; + }; + app.execute(move |_app| { + let mut dialog = MessageDialog::new() + .set_title(message.title) + .set_buttons(message.buttons.as_rfd_buttons()) + .set_description(message.description) + .set_level(message.level.into()); + if let Some(winit) = winit { + dialog = dialog.set_parent(&winit); + } + thread::spawn(move || { + handle_message_result(&dialog.show(), &message.buttons); + }); + }); + }); + } +} + +impl OpenMessageBox for App { + fn open_message_box(&self, message: &MessageBox) { + let shutdown_guard = self.prevent_shutdown(); + let message = message.clone(); + self.execute(move |_app| { + let dialog = MessageDialog::new() + .set_title(message.title) + .set_buttons(message.buttons.as_rfd_buttons()) + .set_description(message.description) + .set_level(message.level.into()); + thread::spawn(move || { + handle_message_result(&dialog.show(), &message.buttons); + drop(shutdown_guard); + }); + }); + } +} + +fn handle_message_result(result: &MessageDialogResult, buttons: &MessageButtons) { + match result { + MessageDialogResult::Ok | MessageDialogResult::Yes => { + buttons.affirmative.callback.invoke(); + } + MessageDialogResult::No => { + buttons + .negative + .as_ref() + .expect("no button") + .callback + .invoke(); + } + MessageDialogResult::Cancel => { + if matches!(buttons.kind, MessageButtonsKind::YesNo) && buttons.cancel.is_none() { + // Cancel means No in this situation. + buttons + .negative + .as_ref() + .expect("no button") + .callback + .invoke(); + } else { + buttons + .cancel + .as_ref() + .expect("cancel button") + .callback + .invoke(); + } + } + MessageDialogResult::Custom(caption) => { + let (default_affirmative, default_negative) = match buttons.kind { + MessageButtonsKind::YesNo => ("Yes", Some("No")), + MessageButtonsKind::OkCancel => ("OK", None), + }; + + if coalesce_empty(&buttons.affirmative.caption, default_affirmative) == caption { + buttons.affirmative.callback.invoke(); + } else if let Some(negative) = buttons.negative.as_ref().filter(|negative| { + &negative.caption == caption + || default_negative + .map_or(false, |default_negative| default_negative == caption) + }) { + negative.callback.invoke(); + } else if let Some(cancel) = buttons + .cancel + .as_ref() + .filter(|cancel| coalesce_empty(&cancel.caption, "Cancel") == caption) + { + cancel.callback.invoke(); + } else { + unreachable!("no matching button") + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b86c7bc95..e8711c88f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ pub mod widget; pub mod widgets; pub mod window; +pub mod dialog; #[doc(hidden)] pub mod example; use std::ops::{Add, AddAssign, Sub, SubAssign}; diff --git a/src/window.rs b/src/window.rs index 76a391449..e6ac469fc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -73,7 +73,7 @@ pub trait PlatformWindowImplementation { /// Marks the window to close as soon as possible. fn close(&mut self); /// Returns the underlying `winit` window, if one exists. - fn winit(&self) -> Option<&winit::window::Window>; + fn winit(&self) -> Option<&Arc>; /// Sets the window to redraw as soon as possible. fn set_needs_redraw(&mut self); /// Sets the window to redraw after a `duration`. @@ -114,8 +114,7 @@ pub trait PlatformWindowImplementation { /// [`winit::window::Window::is_resizable`], or true if this window has no /// winit window. fn is_resizable(&self) -> bool { - self.winit() - .map_or(true, winit::window::Window::is_resizable) + self.winit().map_or(true, |win| win.is_resizable()) } /// Returns true if the window can have its size changed. @@ -124,7 +123,7 @@ pub trait PlatformWindowImplementation { /// dark if this window has no winit window. fn theme(&self) -> winit::window::Theme { self.winit() - .and_then(winit::window::Window::theme) + .and_then(|win| win.theme()) .unwrap_or(winit::window::Theme::Dark) } @@ -208,7 +207,7 @@ impl PlatformWindowImplementation for kludgine::app::Window<'_, WindowCommand> { self.close(); } - fn winit(&self) -> Option<&winit::window::Window> { + fn winit(&self) -> Option<&Arc> { Some(self.winit()) } @@ -260,6 +259,8 @@ pub trait PlatformWindow { fn outer_size(&self) -> Size; /// Returns the shared application resources. fn cushy(&self) -> &Cushy; + /// Returns the app managing this window's event loop. + fn app(&self) -> Option<&App>; /// Sets the window to redraw as soon as possible. fn set_needs_redraw(&mut self); /// Sets the window to redraw after a `duration`. @@ -289,7 +290,7 @@ pub trait PlatformWindow { fn set_max_inner_size(&self, max_size: Option>); /// Returns a handle to the underlying winit window, if available. - fn winit(&self) -> Option<&winit::window::Window>; + fn winit(&self) -> Option<&Arc>; } /// A currently running Cushy window. @@ -297,7 +298,7 @@ pub struct RunningWindow { window: W, kludgine_id: KludgineId, invalidation_status: InvalidationStatus, - cushy: Cushy, + app: App, focused: Dynamic, occluded: Dynamic, inner_size: Dynamic>, @@ -313,7 +314,7 @@ where window: W, kludgine_id: KludgineId, invalidation_status: &InvalidationStatus, - cushy: &Cushy, + app: &App, focused: &Dynamic, occluded: &Dynamic, inner_size: &Dynamic>, @@ -323,7 +324,7 @@ where window, kludgine_id, invalidation_status: invalidation_status.clone(), - cushy: cushy.clone(), + app: app.clone(), focused: focused.clone(), occluded: occluded.clone(), inner_size: inner_size.clone(), @@ -382,7 +383,7 @@ where /// initialized when the window opened. #[must_use] pub fn clipboard_guard(&self) -> Option> { - self.cushy.clipboard_guard() + self.app.cushy().clipboard_guard() } } @@ -422,6 +423,10 @@ where self.kludgine_id } + fn app(&self) -> Option<&App> { + Some(&self.app) + } + fn focused(&self) -> &Dynamic { &self.focused } @@ -439,7 +444,7 @@ where } fn cushy(&self) -> &Cushy { - &self.cushy + self.app.cushy() } fn set_needs_redraw(&mut self) { @@ -490,7 +495,7 @@ where self.window.set_ime_location(location); } - fn winit(&self) -> Option<&winit::window::Window> { + fn winit(&self) -> Option<&Arc> { self.window.winit() } } @@ -1121,14 +1126,14 @@ where App: Application + ?Sized, { let this = self.make_window(); - let cushy = app.cushy().clone(); + let app_app = app.as_app(); let handle = this.pending.handle(); OpenWindow::::open_with( app, sealed::Context { user: this.context, settings: RefCell::new(sealed::WindowSettings { - cushy, + app: app_app, title: this.title, redraw_status: this.pending.0.redraw_status.clone(), on_open: this.on_open, @@ -1348,7 +1353,7 @@ struct OpenWindow { theme_mode: Value, transparent: bool, fonts: FontState, - cushy: Cushy, + app: App, on_closed: Option, vsync: bool, dpi_scale: Dynamic, @@ -1729,10 +1734,10 @@ where .persist(); } - let cushy = settings.cushy.clone(); + let app = settings.app.clone(); let fonts = Self::load_fonts( &mut settings, - cushy.fonts.clone(), + app.cushy().fonts.clone(), graphics.font_system().db_mut(), ); @@ -1788,7 +1793,7 @@ where theme_mode, transparent: settings.transparent, fonts, - cushy, + app, on_closed: settings.on_closed, vsync: settings.vsync, close_requested: settings.close_requested, @@ -1847,7 +1852,7 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); self.synchronize_platform_window(&mut window); @@ -1859,7 +1864,7 @@ where window, graphics.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -1961,13 +1966,13 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); if self.behavior.close_requested(&mut RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2108,13 +2113,13 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2167,13 +2172,13 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2211,13 +2216,13 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2256,13 +2261,13 @@ where ) where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2315,7 +2320,7 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); self.cursor.location = None; self.cursor_position @@ -2325,7 +2330,7 @@ where window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2358,13 +2363,13 @@ where where W: PlatformWindowImplementation, { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2500,13 +2505,13 @@ where ) -> Self { context.pending.opened(window.handle()); let settings = context.settings.borrow(); - let cushy = settings.cushy.clone(); + let cushy = settings.app.cushy().clone(); let _guard = cushy.enter_runtime(); let mut window = RunningWindow::new( window, graphics.id(), &settings.redraw_status, - &settings.cushy, + &settings.app, &settings.focused, &settings.occluded, &settings.inner_size, @@ -2528,7 +2533,7 @@ where window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, ) { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); self.focused.set(window.focused()); self.occluded.set(window.occluded()); @@ -2539,7 +2544,7 @@ where window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2618,7 +2623,7 @@ where window: kludgine::app::Window<'_, WindowCommand>, kludgine: &mut Kludgine, ) -> bool { - let cushy = self.cushy.clone(); + let cushy = self.app.cushy().clone(); let _guard = cushy.enter_runtime(); Self::request_close( &mut self.should_close, @@ -2627,7 +2632,7 @@ where window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2815,7 +2820,7 @@ where window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2859,7 +2864,7 @@ where window, kludgine.id(), &self.redraw_status, - &self.cushy, + &self.app, &self.focused, &self.occluded, self.inner_size.source(), @@ -2959,7 +2964,6 @@ pub(crate) mod sealed { use kludgine::app::winit::window::{Fullscreen, UserAttentionType, WindowButtons, WindowLevel}; use kludgine::Color; - use crate::app::Cushy; use crate::context::sealed::InvalidationStatus; use crate::context::EventContext; use crate::fonts::FontCollection; @@ -2968,6 +2972,7 @@ pub(crate) mod sealed { use crate::widget::{Callback, OnceCallback, SharedCallback}; use crate::widgets::shortcuts::ShortcutMap; use crate::window::{FileDrop, PendingWindow, ThemeMode, WindowAttributes, WindowHandle}; + use crate::App; pub struct Context { pub user: C, @@ -2976,7 +2981,7 @@ pub(crate) mod sealed { } pub struct WindowSettings { - pub cushy: Cushy, + pub app: App, pub redraw_status: InvalidationStatus, pub title: Value, pub attributes: Option, @@ -3516,7 +3521,7 @@ impl PlatformWindowImplementation for &mut VirtualState { self.closed = true; } - fn winit(&self) -> Option<&winit::window::Window> { + fn winit(&self) -> Option<&Arc> { None } @@ -3659,7 +3664,7 @@ impl StandaloneWindowBuilder { window, &mut kludgine::Graphics::new(&mut kludgine, device, queue), sealed::WindowSettings { - cushy: Cushy::default(), + app: App::standalone(), redraw_status: InvalidationStatus::default(), title: Value::default(), attributes: None, From a7972309c3cac87f6cdf907594a46c99f26280b5 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Thu, 3 Oct 2024 13:39:46 -0700 Subject: [PATCH 2/2] File picker --- CHANGELOG.md | 24 + examples/file-picker.rs | 150 +++++ examples/{messagebox.rs => message-box.rs} | 0 src/app.rs | 22 + src/debug.rs | 2 +- src/dialog.rs | 623 ++++++++++++++++++++- src/dialog/native.rs | 153 ++++- src/styles.rs | 6 + src/widgets/button.rs | 85 ++- src/widgets/label.rs | 19 +- src/widgets/stack.rs | 17 +- 11 files changed, 1084 insertions(+), 17 deletions(-) create mode 100644 examples/file-picker.rs rename examples/{messagebox.rs => message-box.rs} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a1095cc..e98da800f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 widget. - A rare deadlock occurring when multiple threads were racing to execute `Dynamic` change callbacks has been fixed. +- `Stack` no longer unwraps a `Resize` child if the resize widget is resizing in + the direction opposite of the Stack's orientation. ### Added @@ -187,6 +189,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `impl FnMut(Duration) -> ControlFlow + Send + Sync + 'static` - `SharedCallback>` - `SharedCallback` +- `Cushy::multi_click_threshold`/`Cushy::set_multi_click_threshold` provide + access to the setting used by Cushy widgets to detect whether two clicks are + related. +- `ClickCounter` is a new helper that simplifies handling actions based on how + many sequential clicks were observed. +- `Dimension::is_unbounded` is a new helper that returns true if neither the + start or end is bounded. +- `&String` and `Cow<'_, str>` now implement `MakeWidget`. +- `MessageBox` displays a prompt to the user in a `Modal` layer, above a + `WindowHandle`, or in an `App`. When shown above a window or app, the `rfd` + crate is used to use the native system dialogs. +- `FilePicker` displays a file picker to the user in a `Modal` layer, above a + `WindowHandle`, or in an `App`. When shown above a window or app, the `rfd` + crate is used to use the native system dialogs. + + The `FilePicker` type supports these modes of operation: + + - Saving a file + - Choosing a single file + - Choosing one or more files + - Choosing a single folder/directory + - Choosing one or more folders/directories [139]: https://github.com/khonsulabs/cushy/issues/139 diff --git a/examples/file-picker.rs b/examples/file-picker.rs new file mode 100644 index 000000000..87e35cf1a --- /dev/null +++ b/examples/file-picker.rs @@ -0,0 +1,150 @@ +use std::path::PathBuf; + +use cushy::dialog::{FilePicker, PickFile}; +use cushy::value::{Destination, Dynamic, Source}; +use cushy::widget::{MakeWidget, MakeWidgetList}; +use cushy::widgets::button::ButtonClick; +use cushy::widgets::checkbox::Checkable; +use cushy::widgets::layers::Modal; +use cushy::window::{PendingWindow, WindowHandle}; +use cushy::{App, Open}; + +#[cushy::main] +fn main(app: &mut App) -> cushy::Result { + let modal = Modal::new(); + let pending = PendingWindow::default(); + let window = pending.handle(); + let chosen_paths = Dynamic::>::default(); + let picker_mode = Dynamic::default(); + let pick_multiple = Dynamic::new(false); + let results = chosen_paths.map_each(|paths| { + if paths.is_empty() { + "None".make_widget() + } else { + paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .into_rows() + .make_widget() + } + }); + + pending + .with_root( + picker_mode + .new_radio(PickerMode::SaveFile, "Save File") + .and(picker_mode.new_radio(PickerMode::PickFile, "Pick File")) + .and(picker_mode.new_radio(PickerMode::PickFolder, "Pick Folder")) + .into_columns() + .and(pick_multiple.to_checkbox("Select Multiple").with_enabled( + picker_mode.map_each(|kind| !matches!(kind, PickerMode::SaveFile)), + )) + .and(picker_buttons( + &picker_mode, + &pick_multiple, + app, + &window, + &modal, + &chosen_paths, + )) + .and("Result:") + .and(results) + .into_rows() + .centered() + .vertical_scroll() + .expand() + .and(modal) + .into_layers(), + ) + .open(app)?; + Ok(()) +} + +#[derive(Default, Clone, Copy, Eq, PartialEq, Debug)] +enum PickerMode { + #[default] + SaveFile, + PickFile, + PickFolder, +} + +fn file_picker() -> FilePicker { + FilePicker::new() + .with_title("Pick a Rust source file") + .with_types([("Rust Source", ["rs"])]) +} + +fn display_single_result( + chosen_paths: &Dynamic>, +) -> impl FnMut(Option) + Send + 'static { + let chosen_paths = chosen_paths.clone(); + move |path| { + chosen_paths.set(path.into_iter().collect()); + } +} + +fn display_multiple_results( + chosen_paths: &Dynamic>, +) -> impl FnMut(Option>) + Send + 'static { + let chosen_paths = chosen_paths.clone(); + move |path| { + chosen_paths.set(path.into_iter().flatten().collect()); + } +} + +fn picker_buttons( + mode: &Dynamic, + pick_multiple: &Dynamic, + app: &App, + window: &WindowHandle, + modal: &Modal, + chosen_paths: &Dynamic>, +) -> impl MakeWidget { + "Show in Modal layer" + .into_button() + .on_click(show_picker_in(modal, chosen_paths, mode, pick_multiple)) + .and("Show above window".into_button().on_click(show_picker_in( + window, + chosen_paths, + mode, + pick_multiple, + ))) + .and("Show in app".into_button().on_click(show_picker_in( + app, + chosen_paths, + mode, + pick_multiple, + ))) + .into_rows() +} + +fn show_picker_in( + target: &(impl PickFile + Clone + Send + 'static), + chosen_paths: &Dynamic>, + mode: &Dynamic, + pick_multiple: &Dynamic, +) -> impl FnMut(Option) + Send + 'static { + let target = target.clone(); + let chosen_paths = chosen_paths.clone(); + let mode = mode.clone(); + let pick_multiple = pick_multiple.clone(); + move |_| { + match mode.get() { + PickerMode::SaveFile => { + file_picker().save_file(&target, display_single_result(&chosen_paths)) + } + PickerMode::PickFile if pick_multiple.get() => { + file_picker().pick_files(&target, display_multiple_results(&chosen_paths)) + } + PickerMode::PickFile => { + file_picker().pick_file(&target, display_single_result(&chosen_paths)) + } + PickerMode::PickFolder if pick_multiple.get() => { + file_picker().pick_folders(&target, display_multiple_results(&chosen_paths)) + } + PickerMode::PickFolder => { + file_picker().pick_folder(&target, display_single_result(&chosen_paths)) + } + }; + } +} diff --git a/examples/messagebox.rs b/examples/message-box.rs similarity index 100% rename from examples/messagebox.rs rename to examples/message-box.rs diff --git a/src/app.rs b/src/app.rs index 32be2b192..33751d01f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::marker::PhantomData; use std::process::exit; use std::sync::Arc; use std::thread; +use std::time::Duration; use arboard::Clipboard; use kludgine::app::winit::error::EventLoopError; @@ -313,11 +314,16 @@ pub struct RuntimeGuard<'a>(Box + 'a>); trait BoxableGuard<'a> {} impl<'a, T> BoxableGuard<'a> for T {} +struct AppSettings { + multi_click_threshold: Duration, +} + /// Shared resources for a GUI application. #[derive(Clone)] pub struct Cushy { pub(crate) clipboard: Option>>, pub(crate) fonts: FontCollection, + settings: Arc>, runtime: BoxedRuntime, } @@ -328,10 +334,26 @@ impl Cushy { .ok() .map(|clipboard| Arc::new(Mutex::new(clipboard))), fonts: FontCollection::default(), + settings: Arc::new(Mutex::new(AppSettings { + multi_click_threshold: Duration::from_millis(500), + })), runtime, } } + /// Returns the duration between two mouse clicks that should be allowed to + /// elapse for the clicks to be considered separate actions. + #[must_use] + pub fn multi_click_threshold(&self) -> Duration { + self.settings.lock().multi_click_threshold + } + + /// Sets the maximum time between sequential clicks that should be + /// considered the same action. + pub fn set_multi_click_threshold(&self, threshold: Duration) { + self.settings.lock().multi_click_threshold = threshold; + } + /// Returns a locked mutex guard to the OS's clipboard, if one was able to be /// initialized when the window opened. #[must_use] diff --git a/src/debug.rs b/src/debug.rs index 7f263ef04..1560f59d0 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -133,7 +133,7 @@ impl Drop for DebugContext { } } -trait Observable: Send + Sync { +trait Observable: Send { fn label(&self) -> &str; // fn alive(&self) -> bool; fn widget(&self) -> &WidgetInstance; diff --git a/src/dialog.rs b/src/dialog.rs index f54eb9934..6f77626a7 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -1,9 +1,23 @@ //! Modal dialogs such as message boxes and file pickers. use std::marker::PhantomData; - -use crate::widget::{MakeWidget, SharedCallback}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use std::{env, fs}; + +use figures::units::Lp; +use parking_lot::Mutex; + +use crate::styles::components::{PrimaryColor, WidgetBackground}; +use crate::styles::DynamicComponent; +use crate::value::{Destination, Dynamic, Source}; +use crate::widget::{MakeWidget, OnceCallback, SharedCallback, WidgetList}; +use crate::widgets::button::{ButtonKind, ClickCounter}; +use crate::widgets::input::InputValue; use crate::widgets::layers::Modal; +use crate::widgets::Custom; +use crate::ModifiersExt; #[cfg(feature = "native-dialogs")] mod native; @@ -323,3 +337,608 @@ impl OpenMessageBox for Modal { } } } + +/// A dialog that can pick one or more files or directories. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct FilePicker { + types: Vec, + directory: Option, + file_name: String, + title: String, + can_create_directories: Option, +} + +impl Default for FilePicker { + fn default() -> Self { + Self::new() + } +} + +impl FilePicker { + /// Returns a new file picker dialog. + #[must_use] + pub const fn new() -> Self { + Self { + types: Vec::new(), + directory: None, + file_name: String::new(), + title: String::new(), + can_create_directories: None, + } + } + + /// Sets the title of the dialog and returns self. + #[must_use] + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Sets the initial file name for the dialog and returns self. + #[must_use] + pub fn with_file_name(mut self, file_name: impl Into) -> Self { + self.file_name = file_name.into(); + self + } + + /// Enables directory creation within the dialog and returns self. + #[must_use] + pub fn allowing_directory_creation(mut self, allowed: bool) -> Self { + self.can_create_directories = Some(allowed); + self + } + + /// Adds the list of type filters to the dialog and returns self. + /// + /// These type filters are used for the dialog to only show related files + /// and restrict what extensions are allowed to be picked. + #[must_use] + pub fn with_types(mut self, types: impl IntoIterator) -> Self + where + Type: Into, + { + self.types = types.into_iter().map(Into::into).collect(); + self + } + + /// Sets the initial directory for the dialog and returns self. + #[must_use] + pub fn with_initial_directory(mut self, directory: impl AsRef) -> Self { + self.directory = Some(directory.as_ref().to_path_buf()); + self + } + + /// Shows a picker that selects a single file and invokes `on_dismiss` when + /// the dialog is dismissed. + pub fn pick_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.pick_file(self, on_dismiss); + } + + /// Shows a picker that creates a new file and invokes `on_dismiss` when the + /// dialog is dismissed. + pub fn save_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.save_file(self, on_dismiss); + } + + /// Shows a picker that selects one or more files and invokes `on_dismiss` + /// when the dialog is dismissed. + pub fn pick_files(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + pick_in.pick_files(self, on_dismiss); + } + + /// Shows a picker that selects a single folder/directory and invokes + /// `on_dismiss` when the dialog is dismissed. + pub fn pick_folder(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + pick_in.pick_folder(self, on_dismiss); + } + + /// Shows a picker that selects one or more folders/directorys and invokes + /// `on_dismiss` when the dialog is dismissed. + pub fn pick_folders(&self, pick_in: &impl PickFile, on_dismiss: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + pick_in.pick_folders(self, on_dismiss); + } +} + +/// A file type filter used in a [`FilePicker`]. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct FileType { + name: String, + extensions: Vec, +} + +impl FileType { + /// Returns a new file type from the given name and list of file extensions. + pub fn new( + name: impl Into, + extensions: impl IntoIterator, + ) -> Self + where + Extension: Into, + { + Self { + name: name.into(), + extensions: extensions.into_iter().map(Into::into).collect(), + } + } + + /// Returns true if the given path matches this file type's extensions. + #[must_use] + pub fn matches(&self, path: &Path) -> bool { + let Some(extension) = path.extension() else { + return false; + }; + self.extensions.iter().any(|test| **test == *extension) + } +} + +impl From<(Name, [Extension; EXTENSIONS])> for FileType +where + Name: Into, + Extension: Into, +{ + fn from((name, extensions): (Name, [Extension; EXTENSIONS])) -> Self { + Self::new(name, extensions) + } +} + +/// Shows a [`FilePicker`] in a given mode. +pub trait PickFile { + /// Shows a picker that selects a single file and invokes `on_dismiss` when + /// the dialog is dismissed. + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that creates a new file and invokes `on_dismiss` when the + /// dialog is dismissed. + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that selects one or more files and invokes `on_dismiss` + /// when the dialog is dismissed. + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static; + /// Shows a picker that selects a single folder/directory and invokes + /// `on_dismiss` when the dialog is dismissed. + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static; + /// Shows a picker that selects one or more folders/directorys and invokes + /// `on_dismiss` when the dialog is dismissed. + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static; +} + +#[derive(Clone, Copy, Debug)] +enum ModeKind { + File, + SaveFile, + Files, + Folder, + Folders, +} + +impl ModeKind { + const fn is_multiple(self) -> bool { + matches!(self, ModeKind::Files | ModeKind::Folders) + } + + const fn is_file(self) -> bool { + matches!(self, ModeKind::File | ModeKind::Files | ModeKind::SaveFile) + } +} + +enum ModeCallback { + Single(OnceCallback>), + Multiple(OnceCallback>>), +} + +enum Mode { + File(OnceCallback>), + SaveFile(OnceCallback>), + Files(OnceCallback>>), + Folder(OnceCallback>), + Folders(OnceCallback>>), +} + +impl Mode { + fn file(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::File(OnceCallback::new(callback)) + } + + fn save_file(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::SaveFile(OnceCallback::new(callback)) + } + + fn files(callback: Callback) -> Self + where + Callback: FnOnce(Option>) + Send + 'static, + { + Self::Files(OnceCallback::new(callback)) + } + + fn folder(callback: Callback) -> Self + where + Callback: FnOnce(Option) + Send + 'static, + { + Self::Folder(OnceCallback::new(callback)) + } + + fn folders(callback: Callback) -> Self + where + Callback: FnOnce(Option>) + Send + 'static, + { + Self::Folders(OnceCallback::new(callback)) + } + + fn into_callback(self) -> ModeCallback { + match self { + Mode::File(once_callback) + | Mode::SaveFile(once_callback) + | Mode::Folder(once_callback) => ModeCallback::Single(once_callback), + Mode::Files(once_callback) | Mode::Folders(once_callback) => { + ModeCallback::Multiple(once_callback) + } + } + } + + fn kind(&self) -> ModeKind { + match self { + Mode::File(_) => ModeKind::File, + Mode::SaveFile(_) => ModeKind::SaveFile, + Mode::Files(_) => ModeKind::Files, + Mode::Folder(_) => ModeKind::Folder, + Mode::Folders(_) => ModeKind::Folders, + } + } +} + +impl PickFile for Modal { + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::file(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::save_file(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::files(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::folder(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + let modal = self.clone(); + self.present(FilePickerWidget { + picker: picker.clone(), + mode: Mode::folders(move |result| { + modal.dismiss(); + callback(result); + }), + }); + } +} + +struct FilePickerWidget { + picker: FilePicker, + mode: Mode, +} + +impl MakeWidget for FilePickerWidget { + #[allow(clippy::too_many_lines)] + fn make_widget(self) -> crate::widget::WidgetInstance { + let kind = self.mode.kind(); + let callback = Arc::new(Mutex::new(Some(self.mode.into_callback()))); + + let title = if self.picker.title.is_empty() { + match kind { + ModeKind::File => "Select a file", + ModeKind::SaveFile => "Save file", + ModeKind::Files => "Select one or more files", + ModeKind::Folder => "Select a folder", + ModeKind::Folders => "Select one or more folders", + } + } else { + &self.picker.title + }; + + let caption = match kind { + ModeKind::File | ModeKind::Files | ModeKind::Folder | ModeKind::Folders => "Select", + ModeKind::SaveFile => "Save", + }; + + let chosen_paths = Dynamic::>::default(); + let confirm_enabled = chosen_paths.map_each(|paths| !paths.is_empty()); + + let browsing_directory = Dynamic::new( + self.picker + .directory + .or_else(|| env::current_dir().ok()) + .or_else(|| { + env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) + }) + .unwrap_or_default(), + ); + + let current_directory_files = browsing_directory.map_each(|dir| { + let mut children = Vec::new(); + match fs::read_dir(dir) { + Ok(entries) => { + for entry in entries.filter_map(Result::ok) { + let name = entry.file_name().to_string_lossy().into_owned(); + children.push((name, entry.path())); + } + } + Err(err) => return Err(format!("Error reading directory: {err}")), + } + Ok(children) + }); + + let multi_click_threshold = Dynamic::new(Duration::from_millis(500)); + + let choose_file = SharedCallback::new({ + let chosen_paths = chosen_paths.clone(); + let callback = callback.clone(); + let types = self.picker.types.clone(); + move |()| { + let chosen_paths = chosen_paths.get(); + match callback.lock().take() { + Some(ModeCallback::Single(cb)) => { + let mut chosen_path = chosen_paths.into_iter().next(); + if let Some(chosen_path) = &mut chosen_path { + if matches!(kind, ModeKind::SaveFile) + && !types.iter().any(|t| t.matches(chosen_path)) + { + if let Some(extension) = + types.first().and_then(|ty| ty.extensions.first()) + { + let path = chosen_path.as_mut_os_string(); + path.push("."); + path.push(extension); + } + } + } + + cb.invoke(chosen_path); + } + Some(ModeCallback::Multiple(cb)) => { + cb.invoke(Some(chosen_paths)); + } + None => {} + } + } + }); + + let file_list = current_directory_files + .map_each({ + let chosen_paths = chosen_paths.clone(); + let allowed_types = self.picker.types.clone(); + let multi_click_threshold = multi_click_threshold.clone(); + let browsing_directory = browsing_directory.clone(); + let choose_file = choose_file.clone(); + move |files| match files { + Ok(files) => files + .iter() + .filter(|(name, path)| { + !name.starts_with('.') && path.is_dir() + || (kind.is_file() + && allowed_types.iter().all(|ty| ty.matches(path))) + }) + .map({ + |(name, full_path)| { + let selected = chosen_paths.map_each({ + let full_path = full_path.clone(); + move |chosen| chosen.contains(&full_path) + }); + + name.align_left() + .into_button() + .kind(ButtonKind::Transparent) + .on_click({ + let mut counter = + ClickCounter::new(multi_click_threshold.clone(), { + let browsing_directory = browsing_directory.clone(); + let choose_file = choose_file.clone(); + let full_path = full_path.clone(); + + move |click_count, _| { + if click_count == 2 { + if full_path.is_dir() { + browsing_directory + .set(full_path.clone()); + } else { + choose_file.invoke(()); + } + } + } + }) + .with_maximum(2); + + let chosen_paths = chosen_paths.clone(); + let full_path = full_path.clone(); + move |click| { + if kind.is_multiple() + && click.map_or(false, |click| { + click.modifiers.state().primary() + }) + { + let mut paths = chosen_paths.lock(); + let mut removed = false; + paths.retain(|p| { + if p == &full_path { + removed = true; + false + } else { + true + } + }); + if !removed { + paths.push(full_path.clone()); + } + } else { + let mut paths = chosen_paths.lock(); + paths.clear(); + paths.push(full_path.clone()); + } + + counter.click(click); + } + }) + .with_dynamic( + &WidgetBackground, + DynamicComponent::new(move |ctx| { + if selected.get_tracking_invalidate(ctx) { + Some(ctx.get(&PrimaryColor).into()) + } else { + None + } + }), + ) + } + }) + .collect::() + .into_rows() + .make_widget(), + Err(err) => err.make_widget(), + } + }) + .vertical_scroll() + .expand(); + + let file_ui = if matches!(kind, ModeKind::SaveFile) { + let name = Dynamic::::default(); + let name_weak = name.downgrade(); + name.set_source(chosen_paths.for_each(move |paths| { + if paths.len() == 1 && paths[0].is_file() { + if let Some(path_name) = paths[0] + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + { + if let Some(name) = name_weak.upgrade() { + name.set(path_name); + } + } + } + })); + let browsing_directory = browsing_directory.clone(); + let chosen_paths = chosen_paths.clone(); + name.for_each(move |name| { + let Ok(mut paths) = chosen_paths.try_lock() else { + return; + }; + paths.clear(); + paths.push(browsing_directory.get().join(name)); + }) + .persist(); + file_list.and(name.into_input()).into_rows().make_widget() + } else { + file_list.make_widget() + }; + + let click_duration_probe = Custom::empty().on_mounted({ + move |ctx| multi_click_threshold.set(ctx.cushy().multi_click_threshold()) + }); + + title + .and(click_duration_probe) + .into_columns() + .and(file_ui.width(Lp::inches(6)).height(Lp::inches(4))) + .and( + "Cancel" + .into_button() + .on_click({ + let mode = callback.clone(); + move |_| match mode.lock().take() { + Some(ModeCallback::Single(cb)) => cb.invoke(None), + Some(ModeCallback::Multiple(cb)) => { + cb.invoke(None); + } + None => {} + } + }) + .into_escape() + .and( + caption + .into_button() + .on_click(move |_| choose_file.invoke(())) + .into_default() + .with_enabled(confirm_enabled), + ) + .into_columns() + .align_right(), + ) + .into_rows() + .contain() + .make_widget() + } +} diff --git a/src/dialog/native.rs b/src/dialog/native.rs index 5f2b2becf..7cad19e84 100644 --- a/src/dialog/native.rs +++ b/src/dialog/native.rs @@ -1,9 +1,11 @@ +use std::path::PathBuf; use std::thread; -use rfd::{MessageDialog, MessageDialogResult}; +use rfd::{FileDialog, MessageDialog, MessageDialogResult}; use super::{ - coalesce_empty, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, OpenMessageBox, + coalesce_empty, FilePicker, MessageBox, MessageButtons, MessageButtonsKind, MessageLevel, Mode, + OpenMessageBox, PickFile, }; use crate::window::WindowHandle; use crate::App; @@ -173,3 +175,150 @@ fn handle_message_result(result: &MessageDialogResult, buttons: &MessageButtons) } } } + +fn create_file_dialog(picker: FilePicker) -> FileDialog { + let mut dialog = FileDialog::new(); + + if !picker.title.is_empty() { + dialog = dialog.set_title(picker.title); + } + + if let Some(directory) = picker.directory { + dialog = dialog.set_directory(directory); + } + + if !picker.file_name.is_empty() { + dialog = dialog.set_file_name(picker.file_name); + } + + for ty in picker.types { + dialog = dialog.add_filter(ty.name, &ty.extensions); + } + + if let Some(can_create) = picker.can_create_directories { + dialog = dialog.set_can_create_directories(can_create); + } + dialog +} + +fn show_picker_in_window(window: &WindowHandle, picker: &FilePicker, mode: Mode) { + let picker = picker.clone(); + window.execute(move |context| { + // Get access to the winit handle from the window thread. + let winit = context.winit().cloned(); + // We can't utilize the window handle outside of the main thread + // with winit, so we now need to move execution to the event loop + // thread. + let Some(app) = context.app().cloned() else { + return; + }; + app.execute(move |_app| { + let mut dialog = create_file_dialog(picker); + + if let Some(winit) = winit { + dialog = dialog.set_parent(&winit); + } + + // Now that we've set the parent, we can move this to its own + // blocking thread to be shown. + thread::spawn(move || match mode { + Mode::File(on_dismiss) => on_dismiss.invoke(dialog.pick_file()), + Mode::SaveFile(on_dismiss) => on_dismiss.invoke(dialog.save_file()), + Mode::Files(on_dismiss) => on_dismiss.invoke(dialog.pick_files()), + Mode::Folder(on_dismiss) => on_dismiss.invoke(dialog.pick_folder()), + Mode::Folders(on_dismiss) => on_dismiss.invoke(dialog.pick_folders()), + }); + }); + }); +} + +impl PickFile for WindowHandle { + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::file(callback)); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::save_file(callback)); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::files(callback)); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::folder(callback)); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_window(self, picker, Mode::folders(callback)); + } +} + +fn show_picker_in_app(app: &App, picker: &FilePicker, mode: Mode) { + let picker = picker.clone(); + app.execute(move |_| { + let dialog = create_file_dialog(picker); + + // Now that we've set the parent, we can move this to its own + // blocking thread to be shown. + thread::spawn(move || match mode { + Mode::File(on_dismiss) => on_dismiss.invoke(dialog.pick_file()), + Mode::SaveFile(on_dismiss) => on_dismiss.invoke(dialog.save_file()), + Mode::Files(on_dismiss) => on_dismiss.invoke(dialog.pick_files()), + Mode::Folder(on_dismiss) => on_dismiss.invoke(dialog.pick_folder()), + Mode::Folders(on_dismiss) => on_dismiss.invoke(dialog.pick_folders()), + }); + }); +} + +impl PickFile for App { + fn pick_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::file(callback)); + } + + fn save_file(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::save_file(callback)); + } + + fn pick_files(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::files(callback)); + } + + fn pick_folder(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::folder(callback)); + } + + fn pick_folders(&self, picker: &FilePicker, callback: Callback) + where + Callback: FnOnce(Option>) + Send + 'static, + { + show_picker_in_app(self, picker, Mode::folders(callback)); + } +} diff --git a/src/styles.rs b/src/styles.rs index 05a104816..6826d98a5 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -927,6 +927,12 @@ impl DimensionRange { Bound::Included(value) => Some(value), } } + + /// Returns true if this range has no bounds. + #[must_use] + pub const fn is_unbounded(&self) -> bool { + matches!(&self.start, Bound::Unbounded) && matches!(&self.end, Bound::Unbounded) + } } impl From for DimensionRange diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 653f3907a..7194ba0e2 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -1,5 +1,5 @@ //! A clickable, labeled button -use std::time::Duration; +use std::time::{Duration, Instant}; use figures::units::{Lp, Px, UPx}; use figures::{IntoSigned, Point, Rect, Round, ScreenScale, Size}; @@ -8,7 +8,7 @@ use kludgine::app::winit::window::CursorIcon; use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; -use crate::animation::{AnimationHandle, AnimationTarget, LinearInterpolate, Spawn}; +use crate::animation::{AnimationHandle, AnimationTarget, IntoAnimate, LinearInterpolate, Spawn}; use crate::context::{ AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetCacheKey, WidgetContext, }; @@ -20,7 +20,9 @@ use crate::styles::components::{ }; use crate::styles::{ColorExt, Styles}; use crate::value::{Destination, Dynamic, IntoValue, Source, Value}; -use crate::widget::{Callback, EventHandling, MakeWidget, Widget, WidgetRef, HANDLED}; +use crate::widget::{ + Callback, EventHandling, MakeWidget, SharedCallback, Widget, WidgetRef, HANDLED, +}; use crate::window::{DeviceId, WindowLocal}; use crate::FitMeasuredSize; @@ -607,3 +609,80 @@ pub struct ButtonClick { /// The keyboard modifiers state when this click began. pub modifiers: Modifiers, } + +/// A multi-click gesture recognizer. +pub struct ClickCounter { + threshold: Value, + maximum: usize, + last_click: Option, + count: usize, + on_click: SharedCallback<(usize, Option)>, + delay_fire: AnimationHandle, +} + +impl ClickCounter { + /// Returns a new click counter that allows up to `threshold` between each + /// click to be recognized as a single action. `on_click` will be invoked + /// after no clicks have been detected for `threshold`. + /// + /// `on_click` accepts two parameters: + /// + /// - The number of clicks recognized for this action. + /// - The final [`ButtonClick`], if provided. + #[must_use] + pub fn new(threshold: impl IntoValue, mut on_click: F) -> Self + where + F: FnMut(usize, Option) + Send + 'static, + { + Self { + threshold: threshold.into_value(), + maximum: usize::MAX, + last_click: None, + count: 0, + on_click: SharedCallback::new(move |(count, click)| on_click(count, click)), + delay_fire: AnimationHandle::new(), + } + } + + /// Sets the maximum number of clicks this counter recognizes to `maximum`. + /// + /// This causes the counter to immediately invoke the callback when the + /// maximum clicks have been reached, allowing for slightly more responsive + /// interfaces when the user is clicking multiple times. + #[must_use] + pub fn with_maximum(mut self, maximum: usize) -> Self { + self.maximum = maximum; + self + } + + /// Notes a single click. + pub fn click(&mut self, click: Option) { + let now = Instant::now(); + let threshold = self.threshold.get(); + if let Some(last_click) = self.last_click { + let elapsed = now.saturating_duration_since(last_click); + if elapsed < threshold { + self.count += 1; + } else { + self.count = 1; + } + } else { + self.count = 1; + } + self.last_click = Some(now); + + if self.count == self.maximum { + self.delay_fire.clear(); + self.on_click.invoke((self.count, click)); + self.count = 0; + } else { + let on_activation = self.on_click.clone(); + let count = self.count; + self.delay_fire = threshold + .on_complete(move || { + on_activation.invoke((count, click)); + }) + .spawn(); + } + } +} diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 35560d0d7..67b6583c4 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -1,5 +1,6 @@ //! A read-only text widget. +use std::borrow::Cow; use std::fmt::{Display, Write}; use figures::units::{Px, UPx}; @@ -12,7 +13,7 @@ use crate::context::{GraphicsContext, LayoutContext, Trackable, WidgetContext}; use crate::styles::components::TextColor; use crate::styles::FontFamilyList; use crate::value::{Dynamic, Generation, IntoReadOnly, ReadOnly, Value}; -use crate::widget::{Widget, WidgetInstance}; +use crate::widget::{MakeWidgetWithTag, Widget, WidgetInstance, WidgetTag}; use crate::window::WindowLocal; use crate::ConstraintLimit; @@ -127,8 +128,8 @@ where macro_rules! impl_make_widget { ($($type:ty => $kind:ty),*) => { - $(impl crate::widget::MakeWidgetWithTag for $type { - fn make_with_tag(self, id: crate::widget::WidgetTag) -> WidgetInstance { + $(impl MakeWidgetWithTag for $type { + fn make_with_tag(self, id: WidgetTag) -> WidgetInstance { Label::<$kind>::new(self).make_with_tag(id) } })* @@ -145,6 +146,18 @@ impl_make_widget!( ReadOnly => String ); +impl MakeWidgetWithTag for Cow<'_, str> { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + Label::new(self.into_owned()).make_with_tag(tag) + } +} + +impl MakeWidgetWithTag for &'_ String { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + Label::new(self.clone()).make_with_tag(tag) + } +} + #[derive(Debug)] struct LabelCacheKey { text: MeasuredText, diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 90bc83fdc..045770e7a 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -81,13 +81,18 @@ impl Stack { (expand.child().clone(), GridDimension::Fractional { weight }) } else if let Some((child, size)) = guard.downcast_ref::().and_then(|r| { - let range = match self.layout.orientation { - Orientation::Row => r.height, - Orientation::Column => r.width, + let (range, other_range) = match self.layout.orientation { + Orientation::Row => (r.height, r.width), + Orientation::Column => (r.width, r.height), }; - range.minimum().map(|size| { - (r.child().clone(), GridDimension::Measured { size }) - }) + let cell = if other_range.is_unbounded() { + r.child().clone() + } else { + WidgetRef::new(widget.clone()) + }; + range + .minimum() + .map(|size| (cell, GridDimension::Measured { size })) }) { (child, size)