diff --git a/Cargo.lock b/Cargo.lock index bb86bc41..c43fa494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,12 +106,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -135,9 +129,9 @@ checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "approx" @@ -199,6 +193,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle 0.6.2", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -239,9 +255,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", @@ -250,20 +266,20 @@ dependencies = [ [[package]] name = "async-io" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 1.0.8", + "rustix 1.1.2", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -290,9 +306,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", "async-io", @@ -303,7 +319,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.0.8", + "rustix 1.1.2", ] [[package]] @@ -319,9 +335,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -329,10 +345,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.0.8", + "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -402,7 +418,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 7.1.3", "num-rational", "v_frame", ] @@ -690,9 +706,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.36" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ "find-msvc-tools", "jobserver", @@ -736,7 +752,7 @@ dependencies = [ "log", "reqwest", "serde", - "thiserror 1.0.69", + "thiserror 2.0.16", "tokio", "tracing-subscriber", "webbrowser", @@ -751,16 +767,15 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-link 0.1.3", + "windows-link 0.2.0", ] [[package]] @@ -1195,6 +1210,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "delineate" +version = "0.1.0" +dependencies = [ + "iced", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1248,6 +1270,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.4", + "block2 0.6.1", + "libc", "objc2 0.6.2", ] @@ -1343,7 +1367,7 @@ name = "editor" version = "0.1.0" dependencies = [ "iced", - "rfd", + "rfd 0.13.0", "tokio", ] @@ -1417,12 +1441,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -1550,9 +1574,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "flate2" @@ -1866,7 +1890,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "rustix 1.0.8", + "rustix 1.1.2", "windows-targets 0.52.6", ] @@ -1901,7 +1925,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.4+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -2034,7 +2058,7 @@ checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ "bitflags 2.9.4", "gpu-descriptor-types", - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -2140,6 +2164,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "headers" version = "0.3.9" @@ -2317,10 +2347,10 @@ dependencies = [ "http 1.3.1", "hyper 1.7.0", "hyper-util", - "rustls 0.23.31", + "rustls 0.23.32", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tower-service", ] @@ -2342,9 +2372,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", @@ -2368,9 +2398,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2378,7 +2408,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.0", ] [[package]] @@ -2402,11 +2432,12 @@ dependencies = [ "iced_highlighter", "iced_renderer", "iced_runtime", + "iced_tester", "iced_wgpu", "iced_widget", "iced_winit", "image", - "thiserror 1.0.69", + "thiserror 2.0.16", ] [[package]] @@ -2419,7 +2450,7 @@ dependencies = [ "log", "semver", "serde", - "thiserror 1.0.69", + "thiserror 2.0.16", "tokio", ] @@ -2436,7 +2467,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "smol_str", - "thiserror 1.0.69", + "thiserror 2.0.16", "web-time", ] @@ -2491,7 +2522,7 @@ dependencies = [ "lyon_path", "raw-window-handle 0.6.2", "rustc-hash 2.1.1", - "thiserror 1.0.69", + "thiserror 2.0.16", "unicode-segmentation", ] @@ -2519,7 +2550,7 @@ dependencies = [ "iced_tiny_skia", "iced_wgpu", "log", - "thiserror 1.0.69", + "thiserror 2.0.16", ] [[package]] @@ -2530,20 +2561,41 @@ dependencies = [ "iced_core", "iced_debug", "iced_futures", + "iced_selector", "raw-window-handle 0.6.2", "sipper", - "thiserror 1.0.69", + "thiserror 2.0.16", +] + +[[package]] +name = "iced_selector" +version = "0.14.0-dev" +dependencies = [ + "iced_core", ] [[package]] name = "iced_test" version = "0.14.0-dev" dependencies = [ + "iced_program", "iced_renderer", "iced_runtime", + "iced_selector", + "nom 8.0.0", "png 0.18.0", "sha2", - "thiserror 1.0.69", + "thiserror 2.0.16", +] + +[[package]] +name = "iced_tester" +version = "0.14.0-dev" +dependencies = [ + "iced_test", + "iced_widget", + "log", + "rfd 0.15.4", ] [[package]] @@ -2578,7 +2630,7 @@ dependencies = [ "lyon", "resvg", "rustc-hash 2.1.1", - "thiserror 1.0.69", + "thiserror 2.0.16", "wgpu", ] @@ -2588,14 +2640,13 @@ version = "0.14.0-dev" dependencies = [ "iced_highlighter", "iced_renderer", - "iced_runtime", "log", "num-traits", "ouroboros", "pulldown-cmark", "qrcode", "rustc-hash 2.1.1", - "thiserror 1.0.69", + "thiserror 2.0.16", "unicode-segmentation", "url", ] @@ -2610,7 +2661,7 @@ dependencies = [ "mundy", "rustc-hash 2.1.1", "sysinfo", - "thiserror 1.0.69", + "thiserror 2.0.16", "tracing", "wasm-bindgen-futures", "web-sys", @@ -2773,12 +2824,12 @@ checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" [[package]] name = "indexmap" -version = "2.11.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -2928,9 +2979,9 @@ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] name = "js-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -3043,9 +3094,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.9.4", "libc", @@ -3081,9 +3132,9 @@ checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -3139,15 +3190,15 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ea4e65087ff52f3862caff188d489f1fab49a0cb09e01b2e3f1a617b10aaed" +checksum = "bfe949189f46fabb938b3a9a0be30fdd93fd8a09260da863399a8cf3db756ec8" [[package]] name = "lyon" -version = "1.0.1" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f" +checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352" dependencies = [ "lyon_algorithms", "lyon_tessellation", @@ -3155,9 +3206,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.5" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f13c9be19d257c7d37e70608ed858e8eab4b2afcea2e3c9a622e892acbf43c08" +checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647" dependencies = [ "lyon_path", "num-traits", @@ -3165,9 +3216,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.6" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8af69edc087272df438b3ee436c4bb6d7c04aa8af665cfd398feae627dbd8570" +checksum = "ce9333c02ea4517fd31207f126124352ad59975218c114c55dbb8f9d56fd4b45" dependencies = [ "arrayvec", "euclid", @@ -3176,9 +3227,9 @@ dependencies = [ [[package]] name = "lyon_path" -version = "1.0.7" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0047f508cd7a85ad6bad9518f68cce7b1bf6b943fb71f6da0ee3bc1e8cb75f25" +checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" dependencies = [ "lyon_geom", "num-traits", @@ -3186,9 +3237,9 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579d42360a4b09846eff2feef28f538696c7d6c7439bfa65874ff3cbe0951b2c" +checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353" dependencies = [ "float_next_after", "lyon_path", @@ -3246,7 +3297,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 1.0.8", + "rustix 1.1.2", ] [[package]] @@ -3420,7 +3471,7 @@ dependencies = [ "cfg_aliases", "codespan-reporting", "half", - "hashbrown", + "hashbrown 0.15.5", "hexf-parse", "indexmap", "libm", @@ -3509,6 +3560,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -4334,9 +4394,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.7.4" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap", @@ -4413,18 +4473,24 @@ dependencies = [ [[package]] name = "polling" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.0", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4472,11 +4538,11 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.6", ] [[package]] @@ -4925,6 +4991,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.1", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "pollster", + "raw-window-handle 0.6.2", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rgb" version = "0.8.52" @@ -4987,15 +5077,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", ] [[package]] @@ -5014,13 +5104,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.4", + "rustls-webpki 0.103.6", "subtle", "zeroize", ] @@ -5047,9 +5137,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "ring", "rustls-pki-types", @@ -5096,11 +5186,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -5176,27 +5266,38 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", + "serde_core", ] [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -5205,14 +5306,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -5755,15 +5857,15 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] @@ -5849,11 +5951,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.43" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -6027,11 +6130,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls 0.23.31", + "rustls 0.23.32", "tokio", ] @@ -6068,8 +6171,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -6081,6 +6184,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -6090,7 +6202,28 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +dependencies = [ + "indexmap", + "toml_datetime 0.7.2", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ "winnow", ] @@ -6303,9 +6436,9 @@ checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -6374,6 +6507,12 @@ dependencies = [ "iced", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.42.0" @@ -6474,13 +6613,6 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" -[[package]] -name = "visible_bounds" -version = "0.1.0" -dependencies = [ - "iced", -] - [[package]] name = "voronator" version = "0.2.1" @@ -6546,18 +6678,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.4+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -6568,9 +6709,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -6582,9 +6723,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.51" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -6595,9 +6736,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6605,9 +6746,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -6618,9 +6759,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] @@ -6660,7 +6801,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.0.8", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -6673,7 +6814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.9.4", - "rustix 1.0.8", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] @@ -6695,7 +6836,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 1.0.8", + "rustix 1.1.2", "wayland-client", "xcursor", ] @@ -6763,9 +6904,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", @@ -6842,7 +6983,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "document-features", - "hashbrown", + "hashbrown 0.15.5", "js-sys", "log", "naga", @@ -6872,7 +7013,7 @@ dependencies = [ "bitflags 2.9.4", "cfg_aliases", "document-features", - "hashbrown", + "hashbrown 0.15.5", "indexmap", "log", "naga", @@ -6949,7 +7090,7 @@ dependencies = [ "gpu-alloc", "gpu-allocator", "gpu-descriptor", - "hashbrown", + "hashbrown 0.15.5", "js-sys", "khronos-egl", "libc", @@ -7115,6 +7256,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -7252,6 +7406,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -7271,6 +7434,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7639,9 +7811,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -7671,7 +7843,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.0.8", + "rustix 1.1.2", "x11rb-protocol", ] @@ -7765,9 +7937,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a073be99ace1adc48af593701c8015cd9817df372e14a1a6b0ee8f8bf043be" +checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" dependencies = [ "async-broadcast", "async-executor", @@ -7798,9 +7970,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e80cd713a45a49859dcb648053f63265f4f2851b6420d47a958e5697c68b131" +checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7942,6 +8114,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow", "zvariant_derive", "zvariant_utils", diff --git a/Cargo.toml b/Cargo.toml index 978f73e0..03b2e721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,11 +42,13 @@ markdown = ["iced_widget/markdown"] # Enables lazy widgets lazy = ["iced_widget/lazy"] # Enables debug metrics in native platforms (press F12) -debug = ["iced_winit/debug", "iced_devtools"] +debug = ["iced_winit/debug", "dep:iced_devtools"] # Enables time-travel debugging (very experimental!) time-travel = ["debug", "iced_devtools/time-travel"] # Enables hot reloading (very experimental!) hot = ["debug", "iced_debug/hot"] +# Enables the tester developer tool for recording and playing tests (press F12) +tester = ["dep:iced_tester"] # Enables the `thread-pool` futures executor as the `executor::Default` on native platforms thread-pool = ["iced_futures/thread-pool"] # Enables `tokio` as the `executor::Default` on native platforms @@ -63,6 +65,8 @@ crisp = ["iced_core/crisp", "iced_widget/crisp"] webgl = ["iced_renderer/webgl"] # Enables syntax highlighting highlighter = ["iced_highlighter", "iced_widget/highlighter"] +# Enables the `widget::selector` module +selector = ["iced_runtime/selector"] # Enables the advanced module advanced = ["iced_core/advanced", "iced_widget/advanced"] # Embeds Fira Sans into the final application; useful for testing and Wasm builds @@ -87,12 +91,14 @@ iced_futures.workspace = true iced_renderer.workspace = true iced_runtime.workspace = true iced_widget.workspace = true -iced_winit.features = ["program"] iced_winit.workspace = true iced_devtools.workspace = true iced_devtools.optional = true +iced_tester.workspace = true +iced_tester.optional = true + iced_highlighter.workspace = true iced_highlighter.optional = true @@ -132,7 +138,9 @@ members = [ "program", "renderer", "runtime", + "selector", "test", + "tester", "tiny_skia", "wgpu", "widget", @@ -163,7 +171,9 @@ iced_highlighter = { version = "0.14.0-dev", path = "highlighter" } iced_program = { version = "0.14.0-dev", path = "program" } iced_renderer = { version = "0.14.0-dev", path = "renderer" } iced_runtime = { version = "0.14.0-dev", path = "runtime" } +iced_selector = { version = "0.14.0-dev", path = "selector" } iced_test = { version = "0.14.0-dev", path = "test" } +iced_tester = { version = "0.14.0-dev", path = "tester" } iced_tiny_skia = { version = "0.14.0-dev", path = "tiny_skia" } iced_wgpu = { version = "0.14.0-dev", path = "wgpu" } iced_widget = { version = "0.14.0-dev", path = "widget" } @@ -188,6 +198,7 @@ log = "0.4" lyon = "1.0" lyon_path = "1.0" mundy = { version = "0.2", default-features = false } +nom = "8" num-traits = "0.2" ouroboros = "0.18" png = "0.18" @@ -195,6 +206,7 @@ pulldown-cmark = "0.12" qrcode = { version = "0.13", default-features = false } raw-window-handle = "0.6" resvg = "0.42" +rfd = "0.15" rustc-hash = "2.0" semver = "1.0" serde = "1.0" @@ -205,7 +217,7 @@ smol_str = "0.2" softbuffer = "0.4" syntect = "5.1" sysinfo = "0.33" -thiserror = "1.0" +thiserror = "2" tiny-skia = "0.11" tokio = "1.0" tracing = "0.1" @@ -221,7 +233,6 @@ winit = { git = "https://github.com/iced-rs/winit.git", rev = "05b8ff17a06562f0a [workspace.lints.rust] rust_2018_idioms = { level = "deny", priority = -1 } -missing_debug_implementations = "deny" missing_docs = "deny" unsafe_code = "deny" unused_results = "deny" diff --git a/core/src/element.rs b/core/src/element.rs index 6a71296c..17731d0c 100644 --- a/core/src/element.rs +++ b/core/src/element.rs @@ -20,7 +20,6 @@ use std::borrow::Borrow; /// to turn it into an [`Element`]. /// /// [built-in widget]: crate::widget -#[allow(missing_debug_implementations)] pub struct Element<'a, Message, Theme, Renderer> { widget: Box + 'a>, } diff --git a/core/src/hasher.rs b/core/src/hasher.rs deleted file mode 100644 index 13180e41..00000000 --- a/core/src/hasher.rs +++ /dev/null @@ -1,14 +0,0 @@ -/// The hasher used to compare layouts. -#[allow(missing_debug_implementations)] // Doesn't really make sense to have debug on the hasher state anyways. -#[derive(Default)] -pub struct Hasher(rustc_hash::FxHasher); - -impl core::hash::Hasher for Hasher { - fn write(&mut self, bytes: &[u8]) { - self.0.write(bytes); - } - - fn finish(&self) -> u64 { - self.0.finish() - } -} diff --git a/core/src/overlay.rs b/core/src/overlay.rs index 5f7ebce9..76fe321a 100644 --- a/core/src/overlay.rs +++ b/core/src/overlay.rs @@ -1,9 +1,11 @@ //! Display interactive elements on top of other widgets. mod element; mod group; +mod nested; pub use element::Element; pub use group::Group; +pub use nested::Nested; use crate::layout; use crate::mouse; diff --git a/core/src/overlay/element.rs b/core/src/overlay/element.rs index 561a18ab..e67a48bc 100644 --- a/core/src/overlay/element.rs +++ b/core/src/overlay/element.rs @@ -7,7 +7,6 @@ use crate::widget; use crate::{Clipboard, Event, Layout, Shell, Size}; /// A generic [`Overlay`]. -#[allow(missing_debug_implementations)] pub struct Element<'a, Message, Theme, Renderer> { overlay: Box + 'a>, } diff --git a/core/src/overlay/group.rs b/core/src/overlay/group.rs index 145ee21d..1a4ce672 100644 --- a/core/src/overlay/group.rs +++ b/core/src/overlay/group.rs @@ -7,7 +7,6 @@ use crate::{Clipboard, Event, Layout, Overlay, Shell, Size}; /// An [`Overlay`] container that displays multiple overlay [`overlay::Element`] /// children. -#[allow(missing_debug_implementations)] pub struct Group<'a, Message, Theme, Renderer> { children: Vec>, } @@ -127,7 +126,7 @@ where renderer: &Renderer, operation: &mut dyn widget::Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.traverse(&mut |operation| { self.children.iter_mut().zip(layout.children()).for_each( |(child, layout)| { child.as_overlay_mut().operate(layout, renderer, operation); diff --git a/runtime/src/overlay/nested.rs b/core/src/overlay/nested.rs similarity index 97% rename from runtime/src/overlay/nested.rs rename to core/src/overlay/nested.rs index 83c58804..2b90a900 100644 --- a/runtime/src/overlay/nested.rs +++ b/core/src/overlay/nested.rs @@ -1,13 +1,12 @@ -use crate::core::event; -use crate::core::layout; -use crate::core::mouse; -use crate::core::overlay; -use crate::core::renderer; -use crate::core::widget; -use crate::core::{Clipboard, Event, Layout, Shell, Size}; +use crate::event; +use crate::layout; +use crate::mouse; +use crate::overlay; +use crate::renderer; +use crate::widget; +use crate::{Clipboard, Event, Layout, Shell, Size}; /// An overlay container that displays nested overlays -#[allow(missing_debug_implementations)] pub struct Nested<'a, Message, Theme, Renderer> { overlay: overlay::Element<'a, Message, Theme, Renderer>, } diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 60c87a81..ff5baa3b 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -31,7 +31,6 @@ impl text::Renderer for () { type Paragraph = (); type Editor = (); - const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::DEFAULT; const CHECKMARK_ICON: char = '0'; const ARROW_DOWN_ICON: char = '0'; diff --git a/core/src/text.rs b/core/src/text.rs index e47d9bbd..fdebd03a 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -299,11 +299,6 @@ pub trait Renderer: crate::Renderer { /// The [`Editor`] of this [`Renderer`]. type Editor: Editor + 'static; - /// A monospace font. - /// - /// It may be used by devtools. - const MONOSPACE_FONT: Self::Font; - /// The icon font of the backend. const ICON_FONT: Self::Font; diff --git a/core/src/widget/id.rs b/core/src/widget/id.rs index e03ded9d..1d67086e 100644 --- a/core/src/widget/id.rs +++ b/core/src/widget/id.rs @@ -8,9 +8,9 @@ static NEXT_ID: AtomicUsize = AtomicUsize::new(0); pub struct Id(Internal); impl Id { - /// Creates a custom [`Id`]. - pub fn new(id: impl Into>) -> Self { - Self(Internal::Custom(id.into())) + /// Creates a new [`Id`] from a static `str`. + pub const fn new(id: &'static str) -> Self { + Self(Internal::Custom(borrow::Cow::Borrowed(id))) } /// Creates a unique [`Id`]. @@ -29,6 +29,12 @@ impl From<&'static str> for Id { } } +impl From for Id { + fn from(value: String) -> Self { + Self(Internal::Custom(borrow::Cow::Owned(value))) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum Internal { Unique(usize), diff --git a/core/src/widget/operation.rs b/core/src/widget/operation.rs index 8fc627bf..9e7b0d34 100644 --- a/core/src/widget/operation.rs +++ b/core/src/widget/operation.rs @@ -18,25 +18,16 @@ use std::sync::Arc; /// A piece of logic that can traverse the widget tree of an application in /// order to query or update some widget state. pub trait Operation: Send { - /// Operates on a widget that contains other widgets. + /// Requests further traversal of the widget tree to keep operating. /// - /// The `operate_on_children` function can be called to return control to - /// the widget tree and keep traversing it. - fn container( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ); + /// The provided `operate` closure may be called by an [`Operation`] + /// to return control to the widget tree and keep traversing it. If + /// the closure is not called, the children of the widget asking for + /// traversal will be skipped. + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)); - /// Operates on a widget that can be focused. - fn focusable( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - _state: &mut dyn Focusable, - ) { - } + /// Operates on a widget that contains other widgets. + fn container(&mut self, _id: Option<&Id>, _bounds: Rectangle) {} /// Operates on a widget that can be scrolled. fn scrollable( @@ -49,6 +40,15 @@ pub trait Operation: Send { ) { } + /// Operates on a widget that can be focused. + fn focusable( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + _state: &mut dyn Focusable, + ) { + } + /// Operates on a widget that has text input. fn text_input( &mut self, @@ -80,13 +80,12 @@ impl Operation for Box where T: Operation + ?Sized, { - fn container( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - self.as_mut().container(id, bounds, operate_on_children); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + self.as_mut().traverse(operate); + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + self.as_mut().container(id, bounds); } fn focusable( @@ -179,17 +178,19 @@ where } impl Operation for BlackBox<'_, T> { - fn container( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - self.operation.container(id, bounds, &mut |operation| { - operate_on_children(&mut BlackBox { operation }); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) + where + Self: Sized, + { + self.operation.traverse(&mut |operation| { + operate(&mut BlackBox { operation }); }); } + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + self.operation.container(id, bounds); + } + fn focusable( &mut self, id: Option<&Id>, @@ -255,7 +256,6 @@ where A: 'static, B: 'static, { - #[allow(missing_debug_implementations)] struct Map { operation: O, f: Arc B + Send + Sync>, @@ -267,28 +267,25 @@ where A: 'static, B: 'static, { - fn container( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { struct MapRef<'a, A> { operation: &'a mut dyn Operation, } impl Operation for MapRef<'_, A> { - fn container( + fn traverse( &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), + operate: &mut dyn FnMut(&mut dyn Operation), ) { + self.operation.traverse(&mut |operation| { + operate(&mut MapRef { operation }); + }); + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { let Self { operation, .. } = self; - operation.container(id, bounds, &mut |operation| { - operate_on_children(&mut MapRef { operation }); - }); + operation.container(id, bounds); } fn scrollable( @@ -345,9 +342,13 @@ where } } - let Self { operation, .. } = self; + self.operation.traverse(&mut |operation| { + operate(&mut MapRef { operation }); + }); + } - MapRef { operation }.container(id, bounds, operate_on_children); + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + self.operation.container(id, bounds); } fn focusable( @@ -444,17 +445,16 @@ where A: 'static, B: Send + 'static, { - fn container( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - self.operation.container(id, bounds, &mut |operation| { - operate_on_children(&mut black_box(operation)); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + self.operation.traverse(&mut |operation| { + operate(&mut black_box(operation)); }); } + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + self.operation.container(id, bounds); + } + fn focusable( &mut self, id: Option<&Id>, @@ -531,21 +531,26 @@ pub fn scope( ) -> impl Operation { struct ScopedOperation { target: Id, + current: Option, operation: Box>, } impl Operation for ScopedOperation { - fn container( + fn traverse( &mut self, - id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), + operate: &mut dyn FnMut(&mut dyn Operation), ) { - if id == Some(&self.target) { - operate_on_children(self.operation.as_mut()); + if self.current.as_ref() == Some(&self.target) { + self.operation.as_mut().traverse(operate); } else { - operate_on_children(self); + operate(self); } + + self.current = None; + } + + fn container(&mut self, id: Option<&Id>, _bounds: Rectangle) { + self.current = id.cloned(); } fn finish(&self) -> Outcome { @@ -553,6 +558,7 @@ pub fn scope( Outcome::Chain(next) => { Outcome::Chain(Box::new(ScopedOperation { target: self.target.clone(), + current: None, operation: next, })) } @@ -563,6 +569,7 @@ pub fn scope( ScopedOperation { target, + current: None, operation: Box::new(operation), } } diff --git a/core/src/widget/operation/focusable.rs b/core/src/widget/operation/focusable.rs index 44c9d647..5b066f2d 100644 --- a/core/src/widget/operation/focusable.rs +++ b/core/src/widget/operation/focusable.rs @@ -48,13 +48,8 @@ pub fn focus(target: Id) -> impl Operation { } } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -75,13 +70,8 @@ pub fn unfocus() -> impl Operation { state.unfocus(); } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -109,13 +99,11 @@ pub fn count() -> impl Operation { self.count.total += 1; } - fn container( + fn traverse( &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), + operate: &mut dyn FnMut(&mut dyn Operation), ) { - operate_on_children(self); + operate(self); } fn finish(&self) -> Outcome { @@ -163,13 +151,8 @@ where self.current += 1; } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -205,13 +188,8 @@ where self.current += 1; } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -237,13 +215,11 @@ pub fn find_focused() -> impl Operation { } } - fn container( + fn traverse( &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), + operate: &mut dyn FnMut(&mut dyn Operation), ) { - operate_on_children(self); + operate(self); } fn finish(&self) -> Outcome { @@ -279,17 +255,15 @@ pub fn is_focused(target: Id) -> impl Operation { } } - fn container( + fn traverse( &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), + operate: &mut dyn FnMut(&mut dyn Operation), ) { if self.is_focused.is_some() { return; } - operate_on_children(self); + operate(self); } fn finish(&self) -> Outcome { diff --git a/core/src/widget/operation/scrollable.rs b/core/src/widget/operation/scrollable.rs index 7c78c087..25a03434 100644 --- a/core/src/widget/operation/scrollable.rs +++ b/core/src/widget/operation/scrollable.rs @@ -28,13 +28,8 @@ pub fn snap_to(target: Id, offset: RelativeOffset) -> impl Operation { } impl Operation for SnapTo { - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } fn scrollable( @@ -63,13 +58,8 @@ pub fn scroll_to(target: Id, offset: AbsoluteOffset) -> impl Operation { } impl Operation for ScrollTo { - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } fn scrollable( @@ -98,13 +88,8 @@ pub fn scroll_by(target: Id, offset: AbsoluteOffset) -> impl Operation { } impl Operation for ScrollBy { - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } fn scrollable( diff --git a/core/src/widget/operation/text_input.rs b/core/src/widget/operation/text_input.rs index efb2a4d3..de2ac1a0 100644 --- a/core/src/widget/operation/text_input.rs +++ b/core/src/widget/operation/text_input.rs @@ -5,12 +5,20 @@ use crate::widget::operation::Operation; /// The internal state of a widget that has text input. pub trait TextInput { + /// Returns the current _visible_ text of the text input + /// + /// Normally, this is either its value or its placeholder. + fn text(&self) -> &str; + /// Moves the cursor of the text input to the front of the input text. fn move_cursor_to_front(&mut self); + /// Moves the cursor of the text input to the end of the input text. fn move_cursor_to_end(&mut self); + /// Moves the cursor of the text input to an arbitrary location. fn move_cursor_to(&mut self, position: usize); + /// Selects all the content of the text input. fn select_all(&mut self); } @@ -37,13 +45,8 @@ pub fn move_cursor_to_front(target: Id) -> impl Operation { } } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -72,13 +75,8 @@ pub fn move_cursor_to_end(target: Id) -> impl Operation { } } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -108,13 +106,8 @@ pub fn move_cursor_to(target: Id, position: usize) -> impl Operation { } } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } @@ -142,13 +135,8 @@ pub fn select_all(target: Id) -> impl Operation { } } - fn container( - &mut self, - _id: Option<&Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut(&mut dyn Operation), - ) { - operate_on_children(self); + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); } } diff --git a/core/src/widget/text.rs b/core/src/widget/text.rs index c1f1e87e..72cb4319 100644 --- a/core/src/widget/text.rs +++ b/core/src/widget/text.rs @@ -55,7 +55,6 @@ pub use text::{Alignment, LineHeight, Shaping, Wrapping}; /// .into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Text<'a, Theme, Renderer> where Theme: Catalog, diff --git a/debug/src/lib.rs b/debug/src/lib.rs index f79dde55..e20c00e6 100644 --- a/debug/src/lib.rs +++ b/debug/src/lib.rs @@ -413,7 +413,7 @@ mod internal { } } -#[cfg(feature = "hot")] +#[cfg(all(feature = "hot", not(target_arch = "wasm32")))] mod hot { use std::collections::BTreeSet; use std::sync::atomic::{self, AtomicBool}; @@ -478,7 +478,7 @@ mod hot { } } -#[cfg(not(feature = "hot"))] +#[cfg(any(not(feature = "hot"), target_arch = "wasm32"))] mod hot { pub fn init() {} @@ -486,7 +486,7 @@ mod hot { f() } - pub fn on_hotpatch(_f: impl Fn() + Send + Sync + 'static) {} + pub fn on_hotpatch(_f: impl Fn()) {} pub fn is_stale() -> bool { false diff --git a/devtools/Cargo.toml b/devtools/Cargo.toml index 04c3792b..f9923410 100644 --- a/devtools/Cargo.toml +++ b/devtools/Cargo.toml @@ -18,7 +18,8 @@ time-travel = ["iced_program/time-travel"] [dependencies] iced_debug.workspace = true -iced_program.workspace = true iced_widget.workspace = true - log.workspace = true + +iced_program.workspace = true +iced_program.features = ["debug"] diff --git a/devtools/src/comet.rs b/devtools/src/comet.rs index d5ba0802..d6975c26 100644 --- a/devtools/src/comet.rs +++ b/devtools/src/comet.rs @@ -1,5 +1,4 @@ -use crate::executor; -use crate::runtime::Task; +use crate::runtime::task::{self, Task}; use std::process; @@ -7,7 +6,7 @@ pub const COMPATIBLE_REVISION: &str = "20f9c9a897fecac5dce0977bbb5639fdce1f54b9"; pub fn launch() -> Task { - executor::try_spawn_blocking(|mut sender| { + task::try_blocking(|mut sender| { let cargo_install = process::Command::new("cargo") .args(["install", "--list"]) .output()?; @@ -48,7 +47,7 @@ pub fn launch() -> Task { } pub fn install() -> Task { - executor::try_spawn_blocking(|mut sender| { + task::try_blocking(|mut sender| { use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; diff --git a/devtools/src/executor.rs b/devtools/src/executor.rs deleted file mode 100644 index 1e7317a2..00000000 --- a/devtools/src/executor.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::futures::futures::channel::mpsc; -use crate::futures::futures::channel::oneshot; -use crate::futures::futures::stream::{self, StreamExt}; -use crate::runtime::Task; - -use std::thread; - -pub fn spawn_blocking( - f: impl FnOnce(mpsc::Sender) + Send + 'static, -) -> Task -where - T: Send + 'static, -{ - let (sender, receiver) = mpsc::channel(1); - - let _ = thread::spawn(move || { - f(sender); - }); - - Task::stream(receiver) -} - -pub fn try_spawn_blocking( - f: impl FnOnce(mpsc::Sender) -> Result<(), E> + Send + 'static, -) -> Task> -where - T: Send + 'static, - E: Send + 'static, -{ - let (sender, receiver) = mpsc::channel(1); - let (error_sender, error_receiver) = oneshot::channel(); - - let _ = thread::spawn(move || { - if let Err(error) = f(sender) { - let _ = error_sender.send(Err(error)); - } - }); - - Task::stream(stream::select( - receiver.map(Ok), - stream::once(error_receiver).filter_map(async |result| result.ok()), - )) -} diff --git a/devtools/src/lib.rs b/devtools/src/lib.rs index d8ef0729..6ad7ae9a 100644 --- a/devtools/src/lib.rs +++ b/devtools/src/lib.rs @@ -1,13 +1,12 @@ #![allow(missing_docs)] use iced_debug as debug; use iced_program as program; +use iced_program::runtime; +use iced_program::runtime::futures; use iced_widget as widget; use iced_widget::core; -use iced_widget::runtime; -use iced_widget::runtime::futures; mod comet; -mod executor; mod time_machine; use crate::core::border; @@ -15,14 +14,17 @@ use crate::core::keyboard; use crate::core::theme::{self, Theme}; use crate::core::time::seconds; use crate::core::window; -use crate::core::{Alignment::Center, Color, Element, Length::Fill}; +use crate::core::{ + Alignment::Center, Color, Element, Font, Length::Fill, Settings, +}; use crate::futures::Subscription; use crate::program::Program; -use crate::runtime::Task; +use crate::program::message; +use crate::runtime::task::{self, Task}; use crate::time_machine::TimeMachine; use crate::widget::{ - bottom_right, button, center, column, container, horizontal_space, opaque, - row, scrollable, stack, text, themer, + bottom_right, button, center, column, container, opaque, row, scrollable, + space, stack, text, themer, }; use std::fmt; @@ -42,6 +44,7 @@ pub struct Attach

{ impl

Program for Attach

where P: Program + 'static, + P::Message: std::fmt::Debug + message::MaybeClone, { type State = DevTools

; type Message = Event

; @@ -53,6 +56,14 @@ where P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { let (state, boot) = self.program.boot(); let (devtools, task) = DevTools::new(state); @@ -83,10 +94,7 @@ where state.title(&self.program, window) } - fn subscription( - &self, - state: &Self::State, - ) -> runtime::futures::Subscription { + fn subscription(&self, state: &Self::State) -> Subscription { state.subscription(&self.program) } @@ -108,15 +116,14 @@ where } /// The state of the devtools. -#[allow(missing_debug_implementations)] pub struct DevTools

where P: Program, { state: P::State, - mode: Mode, show_notification: bool, time_machine: TimeMachine

, + mode: Mode, } #[derive(Debug, Clone)] @@ -130,7 +137,7 @@ pub enum Message { } enum Mode { - None, + Hidden, Setup(Setup), } @@ -147,28 +154,29 @@ enum Goal { impl

DevTools

where P: Program + 'static, + P::Message: std::fmt::Debug + message::MaybeClone, { - fn new(state: P::State) -> (Self, Task) { + pub fn new(state: P::State) -> (Self, Task) { ( Self { state, - mode: Mode::None, + mode: Mode::Hidden, show_notification: true, time_machine: TimeMachine::new(), }, - executor::spawn_blocking(|mut sender| { + Task::batch([task::blocking(|mut sender| { thread::sleep(seconds(2)); let _ = sender.try_send(()); }) - .map(|_| Message::HideNotification), + .map(|_| Message::HideNotification)]), ) } - fn title(&self, program: &P, window: window::Id) -> String { + pub fn title(&self, program: &P, window: window::Id) -> String { program.title(&self.state, window) } - fn update(&mut self, program: &P, event: Event

) -> Task> { + pub fn update(&mut self, program: &P, event: Event

) -> Task> { match event { Event::Message(message) => match message { Message::HideNotification => { @@ -179,7 +187,7 @@ where Message::ToggleComet => { if let Mode::Setup(setup) = &self.mode { if matches!(setup, Setup::Idle { .. }) { - self.mode = Mode::None; + self.mode = Mode::Hidden; } Task::none() @@ -219,7 +227,6 @@ where .map(Message::Installing) .map(Event::Message) } - Message::Installing(Ok(installation)) => { let Mode::Setup(Setup::Running { logs }) = &mut self.mode else { @@ -232,7 +239,7 @@ where Task::none() } comet::install::Event::Finished => { - self.mode = Mode::None; + self.mode = Mode::Hidden; comet::launch().discard() } } @@ -255,7 +262,7 @@ where Task::none() } Message::CancelSetup => { - self.mode = Mode::None; + self.mode = Mode::Hidden; Task::none() } @@ -294,7 +301,7 @@ where } } - fn view( + pub fn view( &self, program: &P, window: window::Id, @@ -311,39 +318,36 @@ where } }; - fn derive_theme(theme: &T) -> Theme { - theme - .palette() + let theme = || { + program + .theme(state, window) + .as_ref() + .and_then(theme::Base::palette) .map(|palette| Theme::custom("iced devtools", palette)) - .unwrap_or(Theme::Dark) - } + }; - let mode = match &self.mode { - Mode::None => None, - Mode::Setup(setup) => { - let stage: Element<'_, _, Theme, P::Renderer> = match setup { - Setup::Idle { goal } => self::setup(goal), - Setup::Running { logs } => installation(logs), - }; + let setup = if let Mode::Setup(setup) = &self.mode { + let stage: Element<'_, _, Theme, P::Renderer> = match setup { + Setup::Idle { goal } => self::setup(goal), + Setup::Running { logs } => installation(logs), + }; - let setup = center( - container(stage) - .padding(20) - .max_width(500) - .style(container::bordered_box), - ) - .padding(10) - .style(|_theme| { - container::Style::default() - .background(Color::BLACK.scale_alpha(0.8)) - }); + let setup = center( + container(stage) + .padding(20) + .max_width(500) + .style(container::bordered_box), + ) + .padding(10) + .style(|_theme| { + container::Style::default() + .background(Color::BLACK.scale_alpha(0.8)) + }); - Some(setup) - } - } - .map(|mode| { - themer(derive_theme, Element::from(mode).map(Event::Message)) - }); + Some(themer(theme(), Element::from(setup).map(Event::Message))) + } else { + None + }; let notification = self .show_notification @@ -354,25 +358,25 @@ where "Types have changed. Restart to re-enable hotpatching.", ) }) - }); - - stack![view] - .height(Fill) - .push_maybe(mode.map(opaque)) - .push_maybe(notification.map(|notification| { + }) + .map(|notification| { themer( - derive_theme, + theme(), bottom_right(opaque( container(notification) .padding(10) .style(container::dark), )), ) - })) + }); + + stack![view, setup, notification] + .width(Fill) + .height(Fill) .into() } - fn subscription(&self, program: &P) -> Subscription> { + pub fn subscription(&self, program: &P) -> Subscription> { let subscription = program.subscription(&self.state).map(Event::Program); debug::subscriptions_tracked(subscription.units()); @@ -391,19 +395,19 @@ where Subscription::batch([subscription, hotkeys, commands]) } - fn theme(&self, program: &P, window: window::Id) -> Option { + pub fn theme(&self, program: &P, window: window::Id) -> Option { program.theme(self.state(), window) } - fn style(&self, program: &P, theme: &P::Theme) -> theme::Style { + pub fn style(&self, program: &P, theme: &P::Theme) -> theme::Style { program.style(self.state(), theme) } - fn scale_factor(&self, program: &P, window: window::Id) -> f32 { + pub fn scale_factor(&self, program: &P, window: window::Id) -> f32 { program.scale_factor(self.state(), window) } - fn state(&self) -> &P::State { + pub fn state(&self) -> &P::State { self.time_machine.state().unwrap_or(&self.state) } } @@ -421,6 +425,7 @@ where impl

fmt::Debug for Event

where P: Program, + P::Message: std::fmt::Debug, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -432,31 +437,16 @@ where } } -#[cfg(feature = "time-travel")] -impl

Clone for Event

-where - P: Program, -{ - fn clone(&self) -> Self { - match self { - Self::Message(message) => Self::Message(message.clone()), - Self::Program(message) => Self::Program(message.clone()), - Self::Command(command) => Self::Command(*command), - Self::Discard => Self::Discard, - } - } -} - fn setup(goal: &Goal) -> Element<'_, Message, Theme, Renderer> where - Renderer: core::text::Renderer + 'static, + Renderer: program::Renderer + 'static, { let controls = row![ button(text("Cancel").center().width(Fill)) .width(100) .on_press(Message::CancelSetup) .style(button::danger), - horizontal_space(), + space::horizontal(), button( text(match goal { Goal::Installation => "Install", @@ -478,7 +468,7 @@ where comet::COMPATIBLE_REVISION ) .size(14) - .font(Renderer::MONOSPACE_FONT), + .font(Font::MONOSPACE), ) .width(Fill) .padding(5) @@ -495,7 +485,7 @@ where your iced applications.", column![ "Do you wish to install it with the \ - following command?", + following command?", command ] .spacing(10), @@ -506,13 +496,13 @@ where let comparison = column![ row![ "Installed revision:", - horizontal_space(), + space::horizontal(), inline_code(revision.as_deref().unwrap_or("Unknown")) ] .align_y(Center), row![ "Compatible revision:", - horizontal_space(), + space::horizontal(), inline_code(comet::COMPATIBLE_REVISION), ] .align_y(Center) @@ -539,15 +529,15 @@ fn installation<'a, Renderer>( logs: &'a [String], ) -> Element<'a, Message, Theme, Renderer> where - Renderer: core::text::Renderer + 'a, + Renderer: program::Renderer + 'a, { column![ text("Installing comet...").size(20), container( scrollable( column(logs.iter().map(|log| { - text(log).size(12).font(Renderer::MONOSPACE_FONT).into() - }),) + text(log).size(12).font(Font::MONOSPACE).into() + })) .spacing(3), ) .spacing(10) @@ -566,9 +556,9 @@ fn inline_code<'a, Renderer>( code: impl text::IntoFragment<'a>, ) -> Element<'a, Message, Theme, Renderer> where - Renderer: core::text::Renderer + 'a, + Renderer: program::Renderer + 'a, { - container(text(code).font(Renderer::MONOSPACE_FONT).size(12)) + container(text(code).size(12).font(Font::MONOSPACE)) .style(|_theme| { container::Style::default() .background(Color::BLACK) diff --git a/devtools/src/time_machine.rs b/devtools/src/time_machine.rs index 6d8b80ae..e9e51874 100644 --- a/devtools/src/time_machine.rs +++ b/devtools/src/time_machine.rs @@ -13,6 +13,7 @@ where impl

TimeMachine

where P: Program, + P::Message: Clone, { pub fn new() -> Self { Self { @@ -30,6 +31,7 @@ where } pub fn rewind(&mut self, program: &P, message: usize) { + crate::debug::disable(); let (mut state, _) = program.boot(); if message < self.messages.len() { @@ -40,7 +42,6 @@ where } self.state = Some(state); - crate::debug::disable(); } pub fn go_to_present(&mut self) { diff --git a/examples/bezier_tool/src/main.rs b/examples/bezier_tool/src/main.rs index 9a525210..2523924a 100644 --- a/examples/bezier_tool/src/main.rs +++ b/examples/bezier_tool/src/main.rs @@ -1,5 +1,5 @@ //! This example showcases an interactive `Canvas` for drawing Bézier curves. -use iced::widget::{button, container, horizontal_space, hover, right}; +use iced::widget::{button, container, hover, right, space}; use iced::{Element, Theme}; pub fn main() -> iced::Result { @@ -38,7 +38,7 @@ impl Example { container(hover( self.bezier.view(&self.curves).map(Message::AddCurve), if self.curves.is_empty() { - container(horizontal_space()) + container(space::horizontal()) } else { right( button("Clear") diff --git a/examples/combo_box/src/main.rs b/examples/combo_box/src/main.rs index a7a68590..fc16d88b 100644 --- a/examples/combo_box/src/main.rs +++ b/examples/combo_box/src/main.rs @@ -1,6 +1,4 @@ -use iced::widget::{ - center, column, combo_box, scrollable, text, vertical_space, -}; +use iced::widget::{center, column, combo_box, scrollable, space, text}; use iced::{Center, Element, Fill}; pub fn main() -> iced::Result { @@ -62,7 +60,7 @@ impl Example { text(&self.text), "What is your language?", combo_box, - vertical_space().height(150), + space().height(150), ] .width(Fill) .align_x(Center) diff --git a/examples/visible_bounds/Cargo.toml b/examples/delineate/Cargo.toml similarity index 73% rename from examples/visible_bounds/Cargo.toml rename to examples/delineate/Cargo.toml index a11af963..ff181689 100644 --- a/examples/visible_bounds/Cargo.toml +++ b/examples/delineate/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "visible_bounds" +name = "delineate" version = "0.1.0" authors = ["Héctor Ramón Jiménez "] edition = "2024" @@ -7,4 +7,4 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["debug"] +iced.features = ["debug", "selector"] diff --git a/examples/visible_bounds/src/main.rs b/examples/delineate/src/main.rs similarity index 76% rename from examples/visible_bounds/src/main.rs rename to examples/delineate/src/main.rs index fa00a8fc..97bd7d62 100644 --- a/examples/visible_bounds/src/main.rs +++ b/examples/delineate/src/main.rs @@ -1,7 +1,7 @@ use iced::event::{self, Event}; use iced::mouse; use iced::widget::{ - column, container, horizontal_space, row, scrollable, text, vertical_space, + self, column, container, row, scrollable, selector, space, text, }; use iced::window; use iced::{ @@ -23,13 +23,13 @@ struct Example { inner_bounds: Option, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] enum Message { MouseMoved(Point), WindowResized, Scrolled, - OuterBoundsFetched(Option), - InnerBoundsFetched(Option), + OuterFound(Option), + InnerFound(Option), } impl Example { @@ -41,18 +41,16 @@ impl Example { Task::none() } Message::Scrolled | Message::WindowResized => Task::batch(vec![ - container::visible_bounds(OUTER_CONTAINER.clone()) - .map(Message::OuterBoundsFetched), - container::visible_bounds(INNER_CONTAINER.clone()) - .map(Message::InnerBoundsFetched), + selector::delineate(OUTER_CONTAINER).map(Message::OuterFound), + selector::delineate(INNER_CONTAINER).map(Message::InnerFound), ]), - Message::OuterBoundsFetched(outer_bounds) => { - self.outer_bounds = outer_bounds; + Message::OuterFound(outer) => { + self.outer_bounds = outer; Task::none() } - Message::InnerBoundsFetched(inner_bounds) => { - self.inner_bounds = inner_bounds; + Message::InnerFound(inner) => { + self.inner_bounds = inner; Task::none() } @@ -63,7 +61,7 @@ impl Example { let data_row = |label, value, color| { row![ text(label), - horizontal_space(), + space::horizontal(), text(value) .font(Font::MONOSPACE) .size(14) @@ -111,21 +109,21 @@ impl Example { scrollable( column![ text("Scroll me!"), - vertical_space().height(400), + space().height(400), container(text("I am the outer container!")) - .id(OUTER_CONTAINER.clone()) + .id(OUTER_CONTAINER) .padding(40) .style(container::rounded_box), - vertical_space().height(400), + space().height(400), scrollable( column![ text("Scroll me!"), - vertical_space().height(400), + space().height(400), container(text("I am the inner container!")) - .id(INNER_CONTAINER.clone()) + .id(INNER_CONTAINER) .padding(40) .style(container::rounded_box), - vertical_space().height(400), + space().height(400), ] .padding(20) ) @@ -157,9 +155,5 @@ impl Example { } } -use std::sync::LazyLock; - -static OUTER_CONTAINER: LazyLock = - LazyLock::new(|| container::Id::new("outer")); -static INNER_CONTAINER: LazyLock = - LazyLock::new(|| container::Id::new("inner")); +const OUTER_CONTAINER: widget::Id = widget::Id::new("outer"); +const INNER_CONTAINER: widget::Id = widget::Id::new("inner"); diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index e6243717..b854275f 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -1,8 +1,8 @@ use iced::highlighter; use iced::keyboard; use iced::widget::{ - self, button, center_x, column, container, horizontal_space, pick_list, - row, text, text_editor, toggler, tooltip, + button, center_x, column, container, operation, pick_list, row, space, + text, text_editor, toggler, tooltip, }; use iced::{Center, Element, Fill, Font, Task, Theme}; @@ -59,7 +59,7 @@ impl Editor { )), Message::FileOpened, ), - widget::focus_next(), + operation::focus_next(), ]), ) } @@ -157,7 +157,7 @@ impl Editor { "Save file", self.is_dirty.then_some(Message::SaveFile) ), - horizontal_space(), + space::horizontal(), toggler(self.word_wrap) .label("Word Wrap") .on_toggle(Message::WordWrapToggled), @@ -184,7 +184,7 @@ impl Editor { } else { String::from("New file") }), - horizontal_space(), + space::horizontal(), text({ let (line, column) = self.content.cursor_position(); diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index b24b1031..97f0ad89 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -15,7 +15,7 @@ pub struct Image { } impl Image { - pub const LIMIT: usize = 99; + pub const LIMIT: usize = 96; pub async fn list() -> Result, Error> { let client = reqwest::Client::new(); diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 14d20103..182fd606 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -9,8 +9,8 @@ use crate::civitai::{Error, Id, Image, Rgba, Size}; use iced::animation; use iced::time::{Instant, milliseconds}; use iced::widget::{ - button, container, float, grid, horizontal_space, image, mouse_area, - opaque, scrollable, sensor, stack, + button, container, float, grid, image, mouse_area, opaque, scrollable, + sensor, space, stack, }; use iced::window; use iced::{ @@ -227,7 +227,7 @@ fn card<'a>( }) .into() } else { - horizontal_space().into() + space::horizontal().into() }; if let Some(blurhash) = preview.blurhash(now) { @@ -241,7 +241,7 @@ fn card<'a>( thumbnail } } else { - horizontal_space().into() + space::horizontal().into() }; let card = mouse_area(container(image).style(container::dark)) @@ -264,7 +264,7 @@ fn card<'a>( } fn placeholder<'a>() -> Element<'a, Message> { - container(horizontal_space()).style(container::dark).into() + container(space()).style(container::dark).into() } enum Preview { @@ -415,35 +415,32 @@ impl Viewer { || self.image_fade_in.is_animating(now) } - fn view(&self, now: Instant) -> Element<'_, Message> { + fn view(&self, now: Instant) -> Option> { let opacity = self.background_fade_in.interpolate(0.0, 0.8, now); - let image: Element<'_, _> = if let Some(handle) = &self.image { + if opacity <= 0.0 { + return None; + } + + let image = self.image.as_ref().map(|handle| { image(handle) .width(Fill) .height(Fill) .opacity(self.image_fade_in.interpolate(0.0, 1.0, now)) .scale(self.image_fade_in.interpolate(1.5, 1.0, now)) - .into() - } else { - horizontal_space().into() - }; + }); - if opacity > 0.0 { - opaque( - mouse_area( - container(image) - .center(Fill) - .style(move |_theme| { - container::Style::default() - .background(color!(0x000000, opacity)) - }) - .padding(20), - ) - .on_press(Message::Close), + Some(opaque( + mouse_area( + container(image) + .center(Fill) + .style(move |_theme| { + container::Style::default() + .background(color!(0x000000, opacity)) + }) + .padding(20), ) - } else { - horizontal_space().into() - } + .on_press(Message::Close), + )) } } diff --git a/examples/gradient/src/main.rs b/examples/gradient/src/main.rs index 8c0c90d3..05505f35 100644 --- a/examples/gradient/src/main.rs +++ b/examples/gradient/src/main.rs @@ -1,8 +1,6 @@ use iced::gradient; use iced::theme; -use iced::widget::{ - checkbox, column, container, horizontal_space, row, slider, text, -}; +use iced::widget::{checkbox, column, container, row, slider, space, text}; use iced::{Center, Color, Element, Fill, Radians, Theme, color}; pub fn main() -> iced::Result { @@ -59,7 +57,7 @@ impl Gradient { transparent, } = *self; - let gradient_box = container(horizontal_space()) + let gradient_box = container(space()) .style(move |_theme| { let gradient = gradient::Linear::new(angle) .add_stop(0.0, start) diff --git a/examples/layout/src/main.rs b/examples/layout/src/main.rs index b930bdb0..c4faf319 100644 --- a/examples/layout/src/main.rs +++ b/examples/layout/src/main.rs @@ -2,9 +2,8 @@ use iced::border; use iced::keyboard; use iced::mouse; use iced::widget::{ - button, canvas, center, center_y, checkbox, column, container, - horizontal_rule, horizontal_space, pick_list, pin, row, scrollable, stack, - text, vertical_rule, + button, canvas, center, center_y, checkbox, column, container, pick_list, + pin, row, rule, scrollable, space, stack, text, }; use iced::{ Center, Element, Fill, Font, Length, Point, Rectangle, Renderer, Shrink, @@ -71,7 +70,7 @@ impl Layout { fn view(&self) -> Element<'_, Message> { let header = row![ text(self.example.title).size(20).font(Font::MONOSPACE), - horizontal_space(), + space::horizontal(), checkbox("Explain", self.explain) .on_toggle(Message::ExplainToggled), pick_list(Theme::ALL, self.theme.as_ref(), Message::ThemeSelected) @@ -93,23 +92,19 @@ impl Layout { }) .padding(4); - let controls = row([ + let controls = row![ (!self.example.is_first()).then_some( button(text("← Previous")) .padding([5, 10]) .on_press(Message::Previous) - .into(), ), - Some(horizontal_space().into()), + space::horizontal(), (!self.example.is_last()).then_some( button(text("Next →")) .padding([5, 10]) .on_press(Message::Next) - .into(), ), - ] - .into_iter() - .flatten()); + ]; column![header, example, controls] .spacing(10) @@ -144,7 +139,7 @@ impl Example { }, Self { title: "Space", - view: space, + view: space_, }, Self { title: "Application", @@ -238,17 +233,17 @@ fn row_<'a>() -> Element<'a, Message> { .into() } -fn space<'a>() -> Element<'a, Message> { - row!["Left!", horizontal_space(), "Right!"].into() +fn space_<'a>() -> Element<'a, Message> { + row!["Left!", space::horizontal(), "Right!"].into() } fn application<'a>() -> Element<'a, Message> { let header = container( row![ square(40), - horizontal_space(), + space::horizontal(), "Header!", - horizontal_space(), + space::horizontal(), square(40), ] .padding(10) @@ -295,7 +290,7 @@ fn quotes<'a>() -> Element<'a, Message> { fn quote<'a>( content: impl Into>, ) -> Element<'a, Message> { - row![vertical_rule(1), content.into()] + row![rule::vertical(1), content.into()] .spacing(10) .height(Shrink) .into() @@ -313,7 +308,7 @@ fn quotes<'a>() -> Element<'a, Message> { reply("This is the original message", "This is a reply"), "This is another reply", ), - horizontal_rule(1), + rule::horizontal(1), text("A separator ↑"), ] .width(Shrink) diff --git a/examples/lazy/src/main.rs b/examples/lazy/src/main.rs index 585b8c0a..6a48b655 100644 --- a/examples/lazy/src/main.rs +++ b/examples/lazy/src/main.rs @@ -1,6 +1,5 @@ use iced::widget::{ - button, column, horizontal_space, lazy, pick_list, row, scrollable, text, - text_input, + button, column, lazy, pick_list, row, scrollable, space, text, text_input, }; use iced::{Element, Fill}; @@ -174,7 +173,7 @@ impl App { row![ text(item.name.clone()).color(item.color), - horizontal_space(), + space::horizontal(), pick_list(Color::ALL, Some(item.color), move |color| { Message::ItemColorChanged(item.clone(), color) }), diff --git a/examples/loading_spinners/src/circular.rs b/examples/loading_spinners/src/circular.rs index c5d6df6f..a05d4e0a 100644 --- a/examples/loading_spinners/src/circular.rs +++ b/examples/loading_spinners/src/circular.rs @@ -21,7 +21,6 @@ const MIN_ANGLE: Radians = Radians(PI / 8.0); const WRAP_ANGLE: Radians = Radians(2.0 * PI - PI / 4.0); const BASE_ROTATION_SPEED: u32 = u32::MAX / 80; -#[allow(missing_debug_implementations)] pub struct Circular<'a, Theme> where Theme: StyleSheet, diff --git a/examples/loading_spinners/src/linear.rs b/examples/loading_spinners/src/linear.rs index e9b4a20e..551d0111 100644 --- a/examples/loading_spinners/src/linear.rs +++ b/examples/loading_spinners/src/linear.rs @@ -12,7 +12,6 @@ use super::easing::{self, Easing}; use std::time::Duration; -#[allow(missing_debug_implementations)] pub struct Linear<'a, Theme> where Theme: StyleSheet, diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index b7195055..6e2a79b9 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -5,8 +5,8 @@ use iced::clipboard; use iced::highlighter; use iced::time::{self, Instant, milliseconds}; use iced::widget::{ - self, button, center_x, container, horizontal_space, hover, image, - markdown, right, row, scrollable, sensor, text_editor, toggler, + button, center_x, container, hover, image, markdown, operation, right, row, + scrollable, sensor, space, text_editor, toggler, }; use iced::window; use iced::{ @@ -78,7 +78,7 @@ impl Markdown { theme: Theme::TokyoNight, now: Instant::now(), }, - widget::focus_next(), + operation::focus_next(), ) } @@ -140,10 +140,7 @@ impl Markdown { pending: self.raw.text(), }; - scrollable::snap_to( - "preview", - scrollable::RelativeOffset::END, - ) + operation::snap_to_end("preview") } else { self.mode = Mode::Preview; @@ -267,7 +264,7 @@ impl<'a> markdown::Viewer<'a, Message> for CustomViewer<'a> { ) .into() } else { - sensor(horizontal_space()) + sensor(space()) .key_ref(url.as_str()) .delay(milliseconds(500)) .on_show(|_size| Message::ImageShown(url.clone())) diff --git a/examples/modal/src/main.rs b/examples/modal/src/main.rs index b4355004..daf288e0 100644 --- a/examples/modal/src/main.rs +++ b/examples/modal/src/main.rs @@ -2,8 +2,8 @@ use iced::event::{self, Event}; use iced::keyboard; use iced::keyboard::key; use iced::widget::{ - self, button, center, column, container, horizontal_space, mouse_area, - opaque, pick_list, row, stack, text, text_input, + button, center, column, container, mouse_area, opaque, operation, + pick_list, row, space, stack, text, text_input, }; use iced::{Bottom, Color, Element, Fill, Subscription, Task}; @@ -43,7 +43,7 @@ impl App { match message { Message::ShowModal => { self.show_modal = true; - widget::focus_next() + operation::focus_next() } Message::HideModal => { self.hide_modal(); @@ -75,9 +75,9 @@ impl App { .. }) => { if modifiers.shift() { - widget::focus_previous() + operation::focus_previous() } else { - widget::focus_next() + operation::focus_next() } } Event::Keyboard(keyboard::Event::KeyPressed { @@ -95,12 +95,12 @@ impl App { fn view(&self) -> Element<'_, Message> { let content = container( column![ - row![text("Top Left"), horizontal_space(), text("Top Right")] + row![text("Top Left"), space::horizontal(), text("Top Right")] .height(Fill), center(button(text("Show Modal")).on_press(Message::ShowModal)), row![ text("Bottom Left"), - horizontal_space(), + space::horizontal(), text("Bottom Right") ] .align_y(Bottom) diff --git a/examples/multi_window/src/main.rs b/examples/multi_window/src/main.rs index 42082f21..a834a617 100644 --- a/examples/multi_window/src/main.rs +++ b/examples/multi_window/src/main.rs @@ -1,5 +1,5 @@ use iced::widget::{ - button, center, center_x, column, container, horizontal_space, scrollable, + button, center, center_x, column, container, operation, scrollable, space, text, text_input, }; use iced::window; @@ -42,7 +42,7 @@ enum Message { impl Example { fn new() -> (Self, Task) { - let (_id, open) = window::open(window::Settings::default()); + let (_, open) = window::open(window::Settings::default()); ( Self { @@ -77,7 +77,7 @@ impl Example { }, ); - let (_id, open) = window::open(window::Settings { + let (_, open) = window::open(window::Settings { position, ..window::Settings::default() }); @@ -88,7 +88,7 @@ impl Example { } Message::WindowOpened(id) => { let window = Window::new(self.windows.len() + 1); - let focus_input = text_input::focus(format!("input-{id}")); + let focus_input = operation::focus(format!("input-{id}")); self.windows.insert(id, window); @@ -134,7 +134,7 @@ impl Example { if let Some(window) = self.windows.get(&window_id) { center(window.view(window_id)).into() } else { - horizontal_space().into() + space().into() } } diff --git a/examples/pick_list/src/main.rs b/examples/pick_list/src/main.rs index 1023a30a..f67e3a6c 100644 --- a/examples/pick_list/src/main.rs +++ b/examples/pick_list/src/main.rs @@ -1,4 +1,4 @@ -use iced::widget::{column, pick_list, scrollable, vertical_space}; +use iced::widget::{column, pick_list, scrollable, space}; use iced::{Center, Element, Fill}; pub fn main() -> iced::Result { @@ -33,10 +33,10 @@ impl Example { .placeholder("Choose a language..."); let content = column![ - vertical_space().height(600), + space().height(600), "Which is your favorite language?", pick_list, - vertical_space().height(600), + space().height(600), ] .width(Fill) .align_x(Center) diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 087f8e51..17b1fad2 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -1,14 +1,9 @@ use iced::widget::{ - button, column, container, horizontal_space, progress_bar, radio, row, - scrollable, slider, text, vertical_space, + button, column, container, operation, progress_bar, radio, row, scrollable, + slider, space, text, }; use iced::{Border, Center, Color, Element, Fill, Task, Theme}; -use std::sync::LazyLock; - -static SCROLLABLE_ID: LazyLock = - LazyLock::new(scrollable::Id::unique); - pub fn main() -> iced::Result { iced::application( ScrollableDemo::default, @@ -65,19 +60,13 @@ impl ScrollableDemo { self.current_scroll_offset = scrollable::RelativeOffset::START; self.scrollable_direction = direction; - scrollable::snap_to( - SCROLLABLE_ID.clone(), - self.current_scroll_offset, - ) + operation::snap_to(SCROLLABLE, self.current_scroll_offset) } Message::AlignmentChanged(alignment) => { self.current_scroll_offset = scrollable::RelativeOffset::START; self.anchor = alignment; - scrollable::snap_to( - SCROLLABLE_ID.clone(), - self.current_scroll_offset, - ) + operation::snap_to(SCROLLABLE, self.current_scroll_offset) } Message::ScrollbarWidthChanged(width) => { self.scrollbar_width = width; @@ -97,18 +86,12 @@ impl ScrollableDemo { Message::ScrollToBeginning => { self.current_scroll_offset = scrollable::RelativeOffset::START; - scrollable::snap_to( - SCROLLABLE_ID.clone(), - self.current_scroll_offset, - ) + operation::snap_to(SCROLLABLE, self.current_scroll_offset) } Message::ScrollToEnd => { self.current_scroll_offset = scrollable::RelativeOffset::END; - scrollable::snap_to( - SCROLLABLE_ID.clone(), - self.current_scroll_offset, - ) + operation::snap_to(SCROLLABLE, self.current_scroll_offset) } Message::Scrolled(viewport) => { self.current_scroll_offset = viewport.relative_offset(); @@ -207,9 +190,9 @@ impl ScrollableDemo { column![ scroll_to_end_button(), text("Beginning!"), - vertical_space().height(1200), + space().height(1200), text("Middle!"), - vertical_space().height(1200), + space().height(1200), text("End!"), scroll_to_beginning_button(), ] @@ -226,15 +209,15 @@ impl ScrollableDemo { )) .width(Fill) .height(Fill) - .id(SCROLLABLE_ID.clone()) + .id(SCROLLABLE) .on_scroll(Message::Scrolled), Direction::Horizontal => scrollable( row![ scroll_to_end_button(), text("Beginning!"), - horizontal_space().width(1200), + space().width(1200), text("Middle!"), - horizontal_space().width(1200), + space().width(1200), text("End!"), scroll_to_beginning_button(), ] @@ -252,32 +235,32 @@ impl ScrollableDemo { )) .width(Fill) .height(Fill) - .id(SCROLLABLE_ID.clone()) + .id(SCROLLABLE) .on_scroll(Message::Scrolled), Direction::Multi => scrollable( //horizontal content row![ column![ text("Let's do some scrolling!"), - vertical_space().height(2400) + space().height(2400) ], scroll_to_end_button(), text("Horizontal - Beginning!"), - horizontal_space().width(1200), + space().width(1200), //vertical content column![ text("Horizontal - Middle!"), scroll_to_end_button(), text("Vertical - Beginning!"), - vertical_space().height(1200), + space().height(1200), text("Vertical - Middle!"), - vertical_space().height(1200), + space().height(1200), text("Vertical - End!"), scroll_to_beginning_button(), - vertical_space().height(40), + space().height(40), ] .spacing(40), - horizontal_space().width(1200), + space().width(1200), text("Horizontal - End!"), scroll_to_beginning_button(), ] @@ -299,7 +282,7 @@ impl ScrollableDemo { }) .width(Fill) .height(Fill) - .id(SCROLLABLE_ID.clone()) + .id(SCROLLABLE) .on_scroll(Message::Scrolled), }); @@ -348,3 +331,5 @@ fn progress_bar_custom_style(theme: &Theme) -> progress_bar::Style { border: Border::default(), } } + +const SCROLLABLE: &str = "scrollable"; diff --git a/examples/styling/snapshots/kanagawa_lotus-tiny-skia.sha256 b/examples/styling/snapshots/kanagawa_lotus-tiny-skia.sha256 index 34f7aa20..d17b3745 100644 --- a/examples/styling/snapshots/kanagawa_lotus-tiny-skia.sha256 +++ b/examples/styling/snapshots/kanagawa_lotus-tiny-skia.sha256 @@ -1 +1 @@ -ab8f9190260837ba9b0cf30f072116e86be1c90197a56ad00da6de60a618a3b8 \ No newline at end of file +896072b46221f83e1edaa37574436af6474969625f5c1a41cc5ddc2e20823cee \ No newline at end of file diff --git a/examples/styling/snapshots/solarized_light-tiny-skia.sha256 b/examples/styling/snapshots/solarized_light-tiny-skia.sha256 index 89ccf263..5862204a 100644 --- a/examples/styling/snapshots/solarized_light-tiny-skia.sha256 +++ b/examples/styling/snapshots/solarized_light-tiny-skia.sha256 @@ -1 +1 @@ -ddee619e66418803c64ed5677fd375ad596e234ab9541ab197f17c81e2100279 \ No newline at end of file +2010df2e80bfc72e7e9274de07b77dc4843485f6be38266fdfb7a4f129d75da1 \ No newline at end of file diff --git a/examples/styling/src/main.rs b/examples/styling/src/main.rs index e5805532..f979f6f2 100644 --- a/examples/styling/src/main.rs +++ b/examples/styling/src/main.rs @@ -1,8 +1,8 @@ use iced::keyboard; use iced::widget::{ - button, center_x, center_y, checkbox, column, container, horizontal_rule, - pick_list, progress_bar, row, scrollable, slider, text, text_input, - toggler, vertical_rule, vertical_space, + button, center_x, center_y, checkbox, column, container, pick_list, + progress_bar, row, rule, scrollable, slider, space, text, text_input, + toggler, }; use iced::{Center, Element, Fill, Shrink, Subscription, Theme}; @@ -127,7 +127,7 @@ impl Styling { let scroll_me = scrollable(column![ "Scroll me!", - vertical_space().height(800), + space().height(800), "You did it!" ]) .width(Fill) @@ -162,14 +162,14 @@ impl Styling { let content = column![ choose_theme, - horizontal_rule(1), + rule::horizontal(1), text_input, buttons, slider(), progress_bar(), row![ scroll_me, - vertical_rule(1), + rule::vertical(1), column![check, check_disabled, toggle, disabled_toggle] .spacing(10) ] diff --git a/examples/toast/src/main.rs b/examples/toast/src/main.rs index dc8c2593..cca0e789 100644 --- a/examples/toast/src/main.rs +++ b/examples/toast/src/main.rs @@ -2,7 +2,7 @@ use iced::event::{self, Event}; use iced::keyboard; use iced::keyboard::key; use iced::widget::{ - self, button, center, column, pick_list, row, slider, text, text_input, + button, center, column, operation, pick_list, row, slider, text, text_input, }; use iced::{Center, Element, Fill, Subscription, Task}; @@ -83,11 +83,11 @@ impl App { key: keyboard::Key::Named(key::Named::Tab), modifiers, .. - })) if modifiers.shift() => widget::focus_previous(), + })) if modifiers.shift() => operation::focus_previous(), Message::Event(Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(key::Named::Tab), .. - })) => widget::focus_next(), + })) => operation::focus_next(), Message::Event(_) => Task::none(), } } @@ -171,9 +171,7 @@ mod toast { use iced::mouse; use iced::theme; use iced::time::{self, Duration, Instant}; - use iced::widget::{ - button, column, container, horizontal_rule, horizontal_space, row, text, - }; + use iced::widget::{button, column, container, row, rule, space, text}; use iced::window; use iced::{ Alignment, Center, Element, Event, Fill, Length, Point, Rectangle, @@ -239,7 +237,7 @@ mod toast { container( row![ text(toast.title.as_str()), - horizontal_space(), + space::horizontal(), button("X") .on_press((on_close)(index)) .padding(3), @@ -254,7 +252,7 @@ mod toast { Status::Success => success, Status::Danger => danger, }), - horizontal_rule(1), + rule::horizontal(1), container(text(toast.body.as_str())) .width(Fill) .padding(5) @@ -349,7 +347,8 @@ mod toast { renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.content.as_widget_mut().operate( &mut state.children[0], layout, @@ -580,7 +579,8 @@ mod toast { renderer: &Renderer, operation: &mut dyn widget::Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.toasts .iter_mut() .zip(self.state.iter_mut()) diff --git a/examples/todos/Cargo.toml b/examples/todos/Cargo.toml index 5e16a2ac..f47cd861 100644 --- a/examples/todos/Cargo.toml +++ b/examples/todos/Cargo.toml @@ -5,6 +5,9 @@ authors = ["Héctor Ramón Jiménez "] edition = "2024" publish = false +[features] +tester = ["iced/tester"] + [dependencies] iced.workspace = true iced.features = ["tokio", "debug", "time-travel"] diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 31f20ab6..168c5c81 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -1,11 +1,13 @@ use iced::keyboard; +use iced::time::milliseconds; use iced::widget::{ - self, Text, button, center, center_x, checkbox, column, keyed_column, row, - scrollable, text, text_input, + self, Text, button, center, center_x, checkbox, column, keyed_column, + operation, row, scrollable, text, text_input, }; use iced::window; use iced::{ - Center, Element, Fill, Font, Function, Subscription, Task as Command, Theme, + Application, Center, Element, Fill, Font, Function, Preset, Program, + Subscription, Task as Command, Theme, }; use serde::{Deserialize, Serialize}; @@ -15,12 +17,16 @@ pub fn main() -> iced::Result { #[cfg(not(target_arch = "wasm32"))] tracing_subscriber::fmt::init(); + application().run() +} + +fn application() -> Application> { iced::application(Todos::new, Todos::update, Todos::view) .subscription(Todos::subscription) .title(Todos::title) .font(Todos::ICON_FONT) .window_size((500.0, 800.0)) - .run() + .presets(presets()) } #[derive(Debug)] @@ -87,7 +93,7 @@ impl Todos { _ => {} } - text_input::focus("new-task") + operation::focus("new-task") } Todos::Loaded(state) => { let mut saved = false; @@ -128,8 +134,8 @@ impl Todos { if should_focus { let id = Task::text_input_id(i); Command::batch(vec![ - text_input::focus(id.clone()), - text_input::select_all(id), + operation::focus(id.clone()), + operation::select_all(id), ]) } else { Command::none() @@ -146,9 +152,9 @@ impl Todos { } Message::TabPressed { shift } => { if shift { - widget::focus_previous() + operation::focus_previous() } else { - widget::focus_next() + operation::focus_next() } } Message::ToggleFullscreen(mode) => window::latest() @@ -301,8 +307,8 @@ pub enum TaskMessage { } impl Task { - fn text_input_id(i: usize) -> text_input::Id { - text_input::Id::new(format!("task-{i}")) + fn text_input_id(i: usize) -> widget::Id { + widget::Id::from(format!("task-{i}")) } fn new(description: String) -> Self { @@ -539,8 +545,8 @@ impl SavedState { .map_err(|_| SaveError::Write)?; } - // This is a simple way to save at most once every couple seconds - tokio::time::sleep(std::time::Duration::from_secs(2)).await; + // This is a simple way to save at most twice every second + tokio::time::sleep(milliseconds(500)).await; Ok(()) } @@ -582,6 +588,32 @@ impl SavedState { } } +fn presets() -> impl IntoIterator> { + [ + Preset::new("Empty", || { + (Todos::Loaded(State::default()), Command::none()) + }), + Preset::new("Carl Sagan", || { + ( + Todos::Loaded(State { + input_value: "Make an apple pie".to_owned(), + filter: Filter::All, + tasks: vec![Task { + id: Uuid::new_v4(), + description: "Create the universe".to_owned(), + completed: false, + state: TaskState::Idle, + }], + dirty: false, + saving: false, + }), + Command::none(), + ) + }), + ] + .into_iter() +} + #[cfg(test)] mod tests { use super::*; @@ -627,4 +659,13 @@ mod tests { Ok(()) } + + #[test] + #[ignore] + fn it_passes_the_ice_tests() -> Result<(), Error> { + iced_test::run( + application(), + format!("{}/tests", env!("CARGO_MANIFEST_DIR")), + ) + } } diff --git a/examples/todos/tests/carl_sagan.ice b/examples/todos/tests/carl_sagan.ice new file mode 100644 index 00000000..16d9bd4f --- /dev/null +++ b/examples/todos/tests/carl_sagan.ice @@ -0,0 +1,14 @@ +viewport: 500x800 +mode: Immediate +preset: Empty +----- +click "What needs to be done?" +type "Create the universe" +type enter +type "Make an apple pie" +type enter +expect "2 tasks left" +click "Create the universe" +expect "1 task left" +click "Make an apple pie" +expect "0 tasks left" diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index 78c329b5..fbefa6ad 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -1,8 +1,7 @@ use iced::widget::{Button, Column, Container, Slider}; use iced::widget::{ - button, center_x, center_y, checkbox, column, horizontal_space, image, - radio, rich_text, row, scrollable, slider, span, text, text_input, toggler, - vertical_space, + button, center_x, center_y, checkbox, column, image, radio, rich_text, row, + scrollable, slider, space, span, text, text_input, toggler, }; use iced::{Center, Color, Element, Fill, Font, Pixels, color}; @@ -147,7 +146,7 @@ impl Tour { .on_press(Message::BackPressed) .style(button::secondary) }), - horizontal_space(), + space::horizontal(), self.can_continue().then(|| { padded_button("Next").on_press(Message::NextPressed) }) @@ -406,14 +405,14 @@ impl Tour { text("Tip: You can use the scrollbar to scroll down faster!") .size(16), ) - .push(vertical_space().height(4096)) + .push(space().height(4096)) .push( text("You are halfway there!") .width(Fill) .size(30) .align_x(Center), ) - .push(vertical_space().height(4096)) + .push(space().height(4096)) .push(ferris(300, image::FilterMethod::Linear)) .push(text("You made it!").width(Fill).size(50).align_x(Center)) } diff --git a/examples/vectorial_text/src/main.rs b/examples/vectorial_text/src/main.rs index 78349696..bf0fd657 100644 --- a/examples/vectorial_text/src/main.rs +++ b/examples/vectorial_text/src/main.rs @@ -1,8 +1,6 @@ use iced::alignment; use iced::mouse; -use iced::widget::{ - canvas, checkbox, column, horizontal_space, row, slider, text, -}; +use iced::widget::{canvas, checkbox, column, row, slider, space, text}; use iced::{Center, Element, Fill, Point, Rectangle, Renderer, Theme, Vector}; pub fn main() -> iced::Result { @@ -51,7 +49,7 @@ impl VectorialText { fn view(&self) -> Element<'_, Message> { let slider_with_label = |label, range, value, message: fn(f32) -> _| { column![ - row![text(label), horizontal_space(), text!("{:.2}", value)], + row![text(label), space::horizontal(), text!("{:.2}", value)], slider(range, value, message).step(0.01) ] .spacing(2) diff --git a/examples/websocket/src/echo.rs b/examples/websocket/src/echo.rs index 1116d1ea..85b3caf6 100644 --- a/examples/websocket/src/echo.rs +++ b/examples/websocket/src/echo.rs @@ -30,7 +30,6 @@ pub fn connect() -> impl Sipper { tokio::time::sleep(tokio::time::Duration::from_secs(1)) .await; - output.send(Event::Disconnected).await; continue; } }; diff --git a/examples/websocket/src/main.rs b/examples/websocket/src/main.rs index 1c034ee4..1968843a 100644 --- a/examples/websocket/src/main.rs +++ b/examples/websocket/src/main.rs @@ -1,10 +1,9 @@ mod echo; use iced::widget::{ - self, button, center, column, row, scrollable, text, text_input, + button, center, column, operation, row, scrollable, text, text_input, }; use iced::{Center, Element, Fill, Subscription, Task, color}; -use std::sync::LazyLock; pub fn main() -> iced::Result { iced::application(WebSocket::new, WebSocket::update, WebSocket::view) @@ -23,7 +22,6 @@ enum Message { NewMessageChanged(String), Send(echo::Message), Echo(echo::Event), - Server, } impl WebSocket { @@ -35,8 +33,8 @@ impl WebSocket { state: State::Disconnected, }, Task::batch([ - Task::perform(echo::server::run(), |_| Message::Server), - widget::focus_next(), + Task::future(echo::server::run()).discard(), + operation::focus_next(), ]), ) } @@ -76,13 +74,9 @@ impl WebSocket { echo::Event::MessageReceived(message) => { self.messages.push(message); - scrollable::snap_to( - MESSAGE_LOG.clone(), - scrollable::RelativeOffset::END, - ) + operation::snap_to_end(MESSAGE_LOG) } }, - Message::Server => Task::none(), } } @@ -102,7 +96,7 @@ impl WebSocket { column(self.messages.iter().map(text).map(Element::from)) .spacing(10), ) - .id(MESSAGE_LOG.clone()) + .id(MESSAGE_LOG) .height(Fill) .spacing(10) .into() @@ -139,5 +133,4 @@ enum State { Connected(echo::Connection), } -static MESSAGE_LOG: LazyLock = - LazyLock::new(scrollable::Id::unique); +const MESSAGE_LOG: &str = "message_log"; diff --git a/futures/src/runtime.rs b/futures/src/runtime.rs index e25ba1d7..927b35db 100644 --- a/futures/src/runtime.rs +++ b/futures/src/runtime.rs @@ -2,7 +2,7 @@ use crate::subscription; use crate::{BoxStream, Executor, MaybeSend}; -use futures::{Sink, channel::mpsc}; +use futures::{Sink, SinkExt, channel::mpsc}; use std::marker::PhantomData; /// A batteries-included runtime of commands and subscriptions. @@ -79,6 +79,15 @@ where self.executor.spawn(future); } + /// Sends a message concurrently through the [`Runtime`]. + pub fn send(&mut self, message: Message) { + let mut sender = self.sender.clone(); + + self.executor.spawn(async move { + let _ = sender.send(message).await; + }); + } + /// Tracks a [`Subscription`] in the [`Runtime`]. /// /// It will spawn new streams or close old ones as necessary! See diff --git a/graphics/src/geometry/frame.rs b/graphics/src/geometry/frame.rs index 5372ec16..fb7aae82 100644 --- a/graphics/src/geometry/frame.rs +++ b/graphics/src/geometry/frame.rs @@ -3,7 +3,6 @@ use crate::core::{Point, Radians, Rectangle, Size, Vector}; use crate::geometry::{self, Fill, Image, Path, Stroke, Svg, Text}; /// The region of a surface that can be used to draw geometry. -#[allow(missing_debug_implementations)] pub struct Frame where Renderer: geometry::Renderer, diff --git a/graphics/src/geometry/path/builder.rs b/graphics/src/geometry/path/builder.rs index e814a3a7..2f389595 100644 --- a/graphics/src/geometry/path/builder.rs +++ b/graphics/src/geometry/path/builder.rs @@ -10,7 +10,6 @@ use lyon_path::math; /// A [`Path`] builder. /// /// Once a [`Path`] is built, it can no longer be mutated. -#[allow(missing_debug_implementations)] pub struct Builder { raw: builder::WithSvg, } diff --git a/graphics/src/text.rs b/graphics/src/text.rs index 9f932661..74ab6b84 100644 --- a/graphics/src/text.rs +++ b/graphics/src/text.rs @@ -134,7 +134,6 @@ pub fn font_system() -> &'static RwLock { } /// A set of system fonts. -#[allow(missing_debug_implementations)] pub struct FontSystem { raw: cosmic_text::FontSystem, loaded_fonts: HashSet, diff --git a/program/Cargo.toml b/program/Cargo.toml index 7aa6414d..76106c25 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -14,6 +14,7 @@ rust-version.workspace = true workspace = true [features] +debug = [] time-travel = [] [dependencies] diff --git a/program/src/lib.rs b/program/src/lib.rs index 3b516770..800a1422 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -4,10 +4,17 @@ pub use iced_runtime as runtime; pub use iced_runtime::core; pub use iced_runtime::futures; -use crate::core::Element; +pub mod message; + +mod preset; + +pub use preset::Preset; + +use crate::core::renderer; use crate::core::text; use crate::core::theme; use crate::core::window; +use crate::core::{Element, Font, Settings}; use crate::futures::{Executor, Subscription}; use crate::graphics::compositor; use crate::runtime::Task; @@ -22,7 +29,7 @@ pub trait Program: Sized { type State; /// The message of the program. - type Message: Message + 'static; + type Message: Send + 'static; /// The theme of the program. type Theme: theme::Base; @@ -36,6 +43,10 @@ pub trait Program: Sized { /// Returns the unique name of the [`Program`]. fn name() -> &'static str; + fn settings(&self) -> Settings; + + fn window(&self) -> Option; + fn boot(&self) -> (Self::State, Task); fn update( @@ -101,6 +112,10 @@ pub trait Program: Sized { fn scale_factor(&self, _state: &Self::State, _window: window::Id) -> f32 { 1.0 } + + fn presets(&self) -> &[Preset] { + &[] + } } /// Decorates a [`Program`] with the given title function. @@ -132,6 +147,14 @@ pub fn with_title( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -214,6 +237,14 @@ pub fn with_subscription( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -297,6 +328,14 @@ pub fn with_theme( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -376,6 +415,14 @@ pub fn with_style( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -451,6 +498,14 @@ pub fn with_scale_factor( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -534,6 +589,14 @@ pub fn with_executor( P::name() } + fn settings(&self) -> Settings { + self.program.settings() + } + + fn window(&self) -> Option { + self.program.window() + } + fn boot(&self) -> (Self::State, Task) { self.program.boot() } @@ -589,12 +652,17 @@ pub fn with_executor( } /// The renderer of some [`Program`]. -pub trait Renderer: text::Renderer + compositor::Default {} +pub trait Renderer: + text::Renderer + compositor::Default + renderer::Headless +{ +} -impl Renderer for T where T: text::Renderer + compositor::Default {} +impl Renderer for T where + T: text::Renderer + compositor::Default + renderer::Headless +{ +} /// A particular instance of a running [`Program`]. -#[allow(missing_debug_implementations)] pub struct Instance { program: P, state: P::State, @@ -646,17 +714,3 @@ impl Instance

{ self.program.scale_factor(&self.state, window) } } - -/// A trait alias for the [`Message`](Program::Message) of a [`Program`]. -#[cfg(feature = "time-travel")] -pub trait Message: Send + std::fmt::Debug + Clone {} - -#[cfg(feature = "time-travel")] -impl Message for T {} - -/// A trait alias for the [`Message`](Program::Message) of a [`Program`]. -#[cfg(not(feature = "time-travel"))] -pub trait Message: Send + std::fmt::Debug {} - -#[cfg(not(feature = "time-travel"))] -impl Message for T {} diff --git a/program/src/message.rs b/program/src/message.rs new file mode 100644 index 00000000..15dfafed --- /dev/null +++ b/program/src/message.rs @@ -0,0 +1,33 @@ +//! Traits for the message type of a [`Program`](crate::Program). + +/// A trait alias for [`Clone`], but only when the `time-travel` +/// feature is enabled. +#[cfg(feature = "time-travel")] +pub trait MaybeClone: Clone {} + +#[cfg(feature = "time-travel")] +impl MaybeClone for T where T: Clone {} + +/// A trait alias for [`Clone`], but only when the `time-travel` +/// feature is enabled. +#[cfg(not(feature = "time-travel"))] +pub trait MaybeClone {} + +#[cfg(not(feature = "time-travel"))] +impl MaybeClone for T {} + +/// A trait alias for [`Debug`](std::fmt::Debug), but only when the +/// `debug` feature is enabled. +#[cfg(feature = "debug")] +pub trait MaybeDebug: std::fmt::Debug {} + +#[cfg(feature = "debug")] +impl MaybeDebug for T where T: std::fmt::Debug {} + +/// A trait alias for [`Debug`](std::fmt::Debug), but only when the +/// `debug` feature is enabled. +#[cfg(not(feature = "debug"))] +pub trait MaybeDebug {} + +#[cfg(not(feature = "debug"))] +impl MaybeDebug for T {} diff --git a/program/src/preset.rs b/program/src/preset.rs new file mode 100644 index 00000000..ba983f67 --- /dev/null +++ b/program/src/preset.rs @@ -0,0 +1,42 @@ +use crate::runtime::Task; + +use std::borrow::Cow; +use std::fmt; + +/// A specific boot strategy for a [`Program`](crate::Program). +pub struct Preset { + name: Cow<'static, str>, + boot: Box (State, Task)>, +} + +impl Preset { + /// Creates a new [`Preset`] with the given name and boot strategy. + pub fn new( + name: impl Into>, + boot: impl Fn() -> (State, Task) + 'static, + ) -> Self { + Self { + name: name.into(), + boot: Box::new(boot), + } + } + + /// Returns the name of the [`Preset`]. + pub fn name(&self) -> &str { + &self.name + } + + /// Boots the [`Preset`], returning the initial [`Program`](crate::Program) state and + /// a [`Task`] for concurrent booting. + pub fn boot(&self) -> (State, Task) { + (self.boot)() + } +} + +impl fmt::Debug for Preset { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Preset") + .field("name", &self.name) + .finish_non_exhaustive() + } +} diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index d018a242..5e775e38 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -84,7 +84,6 @@ where type Paragraph = A::Paragraph; type Editor = A::Editor; - const MONOSPACE_FONT: Self::Font = A::MONOSPACE_FONT; const ICON_FONT: Self::Font = A::ICON_FONT; const CHECKMARK_ICON: char = A::CHECKMARK_ICON; const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON; diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 2dc60474..baddbfa4 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -10,6 +10,9 @@ homepage.workspace = true categories.workspace = true keywords.workspace = true +[features] +selector = ["dep:iced_selector"] + [lints] workspace = true @@ -17,7 +20,6 @@ workspace = true bytes.workspace = true iced_core.workspace = true iced_debug.workspace = true - iced_futures.workspace = true raw-window-handle.workspace = true @@ -25,3 +27,6 @@ thiserror.workspace = true sipper.workspace = true sipper.optional = true + +iced_selector.workspace = true +iced_selector.optional = true diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 457f723c..af43edce 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -12,10 +12,10 @@ pub mod clipboard; pub mod font; pub mod keyboard; -pub mod overlay; pub mod system; pub mod task; pub mod user_interface; +pub mod widget; pub mod window; pub use iced_core as core; @@ -25,7 +25,6 @@ pub use iced_futures as futures; pub use task::Task; pub use user_interface::UserInterface; -use crate::core::widget; use crate::futures::futures::channel::oneshot; use std::borrow::Cow; @@ -45,7 +44,7 @@ pub enum Action { }, /// Run a widget operation. - Widget(Box), + Widget(Box), /// Run a clipboard action. Clipboard(clipboard::Action), @@ -67,8 +66,8 @@ pub enum Action { } impl Action { - /// Creates a new [`Action::Widget`] with the given [`widget::Operation`]. - pub fn widget(operation: impl widget::Operation + 'static) -> Self { + /// Creates a new [`Action::Widget`] with the given [`widget::Operation`](core::widget::Operation). + pub fn widget(operation: impl core::widget::Operation + 'static) -> Self { Self::Widget(Box::new(operation)) } diff --git a/runtime/src/overlay.rs b/runtime/src/overlay.rs deleted file mode 100644 index 03390980..00000000 --- a/runtime/src/overlay.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Overlays for user interfaces. -mod nested; - -pub use nested::Nested; diff --git a/runtime/src/task.rs b/runtime/src/task.rs index 34ee1df7..7ac9befe 100644 --- a/runtime/src/task.rs +++ b/runtime/src/task.rs @@ -9,6 +9,7 @@ use crate::futures::{BoxStream, MaybeSend, boxed_stream}; use std::convert::Infallible; use std::sync::Arc; +use std::thread; #[cfg(feature = "sipper")] #[doc(no_inline)] @@ -466,3 +467,47 @@ pub fn effect(action: impl Into>) -> Task { pub fn into_stream(task: Task) -> Option>> { task.stream } + +/// Creates a new [`Task`] that will run the given closure in a new thread. +/// +/// Any data sent by the closure through the [`mpsc::Sender`] will be produced +/// by the [`Task`]. +pub fn blocking(f: impl FnOnce(mpsc::Sender) + Send + 'static) -> Task +where + T: Send + 'static, +{ + let (sender, receiver) = mpsc::channel(1); + + let _ = thread::spawn(move || { + f(sender); + }); + + Task::stream(receiver) +} + +/// Creates a new [`Task`] that will run the given closure that can fail in a new +/// thread. +/// +/// Any data sent by the closure through the [`mpsc::Sender`] will be produced +/// by the [`Task`]. +pub fn try_blocking( + f: impl FnOnce(mpsc::Sender) -> Result<(), E> + Send + 'static, +) -> Task> +where + T: Send + 'static, + E: Send + 'static, +{ + let (sender, receiver) = mpsc::channel(1); + let (error_sender, error_receiver) = oneshot::channel(); + + let _ = thread::spawn(move || { + if let Err(error) = f(sender) { + let _ = error_sender.send(Err(error)); + } + }); + + Task::stream(stream::select( + receiver.map(Ok), + stream::once(error_receiver).filter_map(async |result| result.ok()), + )) +} diff --git a/runtime/src/user_interface.rs b/runtime/src/user_interface.rs index 26c96201..b63b4747 100644 --- a/runtime/src/user_interface.rs +++ b/runtime/src/user_interface.rs @@ -2,13 +2,13 @@ use crate::core::event::{self, Event}; use crate::core::layout; use crate::core::mouse; +use crate::core::overlay; use crate::core::renderer; use crate::core::widget; use crate::core::window; use crate::core::{ Clipboard, Element, InputMethod, Layout, Rectangle, Shell, Size, Vector, }; -use crate::overlay; /// A set of interactive graphical elements with a specific [`Layout`]. /// @@ -22,7 +22,6 @@ use crate::overlay; /// existing graphical application. /// /// [`integration`]: https://github.com/iced-rs/iced/tree/0.13/examples/integration -#[allow(missing_debug_implementations)] pub struct UserInterface<'a, Message, Theme, Renderer> { root: Element<'a, Message, Theme, Renderer>, base: layout::Node, diff --git a/runtime/src/widget.rs b/runtime/src/widget.rs new file mode 100644 index 00000000..e5ba2b0e --- /dev/null +++ b/runtime/src/widget.rs @@ -0,0 +1,5 @@ +//! Operate on widgets and query them at runtime. +pub mod operation; + +#[cfg(feature = "selector")] +pub mod selector; diff --git a/runtime/src/widget/operation.rs b/runtime/src/widget/operation.rs new file mode 100644 index 00000000..ab03bbe0 --- /dev/null +++ b/runtime/src/widget/operation.rs @@ -0,0 +1,88 @@ +//! Change internal widget state. +use crate::core::widget::Id; +use crate::core::widget::operation; +use crate::task; +use crate::{Action, Task}; + +pub use crate::core::widget::operation::scrollable::{ + AbsoluteOffset, RelativeOffset, +}; + +/// Snaps the scrollable with the given [`Id`] to the provided [`RelativeOffset`]. +pub fn snap_to(id: impl Into, offset: RelativeOffset) -> Task { + task::effect(Action::widget(operation::scrollable::snap_to( + id.into(), + offset, + ))) +} + +/// Snaps the scrollable with the given [`Id`] to the [`RelativeOffset::END`]. +pub fn snap_to_end(id: impl Into) -> Task { + task::effect(Action::widget(operation::scrollable::snap_to( + id.into(), + RelativeOffset::END, + ))) +} + +/// Scrolls the scrollable with the given [`Id`] to the provided [`AbsoluteOffset`]. +pub fn scroll_to(id: impl Into, offset: AbsoluteOffset) -> Task { + task::effect(Action::widget(operation::scrollable::scroll_to( + id.into(), + offset, + ))) +} + +/// Scrolls the scrollable with the given [`Id`] by the provided [`AbsoluteOffset`]. +pub fn scroll_by(id: impl Into, offset: AbsoluteOffset) -> Task { + task::effect(Action::widget(operation::scrollable::scroll_by( + id.into(), + offset, + ))) +} + +/// Focuses the previous focusable widget. +pub fn focus_previous() -> Task { + task::effect(Action::widget(operation::focusable::focus_previous())) +} + +/// Focuses the next focusable widget. +pub fn focus_next() -> Task { + task::effect(Action::widget(operation::focusable::focus_next())) +} + +/// Returns whether the widget with the given [`Id`] is focused or not. +pub fn is_focused(id: impl Into) -> Task { + task::widget(operation::focusable::is_focused(id.into())) +} + +/// Focuses the widget with the given [`Id`]. +pub fn focus(id: impl Into) -> Task { + task::effect(Action::widget(operation::focusable::focus(id.into()))) +} + +/// Moves the cursor of the widget with the given [`Id`] to the end. +pub fn move_cursor_to_end(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_end( + id.into(), + ))) +} + +/// Moves the cursor of the widget with the given [`Id`] to the front. +pub fn move_cursor_to_front(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to_front( + id.into(), + ))) +} + +/// Moves the cursor of the widget with the given [`Id`] to the provided position. +pub fn move_cursor_to(id: impl Into, position: usize) -> Task { + task::effect(Action::widget(operation::text_input::move_cursor_to( + id.into(), + position, + ))) +} + +/// Selects all the content of the widget with the given [`Id`]. +pub fn select_all(id: impl Into) -> Task { + task::effect(Action::widget(operation::text_input::select_all(id.into()))) +} diff --git a/runtime/src/widget/selector.rs b/runtime/src/widget/selector.rs new file mode 100644 index 00000000..a6f306c5 --- /dev/null +++ b/runtime/src/widget/selector.rs @@ -0,0 +1,28 @@ +//! Find and query widgets in your applications. +pub use iced_selector::{Bounded, Candidate, Selector, Target, Text}; + +use crate::core::Rectangle; + +use crate::Task; +use crate::core::widget; +use crate::task; + +/// Finds a widget by the given [`widget::Id`]. +pub fn find_by_id(id: impl Into) -> Task> { + task::widget(id.into().find()) +} + +/// Finds a widget that contains the given text. +pub fn find_by_text(text: impl Into) -> Task> { + task::widget(Selector::find(text.into())) +} + +/// Finds the visible bounds of the first [`Selector`] target. +pub fn delineate(selector: S) -> Task> +where + S: Selector + Send + 'static, + S::Output: Bounded + Clone + Send + 'static, +{ + task::widget(selector.find()) + .map(|target| target.as_ref().and_then(Bounded::visible_bounds)) +} diff --git a/runtime/src/window.rs b/runtime/src/window.rs index dde540b8..f2ed4e5e 100644 --- a/runtime/src/window.rs +++ b/runtime/src/window.rs @@ -15,7 +15,6 @@ pub use raw_window_handle; use raw_window_handle::WindowHandle; /// An operation to be performed on some window. -#[allow(missing_debug_implementations)] pub enum Action { /// Opens a new window with some [`Settings`]. Open(Id, Settings, oneshot::Sender), diff --git a/selector/Cargo.toml b/selector/Cargo.toml new file mode 100644 index 00000000..67488d12 --- /dev/null +++ b/selector/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "iced_selector" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true +rust-version.workspace = true + +[dependencies] +iced_core.workspace = true + +[lints] +workspace = true diff --git a/selector/src/find.rs b/selector/src/find.rs new file mode 100644 index 00000000..37ffd713 --- /dev/null +++ b/selector/src/find.rs @@ -0,0 +1,276 @@ +use crate::Selector; +use crate::core::widget::operation::{ + Focusable, Outcome, Scrollable, TextInput, +}; +use crate::core::widget::{Id, Operation}; +use crate::core::{Rectangle, Vector}; +use crate::target::Candidate; + +use std::any::Any; + +/// An [`Operation`] that runs the [`Selector`] and stops after +/// the first [`Output`](Selector::Output) is produced. +pub type Find = Finder>; + +/// An [`Operation`] that runs the [`Selector`] for the entire +/// widget tree and aggregates all of its [`Output`](Selector::Output). +pub type FindAll = Finder>; + +#[derive(Debug)] +pub struct One +where + S: Selector, +{ + selector: S, + output: Option, +} + +impl One +where + S: Selector, +{ + pub fn new(selector: S) -> Self { + Self { + selector, + output: None, + } + } +} + +impl Strategy for One +where + S: Selector, + S::Output: Clone, +{ + type Output = Option; + + fn feed(&mut self, target: Candidate<'_>) { + if let Some(output) = self.selector.select(target) { + self.output = Some(output); + } + } + + fn is_done(&self) -> bool { + self.output.is_some() + } + + fn finish(&self) -> Self::Output { + self.output.clone() + } +} + +#[derive(Debug)] +pub struct All +where + S: Selector, +{ + selector: S, + outputs: Vec, +} + +impl All +where + S: Selector, +{ + pub fn new(selector: S) -> Self { + Self { + selector, + outputs: Vec::new(), + } + } +} + +impl Strategy for All +where + S: Selector, + S::Output: Clone, +{ + type Output = Vec; + + fn feed(&mut self, target: Candidate<'_>) { + if let Some(output) = self.selector.select(target) { + self.outputs.push(output); + } + } + + fn is_done(&self) -> bool { + false + } + + fn finish(&self) -> Self::Output { + self.outputs.clone() + } +} + +pub trait Strategy { + type Output; + + fn feed(&mut self, target: Candidate<'_>); + + fn is_done(&self) -> bool; + + fn finish(&self) -> Self::Output; +} + +#[derive(Debug)] +pub struct Finder { + strategy: S, + stack: Vec<(Rectangle, Vector)>, + viewport: Rectangle, + translation: Vector, +} + +impl Finder { + pub fn new(strategy: S) -> Self { + Self { + strategy, + stack: vec![(Rectangle::INFINITE, Vector::ZERO)], + viewport: Rectangle::INFINITE, + translation: Vector::ZERO, + } + } +} + +impl Operation for Finder +where + S: Strategy + Send, + S::Output: Send, +{ + fn traverse( + &mut self, + operate: &mut dyn FnMut(&mut dyn Operation), + ) { + if self.strategy.is_done() { + return; + } + + self.stack.push((self.viewport, self.translation)); + operate(self); + let _ = self.stack.pop(); + + let (viewport, translation) = self.stack.last().unwrap(); + self.viewport = *viewport; + self.translation = *translation; + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if self.strategy.is_done() { + return; + } + + self.strategy.feed(Candidate::Container { + id, + bounds, + visible_bounds: self + .viewport + .intersection(&(bounds + self.translation)), + }); + } + + fn focusable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn Focusable, + ) { + if self.strategy.is_done() { + return; + } + + self.strategy.feed(Candidate::Focusable { + id, + bounds, + visible_bounds: self + .viewport + .intersection(&(bounds + self.translation)), + state, + }); + } + + fn scrollable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + content_bounds: Rectangle, + translation: Vector, + state: &mut dyn Scrollable, + ) { + if self.strategy.is_done() { + return; + } + + let visible_bounds = + self.viewport.intersection(&(bounds + self.translation)); + + self.strategy.feed(Candidate::Scrollable { + id, + bounds, + visible_bounds, + content_bounds, + translation, + state, + }); + + self.translation = self.translation - translation; + self.viewport = visible_bounds.unwrap_or_default(); + } + + fn text_input( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn TextInput, + ) { + if self.strategy.is_done() { + return; + } + + self.strategy.feed(Candidate::TextInput { + id, + bounds, + visible_bounds: self + .viewport + .intersection(&(bounds + self.translation)), + state, + }); + } + + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { + if self.strategy.is_done() { + return; + } + + self.strategy.feed(Candidate::Text { + id, + bounds, + visible_bounds: self + .viewport + .intersection(&(bounds + self.translation)), + content: text, + }); + } + + fn custom( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn Any, + ) { + if self.strategy.is_done() { + return; + } + + self.strategy.feed(Candidate::Custom { + id, + bounds, + visible_bounds: self + .viewport + .intersection(&(bounds + self.translation)), + state, + }); + } + + fn finish(&self) -> Outcome { + Outcome::Some(self.strategy.finish()) + } +} diff --git a/selector/src/lib.rs b/selector/src/lib.rs new file mode 100644 index 00000000..e5703df5 --- /dev/null +++ b/selector/src/lib.rs @@ -0,0 +1,148 @@ +//! Select data from the widget tree. +use iced_core as core; + +mod find; +mod target; + +pub use find::{Find, FindAll}; +pub use target::{Bounded, Candidate, Target, Text}; + +use crate::core::Point; +use crate::core::widget; + +/// A type that traverses the widget tree to "select" data and produce some output. +pub trait Selector { + /// The output type of the [`Selector`]. + /// + /// For most selectors, this will normally be a [`Target`]. However, some + /// selectors may want to return a more limited type to encode the selection + /// guarantees in the type system. + /// + /// For instance, the implementations of [`String`] and [`str`] of [`Selector`] + /// return a [`target::Text`] instead of a generic [`Target`], since they are + /// guaranteed to only select text. + type Output; + + /// Performs a selection of the given [`Candidate`], if applicable. + /// + /// This method traverses the widget tree in depth-first order. + fn select(&mut self, candidate: Candidate<'_>) -> Option; + + /// Returns a short description of the [`Selector`] for debugging purposes. + fn description(&self) -> String; + + /// Returns a [`widget::Operation`] that runs the [`Selector`] and stops after + /// the first [`Output`](Self::Output) is produced. + fn find(self) -> Find + where + Self: Sized, + { + Find::new(find::One::new(self)) + } + + /// Returns a [`widget::Operation`] that runs the [`Selector`] for the entire + /// widget tree and aggregates all of its [`Output`](Self::Output). + fn find_all(self) -> FindAll + where + Self: Sized, + { + FindAll::new(find::All::new(self)) + } +} + +impl Selector for &str { + type Output = target::Text; + + fn select(&mut self, candidate: Candidate<'_>) -> Option { + match candidate { + Candidate::TextInput { + id, + bounds, + visible_bounds, + state, + } if state.text() == *self => Some(target::Text::Input { + id: id.cloned(), + bounds, + visible_bounds, + }), + Candidate::Text { + id, + bounds, + visible_bounds, + content, + } if content == *self => Some(target::Text::Raw { + id: id.cloned(), + bounds, + visible_bounds, + }), + _ => None, + } + } + + fn description(&self) -> String { + format!("text == {self:?}") + } +} + +impl Selector for String { + type Output = target::Text; + + fn select(&mut self, candidate: Candidate<'_>) -> Option { + self.as_str().select(candidate) + } + + fn description(&self) -> String { + self.as_str().description() + } +} + +impl Selector for widget::Id { + type Output = Target; + + fn select(&mut self, candidate: Candidate<'_>) -> Option { + if candidate.id() != Some(self) { + return None; + } + + Some(Target::from(candidate)) + } + + fn description(&self) -> String { + format!("id == {self:?}") + } +} + +impl Selector for Point { + type Output = Target; + + fn select(&mut self, candidate: Candidate<'_>) -> Option { + candidate + .visible_bounds() + .is_some_and(|visible_bounds| visible_bounds.contains(*self)) + .then(|| Target::from(candidate)) + } + + fn description(&self) -> String { + format!("bounds contains {self:?}") + } +} + +impl Selector for F +where + F: FnMut(Candidate<'_>) -> Option, +{ + type Output = T; + + fn select(&mut self, candidate: Candidate<'_>) -> Option { + (self)(candidate) + } + + fn description(&self) -> String { + format!("custom selector: {}", std::any::type_name_of_val(self)) + } +} + +/// Creates a new [`Selector`] that matches widgets with the given [`widget::Id`]. +pub fn id(id: impl Into) -> impl Selector { + id.into() +} diff --git a/selector/src/target.rs b/selector/src/target.rs new file mode 100644 index 00000000..d2d20651 --- /dev/null +++ b/selector/src/target.rs @@ -0,0 +1,291 @@ +use crate::core::widget::Id; +use crate::core::widget::operation::{Focusable, Scrollable, TextInput}; +use crate::core::{Rectangle, Vector}; + +use std::any::Any; + +/// A generic widget match produced during selection. +#[allow(missing_docs)] +#[derive(Debug, Clone, PartialEq)] +pub enum Target { + Container { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + }, + Focusable { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + }, + Scrollable { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + content_bounds: Rectangle, + translation: Vector, + }, + TextInput { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + content: String, + }, + Text { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + content: String, + }, + Custom { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + }, +} + +impl Target { + /// Returns the layout bounds of the [`Target`]. + pub fn bounds(&self) -> Rectangle { + match self { + Target::Container { bounds, .. } + | Target::Focusable { bounds, .. } + | Target::Scrollable { bounds, .. } + | Target::TextInput { bounds, .. } + | Target::Text { bounds, .. } + | Target::Custom { bounds, .. } => *bounds, + } + } + + /// Returns the visible bounds of the [`Target`], in screen coordinates. + pub fn visible_bounds(&self) -> Option { + match self { + Target::Container { visible_bounds, .. } + | Target::Focusable { visible_bounds, .. } + | Target::Scrollable { visible_bounds, .. } + | Target::TextInput { visible_bounds, .. } + | Target::Text { visible_bounds, .. } + | Target::Custom { visible_bounds, .. } => *visible_bounds, + } + } +} + +impl From> for Target { + fn from(candidate: Candidate<'_>) -> Self { + match candidate { + Candidate::Container { + id, + bounds, + visible_bounds, + } => Self::Container { + id: id.cloned(), + bounds, + visible_bounds, + }, + Candidate::Focusable { + id, + bounds, + visible_bounds, + .. + } => Self::Focusable { + id: id.cloned(), + bounds, + visible_bounds, + }, + Candidate::Scrollable { + id, + bounds, + visible_bounds, + content_bounds, + translation, + .. + } => Self::Scrollable { + id: id.cloned(), + bounds, + visible_bounds, + content_bounds, + translation, + }, + Candidate::TextInput { + id, + bounds, + visible_bounds, + state, + } => Self::TextInput { + id: id.cloned(), + bounds, + visible_bounds, + content: state.text().to_owned(), + }, + Candidate::Text { + id, + bounds, + visible_bounds, + content, + } => Self::Text { + id: id.cloned(), + bounds, + visible_bounds, + content: content.to_owned(), + }, + Candidate::Custom { + id, + bounds, + visible_bounds, + .. + } => Self::Custom { + id: id.cloned(), + bounds, + visible_bounds, + }, + } + } +} + +impl Bounded for Target { + fn bounds(&self) -> Rectangle { + self.bounds() + } + + fn visible_bounds(&self) -> Option { + self.visible_bounds() + } +} + +/// A selection candidate. +/// +/// This is provided to [`Selector::select`](crate::Selector::select). +#[allow(missing_docs)] +#[derive(Clone)] +pub enum Candidate<'a> { + Container { + id: Option<&'a Id>, + bounds: Rectangle, + visible_bounds: Option, + }, + Focusable { + id: Option<&'a Id>, + bounds: Rectangle, + visible_bounds: Option, + state: &'a dyn Focusable, + }, + Scrollable { + id: Option<&'a Id>, + bounds: Rectangle, + content_bounds: Rectangle, + visible_bounds: Option, + translation: Vector, + state: &'a dyn Scrollable, + }, + TextInput { + id: Option<&'a Id>, + bounds: Rectangle, + visible_bounds: Option, + state: &'a dyn TextInput, + }, + Text { + id: Option<&'a Id>, + bounds: Rectangle, + visible_bounds: Option, + content: &'a str, + }, + Custom { + id: Option<&'a Id>, + bounds: Rectangle, + visible_bounds: Option, + state: &'a dyn Any, + }, +} + +impl<'a> Candidate<'a> { + /// Returns the widget [`Id`] of the [`Candidate`]. + pub fn id(&self) -> Option<&'a Id> { + match self { + Candidate::Container { id, .. } + | Candidate::Focusable { id, .. } + | Candidate::Scrollable { id, .. } + | Candidate::TextInput { id, .. } + | Candidate::Text { id, .. } + | Candidate::Custom { id, .. } => *id, + } + } + + /// Returns the layout bounds of the [`Candidate`]. + pub fn bounds(&self) -> Rectangle { + match self { + Candidate::Container { bounds, .. } + | Candidate::Focusable { bounds, .. } + | Candidate::Scrollable { bounds, .. } + | Candidate::TextInput { bounds, .. } + | Candidate::Text { bounds, .. } + | Candidate::Custom { bounds, .. } => *bounds, + } + } + + /// Returns the visible bounds of the [`Candidate`], in screen coordinates. + pub fn visible_bounds(&self) -> Option { + match self { + Candidate::Container { visible_bounds, .. } + | Candidate::Focusable { visible_bounds, .. } + | Candidate::Scrollable { visible_bounds, .. } + | Candidate::TextInput { visible_bounds, .. } + | Candidate::Text { visible_bounds, .. } + | Candidate::Custom { visible_bounds, .. } => *visible_bounds, + } + } +} + +/// A bounded type has both layout bounds and visible bounds. +/// +/// This trait lets us write generic code over the [`Output`](crate::Selector::Output) +/// of a [`Selector`](crate::Selector). +pub trait Bounded: std::fmt::Debug { + /// Returns the layout bounds. + fn bounds(&self) -> Rectangle; + + /// Returns the visible bounds, in screen coordinates. + fn visible_bounds(&self) -> Option; +} + +/// A text match. +#[allow(missing_docs)] +#[derive(Debug, Clone, PartialEq)] +pub enum Text { + Raw { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + }, + Input { + id: Option, + bounds: Rectangle, + visible_bounds: Option, + }, +} + +impl Text { + /// Returns the layout bounds of the [`Text`]. + pub fn bounds(&self) -> Rectangle { + match self { + Text::Raw { bounds, .. } | Text::Input { bounds, .. } => *bounds, + } + } + + /// Returns the visible bounds of the [`Text`], in screen coordinates. + pub fn visible_bounds(&self) -> Option { + match self { + Text::Raw { visible_bounds, .. } + | Text::Input { visible_bounds, .. } => *visible_bounds, + } + } +} + +impl Bounded for Text { + fn bounds(&self) -> Rectangle { + self.bounds() + } + + fn visible_bounds(&self) -> Option { + self.visible_bounds() + } +} diff --git a/src/application.rs b/src/application.rs index ef15a34a..143dfcc4 100644 --- a/src/application.rs +++ b/src/application.rs @@ -30,12 +30,14 @@ //! ] //! } //! ``` +use crate::message; use crate::program::{self, Program}; use crate::shell; use crate::theme; use crate::window; use crate::{ - Element, Executor, Font, Result, Settings, Size, Subscription, Task, Theme, + Element, Executor, Font, Preset, Result, Settings, Size, Subscription, + Task, Theme, }; use iced_debug as debug; @@ -81,7 +83,7 @@ pub fn application( ) -> Application> where State: 'static, - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base, Renderer: program::Renderer, { @@ -100,7 +102,7 @@ where impl Program for Instance where - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base, Renderer: program::Renderer, Boot: self::BootFn, @@ -128,7 +130,7 @@ where state: &mut Self::State, message: Self::Message, ) -> Task { - debug::hot(|| self.update.update(state, message)) + self.update.update(state, message) } fn view<'a>( @@ -136,7 +138,15 @@ where state: &'a Self::State, _window: window::Id, ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { - debug::hot(|| self.view.view(state)) + self.view.view(state) + } + + fn settings(&self) -> Settings { + Settings::default() + } + + fn window(&self) -> Option { + Some(window::Settings::default()) } } @@ -152,6 +162,7 @@ where }, settings: Settings::default(), window: window::Settings::default(), + presets: Vec::new(), } } @@ -167,6 +178,7 @@ pub struct Application { raw: P, settings: Settings, window: window::Settings, + presets: Vec>, } impl Application

{ @@ -174,22 +186,25 @@ impl Application

{ pub fn run(self) -> Result where Self: 'static, + P::Message: message::MaybeDebug + message::MaybeClone, { - #[cfg(all(feature = "debug", not(target_arch = "wasm32")))] - let program = { - iced_debug::init(iced_debug::Metadata { - name: P::name(), - theme: None, - can_time_travel: cfg!(feature = "time-travel"), - }); + #[cfg(feature = "debug")] + iced_debug::init(iced_debug::Metadata { + name: P::name(), + theme: None, + can_time_travel: cfg!(feature = "time-travel"), + }); - iced_devtools::attach(self.raw) - }; + #[cfg(feature = "tester")] + let program = iced_tester::attach(self); - #[cfg(any(not(feature = "debug"), target_arch = "wasm32"))] - let program = self.raw; + #[cfg(all(feature = "debug", not(feature = "tester")))] + let program = iced_devtools::attach(self); - Ok(shell::run(program, self.settings, Some(self.window))?) + #[cfg(not(any(feature = "tester", feature = "debug")))] + let program = self; + + Ok(shell::run(program)?) } /// Sets the [`Settings`] that will be used to run the [`Application`]. @@ -329,10 +344,11 @@ impl Application

{ > { Application { raw: program::with_title(self.raw, move |state, _window| { - debug::hot(|| title.title(state)) + title.title(state) }), settings: self.settings, window: self.window, + presets: self.presets, } } @@ -344,11 +360,10 @@ impl Application

{ impl Program, > { Application { - raw: program::with_subscription(self.raw, move |state| { - debug::hot(|| f(state)) - }), + raw: program::with_subscription(self.raw, f), settings: self.settings, window: self.window, + presets: self.presets, } } @@ -361,10 +376,11 @@ impl Application

{ > { Application { raw: program::with_theme(self.raw, move |state, _window| { - debug::hot(|| f.theme(state)) + f.theme(state) }), settings: self.settings, window: self.window, + presets: self.presets, } } @@ -376,11 +392,10 @@ impl Application

{ impl Program, > { Application { - raw: program::with_style(self.raw, move |state, theme| { - debug::hot(|| f(state, theme)) - }), + raw: program::with_style(self.raw, f), settings: self.settings, window: self.window, + presets: self.presets, } } @@ -393,10 +408,11 @@ impl Application

{ > { Application { raw: program::with_scale_factor(self.raw, move |state, _window| { - debug::hot(|| f(state)) + f(state) }), settings: self.settings, window: self.window, + presets: self.presets, } } @@ -413,8 +429,92 @@ impl Application

{ raw: program::with_executor::(self.raw), settings: self.settings, window: self.window, + presets: self.presets, } } + + /// Sets the boot presets of the [`Application`]. + /// + /// Presets can be used to override the default booting strategy + /// of your application during testing to create reproducible + /// environments. + pub fn presets( + self, + presets: impl IntoIterator>, + ) -> Self { + Self { + presets: presets.into_iter().collect(), + ..self + } + } +} + +impl Program for Application

{ + type State = P::State; + type Message = P::Message; + type Theme = P::Theme; + type Renderer = P::Renderer; + type Executor = P::Executor; + + fn name() -> &'static str { + P::name() + } + + fn settings(&self) -> Settings { + self.settings.clone() + } + + fn window(&self) -> Option { + Some(self.window.clone()) + } + + fn boot(&self) -> (Self::State, Task) { + self.raw.boot() + } + + fn update( + &self, + state: &mut Self::State, + message: Self::Message, + ) -> Task { + debug::hot(|| self.raw.update(state, message)) + } + + fn view<'a>( + &self, + state: &'a Self::State, + window: window::Id, + ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { + debug::hot(|| self.raw.view(state, window)) + } + + fn title(&self, state: &Self::State, window: window::Id) -> String { + debug::hot(|| self.raw.title(state, window)) + } + + fn subscription(&self, state: &Self::State) -> Subscription { + debug::hot(|| self.raw.subscription(state)) + } + + fn theme( + &self, + state: &Self::State, + window: iced_core::window::Id, + ) -> Option { + debug::hot(|| self.raw.theme(state, window)) + } + + fn style(&self, state: &Self::State, theme: &Self::Theme) -> theme::Style { + debug::hot(|| self.raw.style(state, theme)) + } + + fn scale_factor(&self, state: &Self::State, window: window::Id) -> f32 { + debug::hot(|| self.raw.scale_factor(state, window)) + } + + fn presets(&self) -> &[Preset] { + &self.presets + } } /// The logic to initialize the `State` of some [`Application`]. diff --git a/src/application/timed.rs b/src/application/timed.rs index 32b2f100..099b3dc4 100644 --- a/src/application/timed.rs +++ b/src/application/timed.rs @@ -29,7 +29,7 @@ pub fn timed( > where State: 'static, - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base + 'static, Renderer: program::Renderer + 'static, { @@ -68,7 +68,7 @@ where View, > where - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base + 'static, Renderer: program::Renderer + 'static, Boot: self::BootFn, @@ -88,6 +88,14 @@ where name.split("::").next().unwrap_or("a_cool_application") } + fn settings(&self) -> Settings { + Settings::default() + } + + fn window(&self) -> Option { + Some(window::Settings::default()) + } + fn boot(&self) -> (State, Task) { let (state, task) = self.boot.boot(); @@ -143,6 +151,7 @@ where }, settings: Settings::default(), window: window::Settings::default(), + presets: Vec::new(), } } diff --git a/src/daemon.rs b/src/daemon.rs index 08a7d939..d22fcabd 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,11 +1,13 @@ //! Create and run daemons that run in the background. use crate::application; +use crate::message; use crate::program::{self, Program}; use crate::shell; use crate::theme; use crate::window; use crate::{ - Element, Executor, Font, Result, Settings, Subscription, Task, Theme, + Element, Executor, Font, Preset, Result, Settings, Subscription, Task, + Theme, }; use iced_debug as debug; @@ -29,7 +31,7 @@ pub fn daemon( ) -> Daemon> where State: 'static, - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base, Renderer: program::Renderer, { @@ -48,7 +50,7 @@ where impl Program for Instance where - Message: program::Message + 'static, + Message: Send + 'static, Theme: theme::Base, Renderer: program::Renderer, Boot: application::BootFn, @@ -67,6 +69,14 @@ where name.split("::").next().unwrap_or("a_cool_daemon") } + fn settings(&self) -> Settings { + Settings::default() + } + + fn window(&self) -> Option { + None + } + fn boot(&self) -> (Self::State, Task) { self.boot.boot() } @@ -76,7 +86,7 @@ where state: &mut Self::State, message: Self::Message, ) -> Task { - debug::hot(|| self.update.update(state, message)) + self.update.update(state, message) } fn view<'a>( @@ -84,7 +94,7 @@ where state: &'a Self::State, window: window::Id, ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { - debug::hot(|| self.view.view(state, window)) + self.view.view(state, window) } } @@ -99,6 +109,7 @@ where _renderer: PhantomData, }, settings: Settings::default(), + presets: Vec::new(), } } @@ -113,6 +124,7 @@ where pub struct Daemon { raw: P, settings: Settings, + presets: Vec>, } impl Daemon

{ @@ -120,6 +132,7 @@ impl Daemon

{ pub fn run(self) -> Result where Self: 'static, + P::Message: message::MaybeDebug + message::MaybeClone, { #[cfg(all(feature = "debug", not(target_arch = "wasm32")))] let program = { @@ -129,13 +142,13 @@ impl Daemon

{ can_time_travel: cfg!(feature = "time-travel"), }); - iced_devtools::attach(self.raw) + iced_devtools::attach(self) }; #[cfg(any(not(feature = "debug"), target_arch = "wasm32"))] - let program = self.raw; + let program = self; - Ok(shell::run(program, self.settings, None)?) + Ok(shell::run(program)?) } /// Sets the [`Settings`] that will be used to run the [`Daemon`]. @@ -180,9 +193,10 @@ impl Daemon

{ > { Daemon { raw: program::with_title(self.raw, move |state, window| { - debug::hot(|| title.title(state, window)) + title.title(state, window) }), settings: self.settings, + presets: self.presets, } } @@ -194,10 +208,9 @@ impl Daemon

{ impl Program, > { Daemon { - raw: program::with_subscription(self.raw, move |state| { - debug::hot(|| f(state)) - }), + raw: program::with_subscription(self.raw, f), settings: self.settings, + presets: self.presets, } } @@ -210,9 +223,10 @@ impl Daemon

{ > { Daemon { raw: program::with_theme(self.raw, move |state, window| { - debug::hot(|| f.theme(state, window)) + f.theme(state, window) }), settings: self.settings, + presets: self.presets, } } @@ -224,10 +238,9 @@ impl Daemon

{ impl Program, > { Daemon { - raw: program::with_style(self.raw, move |state, theme| { - debug::hot(|| f(state, theme)) - }), + raw: program::with_style(self.raw, f), settings: self.settings, + presets: self.presets, } } @@ -239,10 +252,9 @@ impl Daemon

{ impl Program, > { Daemon { - raw: program::with_scale_factor(self.raw, move |state, window| { - debug::hot(|| f(state, window)) - }), + raw: program::with_scale_factor(self.raw, f), settings: self.settings, + presets: self.presets, } } @@ -258,8 +270,92 @@ impl Daemon

{ Daemon { raw: program::with_executor::(self.raw), settings: self.settings, + presets: self.presets, } } + + /// Sets the boot presets of the [`Daemon`]. + /// + /// Presets can be used to override the default booting strategy + /// of your application during testing to create reproducible + /// environments. + pub fn presets( + self, + presets: impl IntoIterator>, + ) -> Self { + Self { + presets: presets.into_iter().collect(), + ..self + } + } +} + +impl Program for Daemon

{ + type State = P::State; + type Message = P::Message; + type Theme = P::Theme; + type Renderer = P::Renderer; + type Executor = P::Executor; + + fn name() -> &'static str { + P::name() + } + + fn settings(&self) -> Settings { + self.settings.clone() + } + + fn window(&self) -> Option { + None + } + + fn boot(&self) -> (Self::State, Task) { + self.raw.boot() + } + + fn update( + &self, + state: &mut Self::State, + message: Self::Message, + ) -> Task { + debug::hot(|| self.raw.update(state, message)) + } + + fn view<'a>( + &self, + state: &'a Self::State, + window: window::Id, + ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { + debug::hot(|| self.raw.view(state, window)) + } + + fn title(&self, state: &Self::State, window: window::Id) -> String { + debug::hot(|| self.raw.title(state, window)) + } + + fn subscription(&self, state: &Self::State) -> Subscription { + debug::hot(|| self.raw.subscription(state)) + } + + fn theme( + &self, + state: &Self::State, + window: iced_core::window::Id, + ) -> Option { + debug::hot(|| self.raw.theme(state, window)) + } + + fn style(&self, state: &Self::State, theme: &Self::Theme) -> theme::Style { + debug::hot(|| self.raw.style(state, theme)) + } + + fn scale_factor(&self, state: &Self::State, window: window::Id) -> f32 { + debug::hot(|| self.raw.scale_factor(state, window)) + } + + fn presets(&self) -> &[Preset] { + &self.presets + } } /// The title logic of some [`Daemon`]. diff --git a/src/lib.rs b/src/lib.rs index 8dfad67d..c72da926 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -326,8 +326,8 @@ //! //! Tasks can also be used to interact with the iced runtime. Some modules //! expose functions that create tasks for different purposes—like [changing -//! window settings](window#functions), [focusing a widget](widget::focus_next), or -//! [querying its visible bounds](widget::container::visible_bounds). +//! window settings](window#functions), [focusing a widget](widget::operation::focus_next), or +//! [querying its visible bounds](widget::selector::find_by_id). //! //! Like futures and streams, tasks expose [a monadic interface](Task::then)—but they can also be //! [mapped](Task::map), [chained](Task::chain), [batched](Task::batch), [canceled](Task::abortable), @@ -525,6 +525,8 @@ pub use crate::core::{ Function, Gradient, Length, Padding, Pixels, Point, Radians, Rectangle, Rotation, Settings, Shadow, Size, Theme, Transformation, Vector, never, }; +pub use crate::program::Preset; +pub use crate::program::message; pub use crate::runtime::exit; pub use iced_futures::Subscription; @@ -621,15 +623,13 @@ pub mod touch { #[allow(hidden_glob_reexports)] pub mod widget { //! Use the built-in widgets or create your own. + pub use iced_runtime::widget::*; pub use iced_widget::*; // We hide the re-exported modules by `iced_widget` mod core {} mod graphics {} - mod native {} mod renderer {} - mod style {} - mod runtime {} } pub use application::Application; @@ -698,7 +698,7 @@ pub fn run( ) -> Result where State: Default + 'static, - Message: program::Message + 'static, + Message: Send + message::MaybeDebug + message::MaybeClone + 'static, Theme: theme::Base + 'static, Renderer: program::Renderer + 'static, { diff --git a/test/Cargo.toml b/test/Cargo.toml index 2dd35e7f..7c30cefd 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -15,10 +15,13 @@ workspace = true [dependencies] iced_runtime.workspace = true +iced_program.workspace = true +iced_selector.workspace = true iced_renderer.workspace = true iced_renderer.features = ["fira-sans"] +nom.workspace = true png.workspace = true sha2.workspace = true thiserror.workspace = true diff --git a/test/src/emulator.rs b/test/src/emulator.rs new file mode 100644 index 00000000..0bd653d7 --- /dev/null +++ b/test/src/emulator.rs @@ -0,0 +1,485 @@ +//! Run your application in a headless runtime. +use crate::core; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::widget; +use crate::core::{Element, Point, Size}; +use crate::instruction; +use crate::program; +use crate::program::Program; +use crate::runtime; +use crate::runtime::futures::futures::StreamExt; +use crate::runtime::futures::futures::channel::mpsc; +use crate::runtime::futures::futures::stream; +use crate::runtime::futures::subscription; +use crate::runtime::futures::{Executor, Runtime}; +use crate::runtime::task; +use crate::runtime::user_interface; +use crate::runtime::window; +use crate::runtime::{Task, UserInterface}; +use crate::{Instruction, Selector}; + +use std::fmt; + +/// A headless runtime that can run iced applications and execute +/// [instructions](crate::Instruction). +/// +/// An [`Emulator`] runs its program as faithfully as possible to the real thing. +/// It will run subscriptions and tasks with the [`Executor`](Program::Executor) of +/// the [`Program`]. +/// +/// If you want to run a simulation without side effects, use a [`Simulator`](crate::Simulator) +/// instead. +pub struct Emulator { + state: P::State, + runtime: Runtime>, Event

>, + renderer: P::Renderer, + mode: Mode, + size: Size, + window: core::window::Id, + cursor: mouse::Cursor, + clipboard: Clipboard, + cache: Option, + pending_tasks: usize, +} + +/// An emulation event. +pub enum Event { + /// An action that must be [performed](Emulator::perform) by the [`Emulator`]. + Action(Action

), + /// An [`Instruction`] failed to be executed. + Failed(Instruction), + /// The [`Emulator`] is ready. + Ready, +} + +/// An action that must be [performed](Emulator::perform) by the [`Emulator`]. +pub struct Action(Action_

); + +enum Action_ { + Runtime(runtime::Action), + CountDown, +} + +impl Emulator

{ + /// Creates a new [`Emulator`] of the [`Program`] with the given [`Mode`] and [`Size`]. + /// + /// The [`Emulator`] will send [`Event`] notifications through the provided [`mpsc::Sender`]. + /// + /// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced. + pub fn new( + sender: mpsc::Sender>, + program: &P, + mode: Mode, + size: Size, + ) -> Emulator

{ + Self::with_preset(sender, program, mode, size, None) + } + + /// Creates a new [`Emulator`] analogously to [`new`](Self::new), but it also takes a + /// [`program::Preset`] that will be used as the initial state. + /// + /// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced. + pub fn with_preset( + sender: mpsc::Sender>, + program: &P, + mode: Mode, + size: Size, + preset: Option<&program::Preset>, + ) -> Emulator

{ + use renderer::Headless; + + let settings = program.settings(); + + // TODO: Error handling + let executor = P::Executor::new().expect("Create emulator executor"); + + let renderer = executor + .block_on(P::Renderer::new( + settings.default_font, + settings.default_text_size, + None, + )) + .expect("Create emulator renderer"); + + let runtime = Runtime::new(executor, sender); + + let (state, task) = runtime.enter(|| { + if let Some(preset) = preset { + preset.boot() + } else { + program.boot() + } + }); + + let mut emulator = Self { + state, + runtime, + renderer, + mode, + size, + clipboard: Clipboard { content: None }, + cursor: mouse::Cursor::Unavailable, + window: core::window::Id::unique(), + cache: Some(user_interface::Cache::default()), + pending_tasks: 0, + }; + + emulator.resubscribe(program); + emulator.wait_for(task); + + emulator + } + + /// Updates the state of the [`Emulator`] program. + /// + /// This is equivalent to calling the [`Program::update`] function, + /// resubscribing to any subscriptions, and running the resulting tasks + /// concurrently. + pub fn update(&mut self, program: &P, message: P::Message) { + let task = self + .runtime + .enter(|| program.update(&mut self.state, message)); + + self.resubscribe(program); + + match self.mode { + Mode::Zen if self.pending_tasks > 0 => self.wait_for(task), + _ => { + if let Some(stream) = task::into_stream(task) { + self.runtime.run( + stream + .map(Action_::Runtime) + .map(Action) + .map(Event::Action) + .boxed(), + ); + } + } + } + } + + /// Performs an [`Action`]. + /// + /// Whenever an [`Emulator`] sends an [`Event::Action`], this + /// method must be called to proceed with the execution. + pub fn perform(&mut self, program: &P, action: Action

) { + match action.0 { + Action_::CountDown => { + if self.pending_tasks > 0 { + self.pending_tasks -= 1; + + if self.pending_tasks == 0 { + self.runtime.send(Event::Ready); + } + } + } + Action_::Runtime(action) => match action { + runtime::Action::Output(message) => { + self.update(program, message); + } + runtime::Action::LoadFont { .. } => { + // TODO + } + runtime::Action::Widget(operation) => { + let mut user_interface = UserInterface::build( + program.view(&self.state, self.window), + self.size, + self.cache.take().unwrap(), + &mut self.renderer, + ); + + let mut operation = Some(operation); + + while let Some(mut current) = operation.take() { + user_interface.operate(&self.renderer, &mut current); + + match current.finish() { + widget::operation::Outcome::None => {} + widget::operation::Outcome::Some(()) => {} + widget::operation::Outcome::Chain(next) => { + operation = Some(next); + } + } + } + + self.cache = Some(user_interface.into_cache()); + } + runtime::Action::Clipboard(action) => { + // TODO + dbg!(action); + } + runtime::Action::Window(action) => match action { + window::Action::Open(id, _settings, sender) => { + self.window = id; + + let _ = sender.send(self.window); + } + window::Action::GetOldest(sender) + | window::Action::GetLatest(sender) => { + let _ = sender.send(Some(self.window)); + } + window::Action::GetSize(id, sender) => { + if id == self.window { + let _ = sender.send(self.size); + } + } + window::Action::GetMaximized(id, sender) => { + if id == self.window { + let _ = sender.send(false); + } + } + window::Action::GetMinimized(id, sender) => { + if id == self.window { + let _ = sender.send(None); + } + } + window::Action::GetPosition(id, sender) => { + if id == self.window { + let _ = sender.send(Some(Point::ORIGIN)); + } + } + window::Action::GetScaleFactor(id, sender) => { + if id == self.window { + let _ = sender.send(1.0); + } + } + window::Action::GetMode(id, sender) => { + if id == self.window { + let _ = sender.send(core::window::Mode::Windowed); + } + } + _ => { + // Ignored + } + }, + runtime::Action::System(action) => { + // TODO + dbg!(action); + } + runtime::Action::Exit => { + // TODO + } + runtime::Action::Reload => { + // TODO + } + }, + } + } + + /// Runs an [`Instruction`]. + /// + /// If the [`Instruction`] executes successfully, an [`Event::Ready`] will be + /// produced by the [`Emulator`]. + /// + /// Otherwise, an [`Event::Failed`] will be triggered. + pub fn run(&mut self, program: &P, instruction: Instruction) { + let mut user_interface = UserInterface::build( + program.view(&self.state, self.window), + self.size, + self.cache.take().unwrap(), + &mut self.renderer, + ); + + let mut messages = Vec::new(); + + match &instruction { + Instruction::Interact(interaction) => { + let Some(events) = interaction.events(|target| match target { + instruction::Target::Point(position) => Some(*position), + instruction::Target::Text(text) => { + use widget::Operation; + + let mut operation = Selector::find(text.as_str()); + + user_interface.operate( + &self.renderer, + &mut widget::operation::black_box(&mut operation), + ); + + match operation.finish() { + widget::operation::Outcome::Some(text) => { + Some(text?.visible_bounds()?.center()) + } + _ => None, + } + } + }) else { + self.runtime.send(Event::Failed(instruction)); + self.cache = Some(user_interface.into_cache()); + return; + }; + + for event in &events { + if let core::Event::Mouse(mouse::Event::CursorMoved { + position, + }) = event + { + self.cursor = mouse::Cursor::Available(*position); + } + } + + let (_state, _status) = user_interface.update( + &events, + self.cursor, + &mut self.renderer, + &mut self.clipboard, + &mut messages, + ); + + self.cache = Some(user_interface.into_cache()); + + let task = self.runtime.enter(|| { + Task::batch(messages.into_iter().map(|message| { + program.update(&mut self.state, message) + })) + }); + + self.resubscribe(program); + self.wait_for(task); + } + Instruction::Expect(expectation) => match expectation { + instruction::Expectation::Text(text) => { + use widget::Operation; + + let mut operation = Selector::find(text.as_str()); + + user_interface.operate( + &self.renderer, + &mut widget::operation::black_box(&mut operation), + ); + + match operation.finish() { + widget::operation::Outcome::Some(Some(_text)) => { + self.runtime.send(Event::Ready); + } + _ => { + self.runtime.send(Event::Failed(instruction)); + } + } + + self.cache = Some(user_interface.into_cache()); + } + }, + } + } + + fn wait_for(&mut self, task: Task) { + if let Some(stream) = task::into_stream(task) { + match self.mode { + Mode::Zen => { + self.pending_tasks += 1; + + self.runtime.run( + stream + .map(Action_::Runtime) + .map(Action) + .map(Event::Action) + .chain(stream::once(async { + Event::Action(Action(Action_::CountDown)) + })) + .boxed(), + ); + } + Mode::Patient => { + self.runtime.run( + stream + .map(Action_::Runtime) + .map(Action) + .map(Event::Action) + .chain(stream::once(async { Event::Ready })) + .boxed(), + ); + } + Mode::Immediate => { + self.runtime.run( + stream + .map(Action_::Runtime) + .map(Action) + .map(Event::Action) + .boxed(), + ); + self.runtime.send(Event::Ready); + } + } + } else if self.pending_tasks == 0 { + self.runtime.send(Event::Ready); + } + } + + fn resubscribe(&mut self, program: &P) { + self.runtime + .track(subscription::into_recipes(self.runtime.enter(|| { + program.subscription(&self.state).map(|message| { + Event::Action(Action(Action_::Runtime( + runtime::Action::Output(message), + ))) + }) + }))); + } + + /// Returns the current view of the [`Emulator`]. + pub fn view( + &self, + program: &P, + ) -> Element<'_, P::Message, P::Theme, P::Renderer> { + program.view(&self.state, self.window) + } + + /// Returns the current theme of the [`Emulator`]. + pub fn theme(&self, program: &P) -> Option { + program.theme(&self.state, self.window) + } + + /// Turns the [`Emulator`] into its internal state. + pub fn into_state(self) -> (P::State, core::window::Id) { + (self.state, self.window) + } +} + +/// The strategy used by an [`Emulator`] when waiting for tasks to finish. +/// +/// A [`Mode`] can be used to make an [`Emulator`] wait for side effects to finish before +/// continuing execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + /// Waits for all tasks spawned by an [`Instruction`], as well as all tasks indirectly + /// spawned by the the results of those tasks. + /// + /// This is the default. + #[default] + Zen, + /// Waits only for the tasks directly spawned by an [`Instruction`]. + Patient, + /// Never waits for any tasks to finish. + Immediate, +} + +impl Mode { + /// A list of all the available modes. + pub const ALL: &[Self] = &[Self::Zen, Self::Patient, Self::Immediate]; +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Zen => "Zen", + Self::Patient => "Patient", + Self::Immediate => "Immediate", + }) + } +} + +struct Clipboard { + content: Option, +} + +impl core::Clipboard for Clipboard { + fn read(&self, _kind: core::clipboard::Kind) -> Option { + self.content.clone() + } + + fn write(&mut self, _kind: core::clipboard::Kind, contents: String) { + self.content = Some(contents); + } +} diff --git a/test/src/error.rs b/test/src/error.rs new file mode 100644 index 00000000..96ec47c4 --- /dev/null +++ b/test/src/error.rs @@ -0,0 +1,76 @@ +use crate::Instruction; +use crate::ice; + +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +/// A test error. +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + /// No matching widget was found for the [`Selector`](crate::Selector). + #[error("no matching widget was found for the selector: {selector}")] + SelectorNotFound { + /// A description of the selector. + selector: String, + }, + /// A target matched, but is not visible. + #[error("the matching target is not visible: {target:?}")] + TargetNotVisible { + /// The target + target: Arc, + }, + /// An IO operation failed. + #[error("an IO operation failed: {0}")] + IOFailed(Arc), + /// The decoding of some PNG image failed. + #[error("the decoding of some PNG image failed: {0}")] + PngDecodingFailed(Arc), + /// The encoding of some PNG image failed. + #[error("the encoding of some PNG image failed: {0}")] + PngEncodingFailed(Arc), + /// The parsing of an [`Ice`](crate::Ice) test failed. + #[error("the ice test ({file}) is invalid: {error}")] + IceParsingFailed { + /// The path of the test. + file: PathBuf, + /// The parse error. + error: ice::ParseError, + }, + /// The execution of an [`Ice`](crate::Ice) test failed. + #[error("the ice test ({file}) failed")] + IceTestingFailed { + /// The path of the test. + file: PathBuf, + /// The [`Instruction`] that failed. + instruction: Instruction, + }, + /// The [`Preset`](crate::program::Preset) of a program could not be found. + #[error( + "the preset \"{name}\" does not exist (available presets: {available:?})" + )] + PresetNotFound { + /// The name of the [`Preset`](crate::program::Preset). + name: String, + /// The available set of presets. + available: Vec, + }, +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::IOFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: png::DecodingError) -> Self { + Self::PngDecodingFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: png::EncodingError) -> Self { + Self::PngEncodingFailed(Arc::new(error)) + } +} diff --git a/test/src/ice.rs b/test/src/ice.rs new file mode 100644 index 00000000..bc26fd77 --- /dev/null +++ b/test/src/ice.rs @@ -0,0 +1,237 @@ +//! A shareable, simple format of end-to-end tests. +use crate::Instruction; +use crate::core::Size; +use crate::emulator; +use crate::instruction; + +/// An end-to-end test for iced applications. +/// +/// Ice tests encode a certain configuration together with a sequence of instructions. +/// An ice test passes if all the instructions can be executed successfully. +/// +/// Normally, ice tests are run by an [`Emulator`](crate::Emulator) in continuous +/// integration pipelines. +/// +/// Ice tests can be easily run by saving them as `.ice` files in a folder and simply +/// calling [`run`](crate::run). These test files can be recorded by enabling the `tester` +/// feature flag in the root crate. +#[derive(Debug, Clone, PartialEq)] +pub struct Ice { + /// The viewport [`Size`] that must be used for the test. + pub viewport: Size, + /// The [`emulator::Mode`] that must be used for the test. + pub mode: emulator::Mode, + /// The name of the [`Preset`](crate::program::Preset) that must be used for the test. + pub preset: Option, + /// The sequence of instructions of the test. + pub instructions: Vec, +} + +impl Ice { + /// Parses an [`Ice`] test from its textual representation. + /// + /// Here is an example of the [`Ice`] test syntax: + /// + /// ```text + /// viewport: 500x800 + /// mode: Immediate + /// preset: Empty + /// ----- + /// click "What needs to be done?" + /// type "Create the universe" + /// type enter + /// type "Make an apple pie" + /// type enter + /// expect "2 tasks left" + /// click "Create the universe" + /// expect "1 task left" + /// click "Make an apple pie" + /// expect "0 tasks left" + /// ``` + /// + /// This syntax is _very_ experimental and extremely likely to change often. + /// For this reason, it is reserved for advanced users that want to early test it. + /// + /// Currently, in order to use it, you will need to earn the right and prove you understand + /// its experimental nature by reading the code! + pub fn parse(content: &str) -> Result { + let Some((metadata, rest)) = content.split_once("-") else { + return Err(ParseError::NoMetadata); + }; + + let mut viewport = None; + let mut mode = None; + let mut preset = None; + + for (i, line) in metadata.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + + let Some((field, value)) = line.split_once(':') else { + return Err(ParseError::InvalidMetadata { + line: i, + content: line.to_owned(), + }); + }; + + match field.trim() { + "viewport" => { + viewport = Some( + if let Some((width, height)) = + value.trim().split_once('x') + && let Ok(width) = width.parse() + && let Ok(height) = height.parse() + { + Size::new(width, height) + } else { + return Err(ParseError::InvalidViewport { + line: i, + value: value.to_owned(), + }); + }, + ); + } + "mode" => { + mode = Some(match value.trim().to_lowercase().as_str() { + "zen" => emulator::Mode::Zen, + "patient" => emulator::Mode::Patient, + "immediate" => emulator::Mode::Immediate, + _ => { + return Err(ParseError::InvalidMode { + line: i, + value: value.to_owned(), + }); + } + }); + } + "preset" => { + preset = Some(value.trim().to_owned()); + } + field => { + return Err(ParseError::UnknownField { + line: i, + field: field.to_owned(), + }); + } + } + } + + let Some(viewport) = viewport else { + return Err(ParseError::MissingViewport); + }; + + let Some(mode) = mode else { + return Err(ParseError::MissingMode); + }; + + let instructions = rest + .lines() + .skip(1) + .enumerate() + .map(|(i, line)| { + Instruction::parse(line).map_err(|error| { + ParseError::InvalidInstruction { + line: metadata.lines().count() + 1 + i, + error, + } + }) + }) + .collect::, _>>()?; + + Ok(Self { + viewport, + mode, + preset, + instructions, + }) + } +} + +impl std::fmt::Display for Ice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "viewport: {width}x{height}", + width = self.viewport.width as u32, // TODO + height = self.viewport.height as u32, // TODO + )?; + + writeln!(f, "mode: {}", self.mode)?; + + if let Some(preset) = &self.preset { + writeln!(f, "preset: {preset}")?; + } + + f.write_str("-----\n")?; + + for instruction in &self.instructions { + instruction.fmt(f)?; + f.write_str("\n")?; + } + + Ok(()) + } +} + +/// An error produced during [`Ice::parse`]. +#[derive(Debug, Clone, thiserror::Error)] +pub enum ParseError { + /// No metadata is present. + #[error("the ice test has no metadata")] + NoMetadata, + + /// The metadata is invalid. + #[error("invalid metadata in line {line}: \"{content}\"")] + InvalidMetadata { + /// The number of the invalid line. + line: usize, + /// The content of the invalid line. + content: String, + }, + + /// The viewport is invalid. + #[error("invalid viewport in line {line}: \"{value}\"")] + InvalidViewport { + /// The number of the invalid line. + line: usize, + + /// The invalid value. + value: String, + }, + + /// The [`emulator::Mode`] is invalid. + #[error("invalid mode in line {line}: \"{value}\"")] + InvalidMode { + /// The number of the invalid line. + line: usize, + /// The invalid value. + value: String, + }, + + /// A metadata field is unknown. + #[error("unknown metadata field in line {line}: \"{field}\"")] + UnknownField { + /// The number of the invalid line. + line: usize, + /// The name of the unknown field. + field: String, + }, + + /// Viewport metadata is missing. + #[error("metadata is missing the viewport field")] + MissingViewport, + + /// [`emulator::Mode`] metadata is missing. + #[error("metadata is missing the mode field")] + MissingMode, + + /// An [`Instruction`] failed to parse. + #[error("invalid instruction in line {line}: {error}")] + InvalidInstruction { + /// The number of the invalid line. + line: usize, + /// The parse error. + error: instruction::ParseError, + }, +} diff --git a/test/src/instruction.rs b/test/src/instruction.rs new file mode 100644 index 00000000..99d0d505 --- /dev/null +++ b/test/src/instruction.rs @@ -0,0 +1,729 @@ +//! A step in an end-to-end test. +use crate::core::keyboard; +use crate::core::mouse; +use crate::core::{Event, Point}; +use crate::simulator; + +use std::fmt; + +/// A step in an end-to-end test. +/// +/// An [`Instruction`] can be run by an [`Emulator`](crate::Emulator). +#[derive(Debug, Clone, PartialEq)] +pub enum Instruction { + /// A user [`Interaction`]. + Interact(Interaction), + /// A testing [`Expectation`]. + Expect(Expectation), +} + +impl Instruction { + /// Parses an [`Instruction`] from its textual representation. + pub fn parse(line: &str) -> Result { + parser::run(line) + } +} + +impl fmt::Display for Instruction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Instruction::Interact(interaction) => interaction.fmt(f), + Instruction::Expect(expectation) => expectation.fmt(f), + } + } +} + +/// A user interaction. +#[derive(Debug, Clone, PartialEq)] +pub enum Interaction { + /// A mouse interaction. + Mouse(Mouse), + /// A keyboard interaction. + Keyboard(Keyboard), +} + +impl Interaction { + /// Creates an [`Interaction`] from a runtime [`Event`]. + /// + /// This can be useful for recording tests during real usage. + pub fn from_event(event: &Event) -> Option { + Some(match event { + Event::Mouse(mouse) => Self::Mouse(match mouse { + mouse::Event::CursorMoved { position } => { + Mouse::Move(Target::Point(*position)) + } + mouse::Event::ButtonPressed(button) => Mouse::Press { + button: *button, + target: None, + }, + mouse::Event::ButtonReleased(button) => Mouse::Release { + button: *button, + target: None, + }, + _ => None?, + }), + Event::Keyboard(keyboard) => Self::Keyboard(match keyboard { + keyboard::Event::KeyPressed { key, text, .. } => match key { + keyboard::Key::Named(keyboard::key::Named::Enter) => { + Keyboard::Press(Key::Enter) + } + keyboard::Key::Named(keyboard::key::Named::Escape) => { + Keyboard::Press(Key::Escape) + } + keyboard::Key::Named(keyboard::key::Named::Tab) => { + Keyboard::Press(Key::Tab) + } + keyboard::Key::Named(keyboard::key::Named::Backspace) => { + Keyboard::Press(Key::Backspace) + } + _ => Keyboard::Typewrite(text.as_ref()?.to_string()), + }, + keyboard::Event::KeyReleased { key, .. } => match key { + keyboard::Key::Named(keyboard::key::Named::Enter) => { + Keyboard::Release(Key::Enter) + } + keyboard::Key::Named(keyboard::key::Named::Escape) => { + Keyboard::Release(Key::Escape) + } + keyboard::Key::Named(keyboard::key::Named::Tab) => { + Keyboard::Release(Key::Tab) + } + keyboard::Key::Named(keyboard::key::Named::Backspace) => { + Keyboard::Release(Key::Backspace) + } + _ => None?, + }, + keyboard::Event::ModifiersChanged(_) => None?, + }), + _ => None?, + }) + } + + /// Merges two interactions together, if possible. + /// + /// This method can turn certain sequences of interactions into a single one. + /// For instance, a mouse movement, left button press, and left button release + /// can all be merged into a single click interaction. + /// + /// Merging is lossy and, therefore, it is not always desirable if you are recording + /// a test and want full reproducibility. + /// + /// If the interactions cannot be merged, the `next` interaction will be + /// returned as the second element of the tuple. + pub fn merge(self, next: Self) -> (Self, Option) { + match (self, next) { + (Self::Mouse(current), Self::Mouse(next)) => { + match (current, next) { + (Mouse::Move(_), Mouse::Move(to)) => { + (Self::Mouse(Mouse::Move(to)), None) + } + ( + Mouse::Move(to), + Mouse::Press { + button, + target: None, + }, + ) => ( + Self::Mouse(Mouse::Press { + button, + target: Some(to), + }), + None, + ), + ( + Mouse::Move(to), + Mouse::Release { + button, + target: None, + }, + ) => ( + Self::Mouse(Mouse::Release { + button, + target: Some(to), + }), + None, + ), + ( + Mouse::Press { + button: press, + target: press_at, + }, + Mouse::Release { + button: release, + target: release_at, + }, + ) if press == release + && release_at.as_ref().is_none_or(|release_at| { + Some(release_at) == press_at.as_ref() + }) => + { + ( + Self::Mouse(Mouse::Click { + button: press, + target: press_at, + }), + None, + ) + } + ( + Mouse::Press { + button, + target: Some(press_at), + }, + Mouse::Move(move_at), + ) if press_at == move_at => ( + Self::Mouse(Mouse::Press { + button, + target: Some(press_at), + }), + None, + ), + ( + Mouse::Click { + button, + target: Some(click_at), + }, + Mouse::Move(move_at), + ) if click_at == move_at => ( + Self::Mouse(Mouse::Click { + button, + target: Some(click_at), + }), + None, + ), + (current, next) => { + (Self::Mouse(current), Some(Self::Mouse(next))) + } + } + } + (Self::Keyboard(current), Self::Keyboard(next)) => { + match (current, next) { + ( + Keyboard::Typewrite(current), + Keyboard::Typewrite(next), + ) => ( + Self::Keyboard(Keyboard::Typewrite(format!( + "{current}{next}" + ))), + None, + ), + (Keyboard::Press(current), Keyboard::Release(next)) + if current == next => + { + (Self::Keyboard(Keyboard::Type(current)), None) + } + (current, next) => { + (Self::Keyboard(current), Some(Self::Keyboard(next))) + } + } + } + (current, next) => (current, Some(next)), + } + } + + /// Returns a list of runtime events representing the [`Interaction`]. + /// + /// The `find_target` closure must convert a [`Target`] into its screen + /// coordinates. + pub fn events( + &self, + find_target: impl FnOnce(&Target) -> Option, + ) -> Option> { + let mouse_move_ = + |to| Event::Mouse(mouse::Event::CursorMoved { position: to }); + + let mouse_press = + |button| Event::Mouse(mouse::Event::ButtonPressed(button)); + + let mouse_release = + |button| Event::Mouse(mouse::Event::ButtonReleased(button)); + + let key_press = |key| simulator::press_key(key, None); + + let key_release = |key| simulator::release_key(key); + + Some(match self { + Interaction::Mouse(mouse) => match mouse { + Mouse::Move(to) => vec![mouse_move_(find_target(to)?)], + Mouse::Press { + button, + target: Some(at), + } => vec![mouse_move_(find_target(at)?), mouse_press(*button)], + Mouse::Press { + button, + target: None, + } => { + vec![mouse_press(*button)] + } + Mouse::Release { + button, + target: Some(at), + } => { + vec![mouse_move_(find_target(at)?), mouse_release(*button)] + } + Mouse::Release { + button, + target: None, + } => { + vec![mouse_release(*button)] + } + Mouse::Click { + button, + target: Some(at), + } => { + vec![ + mouse_move_(find_target(at)?), + mouse_press(*button), + mouse_release(*button), + ] + } + Mouse::Click { + button, + target: None, + } => { + vec![mouse_press(*button), mouse_release(*button)] + } + }, + Interaction::Keyboard(keyboard) => match keyboard { + Keyboard::Press(key) => vec![key_press(*key)], + Keyboard::Release(key) => vec![key_release(*key)], + Keyboard::Type(key) => vec![key_press(*key), key_release(*key)], + Keyboard::Typewrite(text) => { + simulator::typewrite(text).collect() + } + }, + }) + } +} + +impl fmt::Display for Interaction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Interaction::Mouse(mouse) => mouse.fmt(f), + Interaction::Keyboard(keyboard) => keyboard.fmt(f), + } + } +} + +/// A mouse interaction. +#[derive(Debug, Clone, PartialEq)] +pub enum Mouse { + /// The mouse was moved. + Move(Target), + /// A button was pressed. + Press { + /// The button. + button: mouse::Button, + /// The location of the press. + target: Option, + }, + /// A button was released. + Release { + /// The button. + button: mouse::Button, + /// The location of the release. + target: Option, + }, + /// A button was clicked. + Click { + /// The button. + button: mouse::Button, + /// The location of the click. + target: Option, + }, +} + +impl fmt::Display for Mouse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Mouse::Move(target) => { + write!(f, "move cursor to {}", target) + } + Mouse::Press { button, target } => { + write!( + f, + "press {}", + format::button_at(*button, target.as_ref()) + ) + } + Mouse::Release { button, target } => { + write!( + f, + "release {}", + format::button_at(*button, target.as_ref()) + ) + } + Mouse::Click { button, target } => { + write!( + f, + "click {}", + format::button_at(*button, target.as_ref()) + ) + } + } + } +} + +/// The target of an interaction. +#[derive(Debug, Clone, PartialEq)] +pub enum Target { + /// A specific point of the viewport. + Point(Point), + /// A UI element containing the given text. + Text(String), +} + +impl fmt::Display for Target { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Point(point) => f.write_str(&format::point(*point)), + Self::Text(text) => f.write_str(&format::string(text)), + } + } +} + +/// A keyboard interaction. +#[derive(Debug, Clone, PartialEq)] +pub enum Keyboard { + /// A key was pressed. + Press(Key), + /// A key was released. + Release(Key), + /// A key was "typed" (press and released). + Type(Key), + /// A bunch of text was typed. + Typewrite(String), +} + +impl fmt::Display for Keyboard { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Keyboard::Press(key) => { + write!(f, "press {}", format::key(*key)) + } + Keyboard::Release(key) => { + write!(f, "release {}", format::key(*key)) + } + Keyboard::Type(key) => { + write!(f, "type {}", format::key(*key)) + } + Keyboard::Typewrite(text) => { + write!(f, "type \"{text}\"") + } + } + } +} + +/// A keyboard key. +/// +/// Only a small subset of keys is supported currently! +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(missing_docs)] +pub enum Key { + Enter, + Escape, + Tab, + Backspace, +} + +impl From for keyboard::Key { + fn from(key: Key) -> Self { + match key { + Key::Enter => Self::Named(keyboard::key::Named::Enter), + Key::Escape => Self::Named(keyboard::key::Named::Escape), + Key::Tab => Self::Named(keyboard::key::Named::Tab), + Key::Backspace => Self::Named(keyboard::key::Named::Backspace), + } + } +} + +mod format { + use super::*; + + pub fn button_at(button: mouse::Button, at: Option<&Target>) -> String { + let button = self::button(button); + + if let Some(at) = at { + if button.is_empty() { + at.to_string() + } else { + format!("{} {}", button, at) + } + } else { + button.to_owned() + } + } + + pub fn button(button: mouse::Button) -> &'static str { + match button { + mouse::Button::Left => "", + mouse::Button::Right => "right", + mouse::Button::Middle => "middle", + mouse::Button::Back => "back", + mouse::Button::Forward => "forward", + mouse::Button::Other(_) => "other", + } + } + + pub fn point(point: Point) -> String { + format!("({:.2}, {:.2})", point.x, point.y) + } + + pub fn key(key: Key) -> &'static str { + match key { + Key::Enter => "enter", + Key::Escape => "escape", + Key::Tab => "tab", + Key::Backspace => "backspace", + } + } + + pub fn string(text: &str) -> String { + format!("\"{}\"", text.escape_default()) + } +} + +/// A testing assertion. +/// +/// Expectations are instructions that verify the current state of +/// the user interface of an application. +#[derive(Debug, Clone, PartialEq)] +pub enum Expectation { + /// Expect some element to contain some text. + Text(String), +} + +impl fmt::Display for Expectation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Expectation::Text(text) => { + write!(f, "expect {}", format::string(text)) + } + } + } +} + +pub use parser::Error as ParseError; + +mod parser { + use super::*; + + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::bytes::{is_not, take_while_m_n}; + use nom::character::complete::{char, multispace0, multispace1}; + use nom::combinator::{map, map_opt, map_res, opt, success, value, verify}; + use nom::error::ParseError; + use nom::multi::fold; + use nom::number::float; + use nom::sequence::{delimited, preceded, separated_pair}; + use nom::{Finish, IResult, Parser}; + + /// A parsing error. + #[derive(Debug, Clone, thiserror::Error)] + #[error("parse error: {0}")] + pub struct Error(nom::error::Error); + + pub fn run(input: &str) -> Result { + match instruction.parse_complete(input).finish() { + Ok((_rest, instruction)) => Ok(instruction), + Err(error) => Err(Error(error.cloned())), + } + } + + fn instruction(input: &str) -> IResult<&str, Instruction> { + alt(( + map(interaction, Instruction::Interact), + map(expectation, Instruction::Expect), + )) + .parse(input) + } + + fn interaction(input: &str) -> IResult<&str, Interaction> { + alt(( + map(mouse, Interaction::Mouse), + map(keyboard, Interaction::Keyboard), + )) + .parse(input) + } + + fn mouse(input: &str) -> IResult<&str, Mouse> { + let mouse_move = + preceded(tag("move cursor to "), target).map(Mouse::Move); + + alt((mouse_move, mouse_click)).parse(input) + } + + fn mouse_click(input: &str) -> IResult<&str, Mouse> { + let (input, _) = tag("click ")(input)?; + + let (input, (button, target)) = mouse_button_at(input)?; + + Ok((input, Mouse::Click { button, target })) + } + + fn mouse_button_at( + input: &str, + ) -> IResult<&str, (mouse::Button, Option)> { + let (input, button) = mouse_button(input)?; + let (input, at) = opt(target).parse(input)?; + + Ok((input, (button, at))) + } + + fn target(input: &str) -> IResult<&str, Target> { + alt((string.map(Target::Text), point.map(Target::Point))).parse(input) + } + + fn mouse_button(input: &str) -> IResult<&str, mouse::Button> { + alt(( + tag("right").map(|_| mouse::Button::Right), + success(mouse::Button::Left), + )) + .parse(input) + } + + fn keyboard(input: &str) -> IResult<&str, Keyboard> { + alt(( + map(preceded(tag("type "), string), Keyboard::Typewrite), + map(preceded(tag("type "), key), Keyboard::Type), + )) + .parse(input) + } + + fn expectation(input: &str) -> IResult<&str, Expectation> { + map(preceded(tag("expect "), string), |text| { + Expectation::Text(text) + }) + .parse(input) + } + + fn key(input: &str) -> IResult<&str, Key> { + alt(( + map(tag("enter"), |_| Key::Enter), + map(tag("escape"), |_| Key::Escape), + map(tag("tab"), |_| Key::Tab), + map(tag("backspace"), |_| Key::Backspace), + )) + .parse(input) + } + + fn point(input: &str) -> IResult<&str, Point> { + let comma = whitespace(char(',')); + + map( + delimited( + char('('), + separated_pair(float(), comma, float()), + char(')'), + ), + |(x, y)| Point { x, y }, + ) + .parse(input) + } + + pub fn whitespace<'a, O, E: ParseError<&'a str>, F>( + inner: F, + ) -> impl Parser<&'a str, Output = O, Error = E> + where + F: Parser<&'a str, Output = O, Error = E>, + { + delimited(multispace0, inner, multispace0) + } + + // Taken from https://github.com/rust-bakery/nom/blob/51c3c4e44fa78a8a09b413419372b97b2cc2a787/examples/string.rs + // + // Copyright (c) 2014-2019 Geoffroy Couprie + // + // Permission is hereby granted, free of charge, to any person obtaining + // a copy of this software and associated documentation files (the + // "Software"), to deal in the Software without restriction, including + // without limitation the rights to use, copy, modify, merge, publish, + // distribute, sublicense, and/or sell copies of the Software, and to + // permit persons to whom the Software is furnished to do so, subject to + // the following conditions: + // + // The above copyright notice and this permission notice shall be + // included in all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + fn string(input: &str) -> IResult<&str, String> { + #[derive(Debug, Clone, Copy)] + enum Fragment<'a> { + Literal(&'a str), + EscapedChar(char), + EscapedWS, + } + + fn fragment(input: &str) -> IResult<&str, Fragment<'_>> { + alt(( + map(string_literal, Fragment::Literal), + map(escaped_char, Fragment::EscapedChar), + value(Fragment::EscapedWS, escaped_whitespace), + )) + .parse(input) + } + + fn string_literal<'a, E: ParseError<&'a str>>( + input: &'a str, + ) -> IResult<&'a str, &'a str, E> { + let not_quote_slash = is_not("\"\\"); + + verify(not_quote_slash, |s: &str| !s.is_empty()).parse(input) + } + + fn unicode(input: &str) -> IResult<&str, char> { + let parse_hex = + take_while_m_n(1, 6, |c: char| c.is_ascii_hexdigit()); + + let parse_delimited_hex = + preceded(char('u'), delimited(char('{'), parse_hex, char('}'))); + + let parse_u32 = map_res(parse_delimited_hex, move |hex| { + u32::from_str_radix(hex, 16) + }); + + map_opt(parse_u32, std::char::from_u32).parse(input) + } + + fn escaped_char(input: &str) -> IResult<&str, char> { + preceded( + char('\\'), + alt(( + unicode, + value('\n', char('n')), + value('\r', char('r')), + value('\t', char('t')), + value('\u{08}', char('b')), + value('\u{0C}', char('f')), + value('\\', char('\\')), + value('/', char('/')), + value('"', char('"')), + )), + ) + .parse(input) + } + + fn escaped_whitespace(input: &str) -> IResult<&str, &str> { + preceded(char('\\'), multispace1).parse(input) + } + + let build_string = + fold(0.., fragment, String::new, |mut string, fragment| { + match fragment { + Fragment::Literal(s) => string.push_str(s), + Fragment::EscapedChar(c) => string.push(c), + Fragment::EscapedWS => {} + } + string + }); + + delimited(char('"'), build_string, char('"')).parse(input) + } +} diff --git a/test/src/lib.rs b/test/src/lib.rs index 72f9441b..ba5c8c9c 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -24,19 +24,18 @@ //! # impl Counter { //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! # let mut ui = simulator(counter.view()); -//! -//! let _ = ui.click(text("+")); -//! let _ = ui.click(text("+")); -//! let _ = ui.click(text("-")); +//! # +//! let _ = ui.click("+"); +//! let _ = ui.click("+"); +//! let _ = ui.click("-"); //! ``` //! -//! [`Simulator::click`] takes a [`Selector`]. A [`Selector`] describes a way to query the widgets of an interface. In this case, -//! [`selector::text`] lets us select a widget by the text it contains. +//! [`Simulator::click`] takes a type implementing the [`Selector`] trait. A [`Selector`] describes a way to query the widgets of an interface. +//! In this case, we leverage the [`Selector`] implementation of `&str`, which selects a widget by the text it contains. //! //! We can now process any messages produced by these interactions and then assert that the final value of our counter is //! indeed `1`! @@ -47,15 +46,14 @@ //! # pub fn update(&mut self, message: ()) {} //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! # use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! # let mut ui = simulator(counter.view()); //! # -//! # let _ = ui.click(text("+")); -//! # let _ = ui.click(text("+")); -//! # let _ = ui.click(text("-")); +//! # let _ = ui.click("+"); +//! # let _ = ui.click("+"); +//! # let _ = ui.click("-"); //! # //! for message in ui.into_messages() { //! counter.update(message); @@ -71,13 +69,12 @@ //! # impl Counter { //! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } //! # } -//! # use iced_test::selector::text; //! # use iced_test::simulator; //! # //! # let mut counter = Counter { value: 0 }; //! let mut ui = simulator(counter.view()); //! -//! assert!(ui.find(text("1")).is_ok(), "Counter should display 1!"); +//! assert!(ui.find("1").is_ok(), "Counter should display 1!"); //! ``` //! //! And that's it! That's the gist of testing `iced` applications! @@ -86,578 +83,133 @@ //! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)! //! //! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface -pub mod selector; +pub use iced_program as program; +pub use iced_renderer as renderer; +pub use iced_runtime as runtime; +pub use iced_runtime::core; +pub use iced_selector as selector; + +pub mod emulator; +pub mod ice; +pub mod instruction; +pub mod simulator; + +mod error; + +pub use emulator::Emulator; +pub use error::Error; +pub use ice::Ice; +pub use instruction::Instruction; pub use selector::Selector; +pub use simulator::{Simulator, simulator}; -use iced_renderer as renderer; -use iced_runtime as runtime; -use iced_runtime::core; +use std::path::Path; -use crate::core::clipboard; -use crate::core::event; -use crate::core::keyboard; -use crate::core::mouse; -use crate::core::theme; -use crate::core::time; -use crate::core::widget; -use crate::core::window; -use crate::core::{ - Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr, -}; -use crate::runtime::UserInterface; -use crate::runtime::user_interface; - -use std::borrow::Cow; -use std::env; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -/// Creates a new [`Simulator`]. +/// Runs an [`Ice`] test suite for the given [`Program`](program::Program). /// -/// This is just a function version of [`Simulator::new`]. -pub fn simulator<'a, Message, Theme, Renderer>( - element: impl Into>, -) -> Simulator<'a, Message, Theme, Renderer> -where - Theme: theme::Base, - Renderer: core::Renderer + core::renderer::Headless, -{ - Simulator::new(element) -} +/// Any `.ice` tests will be parsed from the given directory and executed in +/// an [`Emulator`] of the given [`Program`](program::Program). +/// +/// Remember that an [`Emulator`] executes the real thing! Side effects _will_ +/// take place. It is up to you to ensure your tests have reproducible environments +/// by leveraging [`Preset`][program::Preset]. +pub fn run( + program: impl program::Program + 'static, + tests_dir: impl AsRef, +) -> Result<(), Error> { + use crate::runtime::futures::futures::StreamExt; + use crate::runtime::futures::futures::channel::mpsc; + use crate::runtime::futures::futures::executor; -/// A user interface that can be interacted with and inspected programmatically. -#[allow(missing_debug_implementations)] -pub struct Simulator< - 'a, - Message, - Theme = core::Theme, - Renderer = renderer::Renderer, -> { - raw: UserInterface<'a, Message, Theme, Renderer>, - renderer: Renderer, - size: Size, - cursor: mouse::Cursor, - messages: Vec, -} + use std::ffi::OsStr; + use std::fs; -/// A specific area of a [`Simulator`], normally containing a widget. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Target { - /// The bounds of the area. - pub bounds: Rectangle, -} + let files = fs::read_dir(tests_dir)?; + let mut tests = Vec::new(); -impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> -where - Theme: theme::Base, - Renderer: core::Renderer + core::renderer::Headless, -{ - /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). - pub fn new( - element: impl Into>, - ) -> Self { - Self::with_settings(Settings::default(), element) - } + for file in files { + let file = file?; - /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). - pub fn with_settings( - settings: Settings, - element: impl Into>, - ) -> Self { - Self::with_size(settings, window::Settings::default().size, element) - } - - /// Creates a new [`Simulator`] with the given [`Settings`] and size. - pub fn with_size( - settings: Settings, - size: impl Into, - element: impl Into>, - ) -> Self { - let size = size.into(); - - let default_font = match settings.default_font { - Font::DEFAULT => Font::with_name("Fira Sans"), - _ => settings.default_font, - }; - - for font in settings.fonts { - load_font(font).expect("Font must be valid"); + if file.path().extension().and_then(OsStr::to_str) != Some("ice") { + continue; } - let mut renderer = { - let backend = env::var("ICED_TEST_BACKEND").ok(); + let content = fs::read_to_string(file.path())?; - iced_runtime::futures::futures::executor::block_on(Renderer::new( - default_font, - settings.default_text_size, - backend.as_deref(), - )) - .expect("Create new headless renderer") - }; + match Ice::parse(&content) { + Ok(ice) => { + let preset = if let Some(preset) = &ice.preset { + let Some(preset) = program + .presets() + .iter() + .find(|candidate| candidate.name() == preset) + else { + return Err(Error::PresetNotFound { + name: preset.to_owned(), + available: program + .presets() + .iter() + .map(program::Preset::name) + .map(str::to_owned) + .collect(), + }); + }; - let raw = UserInterface::build( - element, - size, - user_interface::Cache::default(), - &mut renderer, - ); + Some(preset) + } else { + None + }; - Simulator { - raw, - renderer, - size, - cursor: mouse::Cursor::Unavailable, - messages: Vec::new(), - } - } - - /// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`]. - pub fn find( - &mut self, - selector: impl Into, - ) -> Result { - let selector = selector.into(); - - match &selector { - Selector::Id(id) => { - struct FindById<'a> { - id: &'a widget::Id, - target: Option, - } - - impl widget::Operation for FindById<'_> { - fn container( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut( - &mut dyn widget::Operation<()>, - ), - ) { - if self.target.is_some() { - return; - } - - if Some(self.id) == id { - self.target = Some(Target { bounds }); - return; - } - - operate_on_children(self); - } - - fn scrollable( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - _content_bounds: Rectangle, - _translation: core::Vector, - _state: &mut dyn widget::operation::Scrollable, - ) { - if self.target.is_some() { - return; - } - - if Some(self.id) == id { - self.target = Some(Target { bounds }); - } - } - - fn text_input( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - _state: &mut dyn widget::operation::TextInput, - ) { - if self.target.is_some() { - return; - } - - if Some(self.id) == id { - self.target = Some(Target { bounds }); - } - } - - fn text( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - _text: &str, - ) { - if self.target.is_some() { - return; - } - - if Some(self.id) == id { - self.target = Some(Target { bounds }); - } - } - - fn custom( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - _state: &mut dyn std::any::Any, - ) { - if self.target.is_some() { - return; - } - - if Some(self.id) == id { - self.target = Some(Target { bounds }); - } - } - } - - let mut find = FindById { id, target: None }; - self.raw.operate(&self.renderer, &mut find); - - find.target.ok_or(Error::NotFound(selector)) + tests.push((file, ice, preset)); } - Selector::Text(text) => { - struct FindByText<'a> { - text: &'a str, - target: Option, - } - - impl widget::Operation for FindByText<'_> { - fn container( - &mut self, - _id: Option<&widget::Id>, - _bounds: Rectangle, - operate_on_children: &mut dyn FnMut( - &mut dyn widget::Operation<()>, - ), - ) { - if self.target.is_some() { - return; - } - - operate_on_children(self); - } - - fn text( - &mut self, - _id: Option<&widget::Id>, - bounds: Rectangle, - text: &str, - ) { - if self.target.is_some() { - return; - } - - if self.text == text { - self.target = Some(Target { bounds }); - } - } - } - - let mut find = FindByText { text, target: None }; - self.raw.operate(&self.renderer, &mut find); - - find.target.ok_or(Error::NotFound(selector)) + Err(error) => { + return Err(Error::IceParsingFailed { + file: file.path().to_path_buf(), + error, + }); } } } - /// Points the mouse cursor at the given position in the [`Simulator`]. - /// - /// This does _not_ produce mouse movement events! - pub fn point_at(&mut self, position: impl Into) { - self.cursor = mouse::Cursor::Available(position.into()); - } + // TODO: Concurrent runtimes + for (file, ice, preset) in tests { + let (sender, mut receiver) = mpsc::channel(1); - /// Clicks the [`Target`] found by the given [`Selector`], if any. - /// - /// This consists in: - /// - Pointing the mouse cursor at the center of the [`Target`]. - /// - Simulating a [`click`]. - pub fn click( - &mut self, - selector: impl Into, - ) -> Result { - let target = self.find(selector)?; - self.point_at(target.bounds.center()); - - let _ = self.simulate(click()); - - Ok(target) - } - - /// Simulates a key press, followed by a release, in the [`Simulator`]. - pub fn tap_key(&mut self, key: impl Into) -> event::Status { - self.simulate(tap_key(key, None)) - .first() - .copied() - .unwrap_or(event::Status::Ignored) - } - - /// Simulates a user typing in the keyboard the given text in the [`Simulator`]. - pub fn typewrite(&mut self, text: &str) -> event::Status { - let statuses = self.simulate(typewrite(text)); - - statuses - .into_iter() - .fold(event::Status::Ignored, event::Status::merge) - } - - /// Simulates the given raw sequence of events in the [`Simulator`]. - pub fn simulate( - &mut self, - events: impl IntoIterator, - ) -> Vec { - let events: Vec = events.into_iter().collect(); - - let (_state, statuses) = self.raw.update( - &events, - self.cursor, - &mut self.renderer, - &mut clipboard::Null, - &mut self.messages, + let mut emulator = Emulator::with_preset( + sender, + &program, + ice.mode, + ice.viewport, + preset, ); - statuses - } + let mut instructions = ice.instructions.into_iter(); - /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. - pub fn snapshot(&mut self, theme: &Theme) -> Result { - let base = theme.base(); + loop { + let event = executor::block_on(receiver.next()) + .expect("emulator runtime should never stop on its own"); - let _ = self.raw.update( - &[Event::Window(window::Event::RedrawRequested( - time::Instant::now(), - ))], - self.cursor, - &mut self.renderer, - &mut clipboard::Null, - &mut self.messages, - ); + match event { + emulator::Event::Action(action) => { + emulator.perform(&program, action); + } + emulator::Event::Failed(instruction) => { + return Err(Error::IceTestingFailed { + file: file.path().to_path_buf(), + instruction, + }); + } + emulator::Event::Ready => { + let Some(instruction) = instructions.next() else { + break; + }; - self.raw.draw( - &mut self.renderer, - theme, - &core::renderer::Style { - text_color: base.text_color, - }, - self.cursor, - ); - - let scale_factor = 2.0; - - let physical_size = Size::new( - (self.size.width * scale_factor).round() as u32, - (self.size.height * scale_factor).round() as u32, - ); - - let rgba = self.renderer.screenshot( - physical_size, - scale_factor, - base.background_color, - ); - - Ok(Snapshot { - screenshot: window::Screenshot::new( - rgba, - physical_size, - scale_factor, - ), - renderer: self.renderer.name(), - }) - } - - /// Turns the [`Simulator`] into the sequence of messages produced by any interactions. - pub fn into_messages( - self, - ) -> impl Iterator + use { - self.messages.into_iter() - } -} - -/// A frame of a user interface rendered by a [`Simulator`]. -#[derive(Debug, Clone)] -pub struct Snapshot { - screenshot: window::Screenshot, - renderer: String, -} - -impl Snapshot { - /// Compares the [`Snapshot`] with the PNG image found in the given path, returning - /// `true` if they are identical. - /// - /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future - /// testing and `true` will be returned. - pub fn matches_image(&self, path: impl AsRef) -> Result { - let path = self.path(path, "png"); - - if path.exists() { - let file = fs::File::open(&path)?; - let decoder = png::Decoder::new(io::BufReader::new(file)); - - let mut reader = decoder.read_info()?; - let n = reader - .output_buffer_size() - .expect("snapshot should fit in memory"); - let mut bytes = vec![0; n]; - let info = reader.next_frame(&mut bytes)?; - - Ok(self.screenshot.bytes == bytes[..info.buffer_size()]) - } else { - if let Some(directory) = path.parent() { - fs::create_dir_all(directory)?; + emulator.run(&program, instruction); + } } - - let file = fs::File::create(path)?; - - let mut encoder = png::Encoder::new( - file, - self.screenshot.size.width, - self.screenshot.size.height, - ); - encoder.set_color(png::ColorType::Rgba); - - let mut writer = encoder.write_header()?; - writer.write_image_data(&self.screenshot.bytes)?; - writer.finish()?; - - Ok(true) } } - /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning - /// `true` if they are identical. - /// - /// If the hash file does not exist, it will be created by the [`Snapshot`] for future - /// testing and `true` will be returned. - pub fn matches_hash(&self, path: impl AsRef) -> Result { - use sha2::{Digest, Sha256}; - - let path = self.path(path, "sha256"); - - let hash = { - let mut hasher = Sha256::new(); - hasher.update(&self.screenshot.bytes); - format!("{:x}", hasher.finalize()) - }; - - if path.exists() { - let saved_hash = fs::read_to_string(&path)?; - - Ok(hash == saved_hash) - } else { - if let Some(directory) = path.parent() { - fs::create_dir_all(directory)?; - } - - fs::write(path, hash)?; - Ok(true) - } - } - - fn path(&self, path: impl AsRef, extension: &str) -> PathBuf { - let path = path.as_ref(); - - path.with_file_name(format!( - "{name}-{renderer}", - name = path - .file_stem() - .map(std::ffi::OsStr::to_string_lossy) - .unwrap_or_default(), - renderer = self.renderer - )) - .with_extension(extension) - } -} - -/// Returns the sequence of events of a click. -pub fn click() -> impl Iterator { - [ - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), - ] - .into_iter() -} - -/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). -pub fn tap_key( - key: impl Into, - text: Option, -) -> impl Iterator { - let key = key.into(); - - [ - Event::Keyboard(keyboard::Event::KeyPressed { - key: key.clone(), - modified_key: key.clone(), - physical_key: keyboard::key::Physical::Unidentified( - keyboard::key::NativeCode::Unidentified, - ), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::default(), - text, - }), - Event::Keyboard(keyboard::Event::KeyReleased { - key: key.clone(), - modified_key: key, - physical_key: keyboard::key::Physical::Unidentified( - keyboard::key::NativeCode::Unidentified, - ), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::default(), - }), - ] - .into_iter() -} - -/// Returns the sequence of events of typewriting the given text in a keyboard. -pub fn typewrite(text: &str) -> impl Iterator + '_ { - text.chars() - .map(|c| SmolStr::new_inline(&c.to_string())) - .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) -} - -/// A test error. -#[derive(Debug, Clone, thiserror::Error)] -pub enum Error { - /// No matching widget was found for the [`Selector`]. - #[error("no matching widget was found for the selector: {0:?}")] - NotFound(Selector), - /// An IO operation failed. - #[error("an IO operation failed: {0}")] - IOFailed(Arc), - /// The decoding of some PNG image failed. - #[error("the decoding of some PNG image failed: {0}")] - PngDecodingFailed(Arc), - /// The encoding of some PNG image failed. - #[error("the encoding of some PNG image failed: {0}")] - PngEncodingFailed(Arc), -} - -impl From for Error { - fn from(error: io::Error) -> Self { - Self::IOFailed(Arc::new(error)) - } -} - -impl From for Error { - fn from(error: png::DecodingError) -> Self { - Self::PngDecodingFailed(Arc::new(error)) - } -} - -impl From for Error { - fn from(error: png::EncodingError) -> Self { - Self::PngEncodingFailed(Arc::new(error)) - } -} - -fn load_font(font: impl Into>) -> Result<(), Error> { - renderer::graphics::text::font_system() - .write() - .expect("Write to font system") - .load_font(font.into()); - Ok(()) } diff --git a/test/src/selector.rs b/test/src/selector.rs deleted file mode 100644 index 58e0fca4..00000000 --- a/test/src/selector.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Select widgets of a user interface. -use crate::core::text; -use crate::core::widget; - -/// A selector describes a strategy to find a certain widget in a user interface. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Selector { - /// Find the widget with the given [`widget::Id`]. - Id(widget::Id), - /// Find the widget containing the given [`text::Fragment`]. - Text(text::Fragment<'static>), -} - -impl From for Selector { - fn from(id: widget::Id) -> Self { - Self::Id(id) - } -} - -impl From<&'static str> for Selector { - fn from(text: &'static str) -> Self { - Self::Text(text.into()) - } -} - -/// Creates a [`Selector`] that finds the widget with the given [`widget::Id`]. -pub fn id(id: impl Into) -> Selector { - Selector::Id(id.into()) -} - -/// Creates a [`Selector`] that finds the widget containing the given text fragment. -pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector { - Selector::Text(fragment.into_fragment()) -} diff --git a/test/src/simulator.rs b/test/src/simulator.rs new file mode 100644 index 00000000..b0a9a5d1 --- /dev/null +++ b/test/src/simulator.rs @@ -0,0 +1,427 @@ +//! Run a simulation of your application without side effects. +use crate::core; +use crate::core::clipboard; +use crate::core::event; +use crate::core::keyboard; +use crate::core::mouse; +use crate::core::theme; +use crate::core::time; +use crate::core::widget; +use crate::core::window; +use crate::core::{Element, Event, Font, Point, Settings, Size, SmolStr}; +use crate::renderer; +use crate::runtime::UserInterface; +use crate::runtime::user_interface; +use crate::selector::Bounded; +use crate::{Error, Selector}; + +use std::borrow::Cow; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// A user interface that can be interacted with and inspected programmatically. +pub struct Simulator< + 'a, + Message, + Theme = core::Theme, + Renderer = renderer::Renderer, +> { + raw: UserInterface<'a, Message, Theme, Renderer>, + renderer: Renderer, + size: Size, + cursor: mouse::Cursor, + messages: Vec, +} + +impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> +where + Theme: theme::Base, + Renderer: core::Renderer + core::renderer::Headless, +{ + /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). + pub fn new( + element: impl Into>, + ) -> Self { + Self::with_settings(Settings::default(), element) + } + + /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). + pub fn with_settings( + settings: Settings, + element: impl Into>, + ) -> Self { + Self::with_size(settings, window::Settings::default().size, element) + } + + /// Creates a new [`Simulator`] with the given [`Settings`] and size. + pub fn with_size( + settings: Settings, + size: impl Into, + element: impl Into>, + ) -> Self { + let size = size.into(); + + let default_font = match settings.default_font { + Font::DEFAULT => Font::with_name("Fira Sans"), + _ => settings.default_font, + }; + + for font in settings.fonts { + load_font(font).expect("Font must be valid"); + } + + let mut renderer = { + let backend = env::var("ICED_TEST_BACKEND").ok(); + + iced_runtime::futures::futures::executor::block_on(Renderer::new( + default_font, + settings.default_text_size, + backend.as_deref(), + )) + .expect("Create new headless renderer") + }; + + let raw = UserInterface::build( + element, + size, + user_interface::Cache::default(), + &mut renderer, + ); + + Simulator { + raw, + renderer, + size, + cursor: mouse::Cursor::Unavailable, + messages: Vec::new(), + } + } + + /// Finds the target of the given widget [`Selector`] in the [`Simulator`]. + pub fn find(&mut self, selector: S) -> Result + where + S: Selector + Send, + S::Output: Clone + Send, + { + use widget::Operation; + + let description = selector.description(); + let mut operation = selector.find(); + + self.raw.operate( + &self.renderer, + &mut widget::operation::black_box(&mut operation), + ); + + match operation.finish() { + widget::operation::Outcome::Some(output) => { + output.ok_or(Error::SelectorNotFound { + selector: description, + }) + } + _ => Err(Error::SelectorNotFound { + selector: description, + }), + } + } + + /// Points the mouse cursor at the given position in the [`Simulator`]. + /// + /// This does _not_ produce mouse movement events! + pub fn point_at(&mut self, position: impl Into) { + self.cursor = mouse::Cursor::Available(position.into()); + } + + /// Clicks the [`Bounded`] target found by the given [`Selector`], if any. + /// + /// This consists in: + /// - Pointing the mouse cursor at the center of the [`Bounded`] target. + /// - Simulating a [`click`]. + pub fn click(&mut self, selector: S) -> Result + where + S: Selector + Send, + S::Output: Bounded + Clone + Send + Sync + 'static, + { + let target = self.find(selector)?; + + let Some(visible_bounds) = target.visible_bounds() else { + return Err(Error::TargetNotVisible { + target: Arc::new(target), + }); + }; + + self.point_at(visible_bounds.center()); + + let _ = self.simulate(click()); + + Ok(target) + } + + /// Simulates a key press, followed by a release, in the [`Simulator`]. + pub fn tap_key(&mut self, key: impl Into) -> event::Status { + self.simulate(tap_key(key, None)) + .first() + .copied() + .unwrap_or(event::Status::Ignored) + } + + /// Simulates a user typing in the keyboard the given text in the [`Simulator`]. + pub fn typewrite(&mut self, text: &str) -> event::Status { + let statuses = self.simulate(typewrite(text)); + + statuses + .into_iter() + .fold(event::Status::Ignored, event::Status::merge) + } + + /// Simulates the given raw sequence of events in the [`Simulator`]. + pub fn simulate( + &mut self, + events: impl IntoIterator, + ) -> Vec { + let events: Vec = events.into_iter().collect(); + + let (_state, statuses) = self.raw.update( + &events, + self.cursor, + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + + statuses + } + + /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. + pub fn snapshot(&mut self, theme: &Theme) -> Result { + let base = theme.base(); + + let _ = self.raw.update( + &[Event::Window(window::Event::RedrawRequested( + time::Instant::now(), + ))], + self.cursor, + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + + self.raw.draw( + &mut self.renderer, + theme, + &core::renderer::Style { + text_color: base.text_color, + }, + self.cursor, + ); + + let scale_factor = 2.0; + + let physical_size = Size::new( + (self.size.width * scale_factor).round() as u32, + (self.size.height * scale_factor).round() as u32, + ); + + let rgba = self.renderer.screenshot( + physical_size, + scale_factor, + base.background_color, + ); + + Ok(Snapshot { + screenshot: window::Screenshot::new( + rgba, + physical_size, + scale_factor, + ), + renderer: self.renderer.name(), + }) + } + + /// Turns the [`Simulator`] into the sequence of messages produced by any interactions. + pub fn into_messages( + self, + ) -> impl Iterator + use { + self.messages.into_iter() + } +} + +/// A frame of a user interface rendered by a [`Simulator`]. +#[derive(Debug, Clone)] +pub struct Snapshot { + screenshot: window::Screenshot, + renderer: String, +} + +impl Snapshot { + /// Compares the [`Snapshot`] with the PNG image found in the given path, returning + /// `true` if they are identical. + /// + /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. + pub fn matches_image(&self, path: impl AsRef) -> Result { + let path = self.path(path, "png"); + + if path.exists() { + let file = fs::File::open(&path)?; + let decoder = png::Decoder::new(io::BufReader::new(file)); + + let mut reader = decoder.read_info()?; + let n = reader + .output_buffer_size() + .expect("snapshot should fit in memory"); + let mut bytes = vec![0; n]; + let info = reader.next_frame(&mut bytes)?; + + Ok(self.screenshot.bytes == bytes[..info.buffer_size()]) + } else { + if let Some(directory) = path.parent() { + fs::create_dir_all(directory)?; + } + + let file = fs::File::create(path)?; + + let mut encoder = png::Encoder::new( + file, + self.screenshot.size.width, + self.screenshot.size.height, + ); + encoder.set_color(png::ColorType::Rgba); + + let mut writer = encoder.write_header()?; + writer.write_image_data(&self.screenshot.bytes)?; + writer.finish()?; + + Ok(true) + } + } + + /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning + /// `true` if they are identical. + /// + /// If the hash file does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. + pub fn matches_hash(&self, path: impl AsRef) -> Result { + use sha2::{Digest, Sha256}; + + let path = self.path(path, "sha256"); + + let hash = { + let mut hasher = Sha256::new(); + hasher.update(&self.screenshot.bytes); + format!("{:x}", hasher.finalize()) + }; + + if path.exists() { + let saved_hash = fs::read_to_string(&path)?; + + Ok(hash == saved_hash) + } else { + if let Some(directory) = path.parent() { + fs::create_dir_all(directory)?; + } + + fs::write(path, hash)?; + Ok(true) + } + } + + fn path(&self, path: impl AsRef, extension: &str) -> PathBuf { + let path = path.as_ref(); + + path.with_file_name(format!( + "{name}-{renderer}", + name = path + .file_stem() + .map(std::ffi::OsStr::to_string_lossy) + .unwrap_or_default(), + renderer = self.renderer + )) + .with_extension(extension) + } +} + +/// Creates a new [`Simulator`]. +/// +/// This is just a function version of [`Simulator::new`]. +pub fn simulator<'a, Message, Theme, Renderer>( + element: impl Into>, +) -> Simulator<'a, Message, Theme, Renderer> +where + Theme: theme::Base, + Renderer: core::Renderer + core::renderer::Headless, +{ + Simulator::new(element) +} + +/// Returns the sequence of events of a click. +pub fn click() -> impl Iterator { + [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] + .into_iter() +} + +/// Returns the sequence of events of a key press. +pub fn press_key( + key: impl Into, + text: Option, +) -> Event { + let key = key.into(); + + Event::Keyboard(keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key, + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::default(), + text, + }) +} + +/// Returns the sequence of events of a key release. +pub fn release_key(key: impl Into) -> Event { + let key = key.into(); + + Event::Keyboard(keyboard::Event::KeyReleased { + key: key.clone(), + modified_key: key, + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::default(), + }) +} + +/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). +pub fn tap_key( + key: impl Into, + text: Option, +) -> impl Iterator { + let key = key.into(); + + [press_key(key.clone(), text), release_key(key)].into_iter() +} + +/// Returns the sequence of events of typewriting the given text in a keyboard. +pub fn typewrite(text: &str) -> impl Iterator + '_ { + text.chars() + .map(|c| SmolStr::new_inline(&c.to_string())) + .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) +} + +fn load_font(font: impl Into>) -> Result<(), Error> { + renderer::graphics::text::font_system() + .write() + .expect("Write to font system") + .load_font(font.into()); + + Ok(()) +} diff --git a/tester/Cargo.toml b/tester/Cargo.toml new file mode 100644 index 00000000..4f97bac3 --- /dev/null +++ b/tester/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "iced_tester" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true +rust-version.workspace = true + +[dependencies] +iced_test.workspace = true +iced_widget.workspace = true +log.workspace = true +rfd.workspace = true + +[lints] +workspace = true diff --git a/tester/fonts/iced_tester-icons.toml b/tester/fonts/iced_tester-icons.toml new file mode 100644 index 00000000..44613c4e --- /dev/null +++ b/tester/fonts/iced_tester-icons.toml @@ -0,0 +1,15 @@ +module = "icon" + +[glyphs] +play = "entypo-play" +stop = "entypo-stop" +pause = "entypo-pause" +record = "entypo-record" +lightbulb = "fontawesome-lightbulb" +check = "entypo-check" +cancel = "entypo-cancel" +folder = "entypo-folder" +floppy = "entypo-floppy" +pencil = "entypo-pencil" +mouse_pointer = "fontawesome-mouse-pointer" +keyboard = "entypo-keyboard" diff --git a/tester/fonts/iced_tester-icons.ttf b/tester/fonts/iced_tester-icons.ttf new file mode 100644 index 00000000..301915f4 Binary files /dev/null and b/tester/fonts/iced_tester-icons.ttf differ diff --git a/tester/src/icon.rs b/tester/src/icon.rs new file mode 100644 index 00000000..343f942f --- /dev/null +++ b/tester/src/icon.rs @@ -0,0 +1,118 @@ +#![allow(unused)] +use crate::core::Font; +use crate::program; +use crate::widget::{Text, text}; + +pub const FONT: &[u8] = include_bytes!("../fonts/iced_tester-icons.ttf"); + +pub fn cancel<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{2715}") +} + +pub fn check<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{2713}") +} + +pub fn floppy<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{1F4BE}") +} + +pub fn folder<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{1F4C1}") +} + +pub fn keyboard<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{2328}") +} + +pub fn lightbulb<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{F0EB}") +} + +pub fn mouse_pointer<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{F245}") +} + +pub fn pause<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{2389}") +} + +pub fn pencil<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{270E}") +} + +pub fn play<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{25B6}") +} + +pub fn record<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{26AB}") +} + +pub fn stop<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{25A0}") +} + +pub fn tape<'a, Theme, Renderer>() -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + icon("\u{2707}") +} + +fn icon<'a, Theme, Renderer>(codepoint: &'a str) -> Text<'a, Theme, Renderer> +where + Theme: text::Catalog + 'a, + Renderer: program::Renderer, +{ + text(codepoint).font(Font::with_name("iced_devtools-icons")) +} diff --git a/tester/src/lib.rs b/tester/src/lib.rs new file mode 100644 index 00000000..d8a8df71 --- /dev/null +++ b/tester/src/lib.rs @@ -0,0 +1,945 @@ +//! Record, edit, and run end-to-end tests for your iced applications. +pub use iced_test as test; +pub use iced_test::core; +pub use iced_test::program; +pub use iced_test::runtime; +pub use iced_test::runtime::futures; +pub use iced_widget as widget; + +mod icon; +mod recorder; + +use recorder::recorder; + +use crate::core::Alignment::Center; +use crate::core::Length::Fill; +use crate::core::alignment::Horizontal::Right; +use crate::core::border; +use crate::core::mouse; +use crate::core::window; +use crate::core::{Color, Element, Font, Settings, Size, Theme}; +use crate::futures::futures::channel::mpsc; +use crate::program::Program; +use crate::runtime::task::{self, Task}; +use crate::test::emulator; +use crate::test::ice; +use crate::test::instruction; +use crate::test::{Emulator, Ice, Instruction}; +use crate::widget::{ + button, center, column, combo_box, container, pick_list, row, rule, + scrollable, slider, space, stack, text, text_editor, themer, +}; + +use std::ops::RangeInclusive; + +/// Attaches a [`Tester`] to the given [`Program`]. +pub fn attach(program: P) -> Attach

{ + Attach { program } +} + +/// A [`Program`] with a [`Tester`] attached to it. +#[derive(Debug)] +pub struct Attach

{ + /// The original [`Program`] attached to the [`Tester`]. + pub program: P, +} + +impl

Program for Attach

+where + P: Program + 'static, +{ + type State = Tester

; + type Message = Message

; + type Theme = Theme; + type Renderer = P::Renderer; + type Executor = P::Executor; + + fn name() -> &'static str { + P::name() + } + + fn settings(&self) -> Settings { + let mut settings = self.program.settings(); + settings.fonts.push(icon::FONT.into()); + settings + } + + fn window(&self) -> Option { + self.program.window().map(|window| window::Settings { + size: window.size + Size::new(300.0, 80.0), + ..window + }) + } + + fn boot(&self) -> (Self::State, Task) { + (Tester::new(&self.program), Task::none()) + } + + fn update( + &self, + state: &mut Self::State, + message: Self::Message, + ) -> Task { + state.tick(&self.program, message.0).map(Message) + } + + fn view<'a>( + &self, + state: &'a Self::State, + window: window::Id, + ) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> { + state.view(&self.program, window).map(Message) + } +} + +/// A tester decorates a [`Program`] definition and attaches a test recorder on top. +/// +/// It can be used to both record and play [`Ice`] tests. +pub struct Tester { + viewport: Size, + mode: emulator::Mode, + presets: combo_box::State, + preset: Option, + instructions: Vec, + state: State

, + edit: Option>, +} + +enum State { + Empty, + Idle { + state: P::State, + }, + Recording { + emulator: Emulator

, + }, + Asserting { + state: P::State, + window: window::Id, + last_interaction: Option, + }, + Playing { + emulator: Emulator

, + current: usize, + outcome: Outcome, + }, +} + +enum Outcome { + Running, + Failed, + Success, +} + +/// The message of a [`Tester`]. +pub struct Message(Tick

); + +#[derive(Debug, Clone)] +enum Event { + ViewportChanged(Size), + ModeSelected(emulator::Mode), + PresetSelected(String), + Record, + Stop, + Play, + Import, + Export, + Imported(Result), + Edit, + Edited(text_editor::Action), + Confirm, +} + +enum Tick { + Tester(Event), + Program(P::Message), + Emulator(emulator::Event

), + Record(instruction::Interaction), + Assert(instruction::Interaction), +} + +impl Tester

{ + fn new(program: &P) -> Self { + let (state, _) = program.boot(); + let window = program.window().unwrap_or_default(); + + Self { + mode: emulator::Mode::default(), + viewport: window.size, + presets: combo_box::State::new( + program + .presets() + .iter() + .map(program::Preset::name) + .map(str::to_owned) + .collect(), + ), + preset: None, + instructions: Vec::new(), + state: State::Idle { state }, + edit: None, + } + } + + fn is_busy(&self) -> bool { + matches!( + self.state, + State::Recording { .. } + | State::Playing { + outcome: Outcome::Running, + .. + } + ) + } + + fn update(&mut self, program: &P, event: Event) -> Task> { + match event { + Event::ViewportChanged(viewport) => { + self.viewport = viewport; + + Task::none() + } + Event::ModeSelected(mode) => { + self.mode = mode; + + Task::none() + } + Event::PresetSelected(preset) => { + self.preset = Some(preset); + + let (state, _) = self + .preset(program) + .map(program::Preset::boot) + .unwrap_or_else(|| program.boot()); + + self.state = State::Idle { state }; + + Task::none() + } + Event::Record => { + self.edit = None; + self.instructions.clear(); + + let (sender, receiver) = mpsc::channel(1); + + let emulator = Emulator::with_preset( + sender, + program, + self.mode, + self.viewport, + self.preset(program), + ); + + self.state = State::Recording { emulator }; + + Task::run(receiver, Tick::Emulator) + } + Event::Stop => { + let State::Recording { emulator } = + std::mem::replace(&mut self.state, State::Empty) + else { + return Task::none(); + }; + + while let Some(Instruction::Interact( + instruction::Interaction::Mouse(instruction::Mouse::Move( + _, + )), + )) = self.instructions.last() + { + let _ = self.instructions.pop(); + } + + let (state, window) = emulator.into_state(); + + self.state = State::Asserting { + state, + window, + last_interaction: None, + }; + + Task::none() + } + Event::Play => { + self.confirm(); + + let (sender, receiver) = mpsc::channel(1); + + let emulator = Emulator::with_preset( + sender, + program, + self.mode, + self.viewport, + self.preset(program), + ); + + self.state = State::Playing { + emulator, + current: 0, + outcome: Outcome::Running, + }; + + Task::run(receiver, Tick::Emulator) + } + Event::Import => { + use std::fs; + + let import = rfd::AsyncFileDialog::new() + .add_filter("ice", &["ice"]) + .pick_file(); + + Task::future(import) + .and_then(|file| { + task::blocking(move |mut sender| { + let _ = sender.try_send(Ice::parse( + &fs::read_to_string(file.path()) + .unwrap_or_default(), + )); + }) + }) + .map(Event::Imported) + .map(Tick::Tester) + } + Event::Export => { + use std::fs; + use std::thread; + + self.confirm(); + + let ice = Ice { + viewport: self.viewport, + mode: self.mode, + preset: self.preset.clone(), + instructions: self.instructions.clone(), + }; + + let export = rfd::AsyncFileDialog::new() + .add_filter("ice", &["ice"]) + .save_file(); + + Task::future(async move { + let Some(file) = export.await else { + return; + }; + + let _ = thread::spawn(move || { + fs::write(file.path(), ice.to_string()) + }); + }) + .discard() + } + Event::Imported(Ok(ice)) => { + self.viewport = ice.viewport; + self.mode = ice.mode; + self.preset = ice.preset; + self.instructions = ice.instructions; + self.edit = None; + + let (state, _) = self + .preset(program) + .map(program::Preset::boot) + .unwrap_or_else(|| program.boot()); + + self.state = State::Idle { state }; + + Task::none() + } + Event::Edit => { + if self.is_busy() { + return Task::none(); + } + + self.edit = Some(text_editor::Content::with_text( + &self + .instructions + .iter() + .map(Instruction::to_string) + .collect::>() + .join("\n"), + )); + + Task::none() + } + Event::Edited(action) => { + if let Some(edit) = &mut self.edit { + edit.perform(action); + } + + Task::none() + } + Event::Confirm => { + self.confirm(); + + Task::none() + } + Event::Imported(Err(error)) => { + log::error!("{error}"); + + Task::none() + } + } + } + + fn confirm(&mut self) { + let Some(edit) = &mut self.edit else { + return; + }; + + self.instructions = edit + .lines() + .filter(|line| !line.text.trim().is_empty()) + .filter_map(|line| Instruction::parse(&line.text).ok()) + .collect(); + + self.edit = None; + } + + fn preset<'a>( + &self, + program: &'a P, + ) -> Option<&'a program::Preset> { + self.preset.as_ref().and_then(|preset| { + program + .presets() + .iter() + .find(|candidate| candidate.name() == preset) + }) + } + + fn tick(&mut self, program: &P, tick: Tick

) -> Task> { + match tick { + Tick::Tester(message) => self.update(program, message), + Tick::Program(message) => { + let State::Recording { emulator } = &mut self.state else { + return Task::none(); + }; + + emulator.update(program, message); + + Task::none() + } + Tick::Emulator(event) => { + match &mut self.state { + State::Recording { emulator } => { + if let emulator::Event::Action(action) = event { + emulator.perform(program, action); + } + } + State::Playing { + emulator, + current, + outcome, + } => match event { + emulator::Event::Action(action) => { + emulator.perform(program, action); + } + emulator::Event::Failed(_instruction) => { + *outcome = Outcome::Failed; + } + emulator::Event::Ready => { + *current += 1; + + if let Some(instruction) = + self.instructions.get(*current - 1).cloned() + { + emulator.run(program, instruction); + } + + if *current >= self.instructions.len() { + *outcome = Outcome::Success; + } + } + }, + State::Empty + | State::Idle { .. } + | State::Asserting { .. } => {} + } + + Task::none() + } + Tick::Record(interaction) => { + let mut interaction = Some(interaction); + + while let Some(new_interaction) = interaction.take() { + if let Some(Instruction::Interact(last_interaction)) = + self.instructions.pop() + { + let (merged_interaction, new_interaction) = + last_interaction.merge(new_interaction); + + if let Some(new_interaction) = new_interaction { + self.instructions.push(Instruction::Interact( + merged_interaction, + )); + + self.instructions + .push(Instruction::Interact(new_interaction)); + } else { + interaction = Some(merged_interaction); + } + } else { + self.instructions + .push(Instruction::Interact(new_interaction)); + } + } + + Task::none() + } + Tick::Assert(interaction) => { + let State::Asserting { + last_interaction, .. + } = &mut self.state + else { + return Task::none(); + }; + + *last_interaction = + if let Some(last_interaction) = last_interaction.take() { + let (merged, new) = last_interaction.merge(interaction); + + Some(new.unwrap_or(merged)) + } else { + Some(interaction) + }; + + let Some(interaction) = last_interaction.take() else { + return Task::none(); + }; + + let instruction::Interaction::Mouse( + instruction::Mouse::Click { + button: mouse::Button::Left, + target: Some(instruction::Target::Text(text)), + }, + ) = interaction + else { + *last_interaction = Some(interaction); + return Task::none(); + }; + + self.instructions.push(Instruction::Expect( + instruction::Expectation::Text(text), + )); + + Task::none() + } + } + } + + fn view<'a>( + &'a self, + program: &P, + window: window::Id, + ) -> Element<'a, Tick

, Theme, P::Renderer> { + let status = { + let (icon, label) = match &self.state { + State::Empty | State::Idle { .. } => (text(""), "Idle"), + State::Recording { .. } => (icon::record(), "Recording"), + State::Asserting { .. } => (icon::lightbulb(), "Asserting"), + State::Playing { outcome, .. } => match outcome { + Outcome::Running => (icon::play(), "Playing"), + Outcome::Failed => (icon::cancel(), "Failed"), + Outcome::Success => (icon::check(), "Success"), + }, + }; + + container(row![icon.size(14), label].align_y(Center).spacing(8)) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + + container::Style { + text_color: Some(match &self.state { + State::Empty | State::Idle { .. } => { + palette.background.strongest.color + } + State::Recording { .. } => { + palette.danger.base.color + } + State::Asserting { .. } => { + palette.warning.base.color + } + State::Playing { outcome, .. } => match outcome { + Outcome::Running => theme.palette().primary, + Outcome::Failed => theme.palette().danger, + Outcome::Success => { + theme + .extended_palette() + .success + .strong + .color + } + }, + }), + ..container::Style::default() + } + }) + }; + + let viewport = container( + scrollable( + container(match &self.state { + State::Empty => Element::from(space()), + State::Idle { state } => { + let theme = program.theme(state, window); + + themer( + theme, + program.view(state, window).map(Tick::Program), + ) + .into() + } + State::Recording { emulator } => { + let theme = emulator.theme(program); + let view = emulator.view(program).map(Tick::Program); + + recorder(themer(theme, view)) + .on_record(Tick::Record) + .into() + } + State::Asserting { state, window, .. } => { + let theme = program.theme(state, *window); + let view = + program.view(state, *window).map(Tick::Program); + + recorder(themer(theme, view)) + .on_record(Tick::Assert) + .into() + } + State::Playing { emulator, .. } => { + let theme = emulator.theme(program); + let view = emulator.view(program).map(Tick::Program); + + themer(theme, view).into() + } + }) + .width(self.viewport.width) + .height(self.viewport.height), + ) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }), + ) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + + container::Style { + border: border::width(2.0).color(match &self.state { + State::Empty | State::Idle { .. } => { + palette.background.strongest.color + } + State::Recording { .. } => palette.danger.base.color, + State::Asserting { .. } => palette.warning.weak.color, + State::Playing { outcome, .. } => match outcome { + Outcome::Running => palette.primary.base.color, + Outcome::Failed => palette.danger.strong.color, + Outcome::Success => palette.success.strong.color, + }, + }), + ..container::Style::default() + } + }) + .padding(10); + + row![ + center(column![status, viewport].spacing(10).align_x(Right)) + .padding(10), + rule::vertical(1).style(rule::weak), + container(self.controls().map(Tick::Tester)) + .width(250) + .padding(10) + .style(|theme| container::Style::default().background( + theme.extended_palette().background.weakest.color + )), + ] + .into() + } + + fn controls(&self) -> Element<'_, Event, Theme, P::Renderer> { + let viewport = column![ + labeled_slider( + "Width", + 100.0..=2000.0, + self.viewport.width, + |width| Event::ViewportChanged(Size { + width, + ..self.viewport + }), + |width| format!("{width:.0}"), + ), + labeled_slider( + "Height", + 100.0..=2000.0, + self.viewport.height, + |height| Event::ViewportChanged(Size { + height, + ..self.viewport + }), + |height| format!("{height:.0}"), + ), + ] + .spacing(10); + + let preset = combo_box( + &self.presets, + "Default", + self.preset.as_ref(), + Event::PresetSelected, + ) + .size(14) + .width(Fill); + + let mode = pick_list( + emulator::Mode::ALL, + Some(self.mode), + Event::ModeSelected, + ) + .text_size(14) + .width(Fill); + + let player = { + let instructions = if let Some(edit) = &self.edit { + text_editor(edit) + .size(12) + .height(Fill) + .font(Font::MONOSPACE) + .on_action(Event::Edited) + .into() + } else if self.instructions.is_empty() { + Element::from(center( + text("No instructions recorded yet!") + .size(14) + .font(Font::MONOSPACE) + .width(Fill) + .center(), + )) + } else { + scrollable( + column(self.instructions.iter().enumerate().map( + |(i, instruction)| { + text(instruction.to_string()) + .wrapping(text::Wrapping::None) // TODO: Ellipsize? + .size(10) + .font(Font::MONOSPACE) + .style(move |theme: &Theme| text::Style { + color: match &self.state { + State::Playing { + current, + outcome, + .. + } => { + if *current == i + 1 { + Some(match outcome { + Outcome::Running => { + theme.palette().primary + } + Outcome::Failed => { + theme + .extended_palette() + .danger + .strong + .color + } + Outcome::Success => { + theme + .extended_palette() + .success + .strong + .color + } + }) + } else if *current > i + 1 { + Some( + theme + .extended_palette() + .success + .strong + .color, + ) + } else { + None + } + } + _ => None, + }, + }) + .into() + }, + )) + .spacing(5), + ) + .width(Fill) + .height(Fill) + .spacing(5) + .into() + }; + + let control = |icon: text::Text<'static, _, _>| { + button(icon.size(14).width(Fill).height(Fill).center()) + }; + + let play = control(icon::play()).on_press_maybe( + (!matches!(self.state, State::Recording { .. }) + && !self.instructions.is_empty()) + .then_some(Event::Play), + ); + + let record = if let State::Recording { .. } = &self.state { + control(icon::stop()) + .on_press(Event::Stop) + .style(button::success) + } else { + control(icon::record()) + .on_press_maybe((!self.is_busy()).then_some(Event::Record)) + .style(button::danger) + }; + + let import = control(icon::folder()) + .on_press_maybe((!self.is_busy()).then_some(Event::Import)) + .style(button::secondary); + + let export = control(icon::floppy()) + .on_press_maybe( + (!matches!(self.state, State::Recording { .. }) + && !self.instructions.is_empty()) + .then_some(Event::Export), + ) + .style(button::success); + + let controls = + row![import, export, play, record].height(30).spacing(10); + + column![instructions, controls].spacing(10).align_x(Center) + }; + + let edit = if self.is_busy() { + Element::from(space::horizontal()) + } else if self.edit.is_none() { + button(icon::pencil().size(14)) + .padding(0) + .on_press(Event::Edit) + .style(button::text) + .into() + } else { + button(icon::check().size(14)) + .padding(0) + .on_press(Event::Confirm) + .style(button::text) + .into() + }; + + column![ + labeled("Viewport", viewport), + labeled("Mode", mode), + labeled("Preset", preset), + labeled_with("Instructions", edit, player) + ] + .spacing(10) + .into() + } +} + +fn labeled<'a, Message, Renderer>( + fragment: impl text::IntoFragment<'a>, + content: impl Into>, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Renderer: program::Renderer + 'a, +{ + column![ + text(fragment).size(14).font(Font::MONOSPACE), + content.into() + ] + .spacing(5) + .into() +} + +fn labeled_with<'a, Message, Renderer>( + fragment: impl text::IntoFragment<'a>, + control: impl Into>, + content: impl Into>, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Renderer: program::Renderer + 'a, +{ + column![ + row![ + text(fragment).size(14).font(Font::MONOSPACE), + space::horizontal(), + control.into() + ] + .spacing(5) + .align_y(Center), + content.into() + ] + .spacing(5) + .into() +} + +fn labeled_slider<'a, Message, Renderer>( + label: impl text::IntoFragment<'a>, + range: RangeInclusive, + current: f32, + on_change: impl Fn(f32) -> Message + 'a, + to_string: impl Fn(&f32) -> String, +) -> Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Renderer: core::text::Renderer + 'a, +{ + stack![ + container( + slider(range, current, on_change) + .step(10.0) + .width(Fill) + .height(24) + .style(|theme: &core::Theme, status| { + let palette = theme.extended_palette(); + + slider::Style { + rail: slider::Rail { + backgrounds: ( + match status { + slider::Status::Active + | slider::Status::Dragged => { + palette.background.strongest.color + } + slider::Status::Hovered => { + palette.background.stronger.color + } + } + .into(), + Color::TRANSPARENT.into(), + ), + width: 24.0, + border: border::rounded(2), + }, + handle: slider::Handle { + shape: slider::HandleShape::Circle { radius: 0.0 }, + background: Color::TRANSPARENT.into(), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + } + }) + ) + .style(|theme| container::Style::default() + .background(theme.extended_palette().background.weak.color) + .border(border::rounded(2))), + row![ + text(label).size(14).style(|theme: &core::Theme| { + text::Style { + color: Some(theme.extended_palette().background.weak.text), + } + }), + space::horizontal(), + text(to_string(¤t)).size(14) + ] + .padding([0, 10]) + .height(Fill) + .align_y(Center), + ] + .into() +} diff --git a/tester/src/recorder.rs b/tester/src/recorder.rs new file mode 100644 index 00000000..3baefd25 --- /dev/null +++ b/tester/src/recorder.rs @@ -0,0 +1,498 @@ +use crate::core::layout; +use crate::core::mouse; +use crate::core::overlay; +use crate::core::renderer; +use crate::core::widget; +use crate::core::widget::operation; +use crate::core::widget::tree; +use crate::core::{ + self, Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell, + Size, Theme, Vector, Widget, +}; +use crate::test::Selector; +use crate::test::instruction::{Interaction, Mouse, Target}; +use crate::test::selector; + +pub fn recorder<'a, Message, Renderer>( + content: impl Into>, +) -> Recorder<'a, Message, Renderer> { + Recorder::new(content) +} + +pub struct Recorder<'a, Message, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + on_record: Option Message + 'a>>, + has_overlay: bool, +} + +impl<'a, Message, Renderer> Recorder<'a, Message, Renderer> { + pub fn new( + content: impl Into>, + ) -> Self { + Self { + content: content.into(), + on_record: None, + has_overlay: false, + } + } + + pub fn on_record( + mut self, + on_record: impl Fn(Interaction) -> Message + 'a, + ) -> Self { + self.on_record = Some(Box::new(on_record)); + self + } +} + +struct State { + last_hovered: Option, + last_hovered_overlay: Option, +} + +impl Widget + for Recorder<'_, Message, Renderer> +where + Renderer: core::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State { + last_hovered: None, + last_hovered_overlay: None, + }) + } + + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut tree::Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if shell.is_event_captured() { + return; + } + + if !self.has_overlay + && let Some(on_record) = &self.on_record + { + let state = tree.state.downcast_mut::(); + + record( + event, + cursor, + shell, + layout.bounds(), + &mut state.last_hovered, + on_record, + |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + }, + ); + } + + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget_mut().layout( + &mut tree.children[0], + renderer, + limits, + ) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + + let state = tree.state.downcast_ref::(); + + let Some(last_hovered) = &state.last_hovered else { + return; + }; + + let palette = theme.palette(); + + renderer.with_layer(*viewport, |renderer| { + renderer.fill_quad( + renderer::Quad { + bounds: *last_hovered, + ..renderer::Quad::default() + }, + palette.primary.scale_alpha(0.7), + ); + }); + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &Renderer, + _viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.has_overlay = false; + + self.content + .as_widget_mut() + .overlay( + &mut tree.children[0], + layout, + renderer, + &layout.bounds(), + translation, + ) + .map(|raw| { + self.has_overlay = true; + + let state = tree.state.downcast_mut::(); + + overlay::Element::new(Box::new(Overlay { + raw, + bounds: layout.bounds(), + last_hovered: &mut state.last_hovered_overlay, + on_record: self.on_record.as_deref(), + })) + }) + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: core::Renderer + 'a, +{ + fn from(recorder: Recorder<'a, Message, Renderer>) -> Self { + Element::new(recorder) + } +} + +struct Overlay<'a, Message, Renderer> { + raw: overlay::Element<'a, Message, Theme, Renderer>, + bounds: Rectangle, + last_hovered: &'a mut Option, + on_record: Option<&'a dyn Fn(Interaction) -> Message>, +} + +impl<'a, Message, Renderer> core::Overlay + for Overlay<'a, Message, Renderer> +where + Renderer: core::Renderer + 'a, +{ + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + self.raw.as_overlay_mut().layout(renderer, bounds) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self.raw + .as_overlay() + .draw(renderer, theme, style, layout, cursor); + + let Some(last_hovered) = &self.last_hovered else { + return; + }; + + let palette = theme.palette(); + + renderer.with_layer(self.bounds, |renderer| { + renderer.fill_quad( + renderer::Quad { + bounds: *last_hovered, + ..renderer::Quad::default() + }, + palette.primary.scale_alpha(0.7), + ); + }); + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.raw + .as_overlay_mut() + .operate(layout, renderer, operation); + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + if shell.is_event_captured() { + return; + } + + if let Some(on_event) = &self.on_record { + record( + event, + cursor, + shell, + self.bounds, + self.last_hovered, + on_event, + |operation| { + self.raw + .as_overlay_mut() + .operate(layout, renderer, operation); + }, + ); + } + + self.raw + .as_overlay_mut() + .update(event, layout, cursor, renderer, clipboard, shell); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + ) -> mouse::Interaction { + self.raw + .as_overlay() + .mouse_interaction(layout, cursor, renderer) + } + + fn overlay<'b>( + &'b mut self, + layout: Layout<'b>, + renderer: &Renderer, + ) -> Option> { + self.raw + .as_overlay_mut() + .overlay(layout, renderer) + .map(|raw| { + overlay::Element::new(Box::new(Overlay { + raw, + bounds: self.bounds, + last_hovered: self.last_hovered, + on_record: self.on_record, + })) + }) + } + + fn index(&self) -> f32 { + self.raw.as_overlay().index() + } +} + +fn record( + event: &Event, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, + bounds: Rectangle, + last_hovered: &mut Option, + on_record: impl Fn(Interaction) -> Message, + operate: impl FnMut(&mut dyn widget::Operation), +) { + if let Event::Mouse(_) = event + && !cursor.is_over(bounds) + { + return; + } + + let interaction = + if let Event::Mouse(mouse::Event::CursorMoved { position }) = event { + Interaction::from_event(&Event::Mouse(mouse::Event::CursorMoved { + position: *position - (bounds.position() - Point::ORIGIN), + })) + } else { + Interaction::from_event(event) + }; + + let Some(mut interaction) = interaction else { + return; + }; + + let Interaction::Mouse( + Mouse::Move(target) + | Mouse::Press { + target: Some(target), + .. + } + | Mouse::Release { + target: Some(target), + .. + } + | Mouse::Click { + target: Some(target), + .. + }, + ) = &mut interaction + else { + shell.publish(on_record(interaction)); + return; + }; + + let Target::Point(position) = *target else { + shell.publish(on_record(interaction)); + return; + }; + + if let Some((content, visible_bounds)) = + find_text(position + (bounds.position() - Point::ORIGIN), operate) + { + *target = Target::Text(content); + *last_hovered = visible_bounds; + } else { + *last_hovered = None; + } + + shell.publish(on_record(interaction)); +} + +fn find_text( + position: Point, + mut operate: impl FnMut(&mut dyn widget::Operation), +) -> Option<(String, Option)> { + use widget::Operation; + + let mut by_position = position.find_all(); + operate(&mut operation::black_box(&mut by_position)); + + let operation::Outcome::Some(targets) = by_position.finish() else { + return None; + }; + + let (content, visible_bounds) = + targets.into_iter().rev().find_map(|target| { + if let selector::Target::Text { + content, + visible_bounds, + .. + } + | selector::Target::TextInput { + content, + visible_bounds, + .. + } = target + { + Some((content, visible_bounds)) + } else { + None + } + })?; + + let mut by_text = content.clone().find_all(); + operate(&mut operation::black_box(&mut by_text)); + + let operation::Outcome::Some(texts) = by_text.finish() else { + return None; + }; + + if texts.len() > 1 { + return None; + } + + Some((content, visible_bounds)) +} diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index e06c7820..e5739ea7 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -235,7 +235,6 @@ impl core::text::Renderer for Renderer { type Paragraph = Paragraph; type Editor = Editor; - const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::with_name("Iced-Icons"); const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; diff --git a/tiny_skia/src/window/compositor.rs b/tiny_skia/src/window/compositor.rs index 02cace72..9ede982d 100644 --- a/tiny_skia/src/window/compositor.rs +++ b/tiny_skia/src/window/compositor.rs @@ -8,13 +8,11 @@ use crate::{Layer, Renderer, Settings}; use std::collections::VecDeque; use std::num::NonZeroU32; -#[allow(missing_debug_implementations)] pub struct Compositor { context: softbuffer::Context>, settings: Settings, } -#[allow(missing_debug_implementations)] pub struct Surface { window: softbuffer::Surface< Box, diff --git a/wgpu/src/engine.rs b/wgpu/src/engine.rs index 5125562c..574223a4 100644 --- a/wgpu/src/engine.rs +++ b/wgpu/src/engine.rs @@ -7,7 +7,6 @@ use crate::triangle; use std::sync::{Arc, RwLock}; #[derive(Clone)] -#[allow(missing_debug_implementations)] pub struct Engine { pub(crate) device: wgpu::Device, pub(crate) queue: wgpu::Queue, diff --git a/wgpu/src/geometry.rs b/wgpu/src/geometry.rs index 18e26794..872d9fe1 100644 --- a/wgpu/src/geometry.rs +++ b/wgpu/src/geometry.rs @@ -92,7 +92,6 @@ impl Cached for Geometry { } /// A frame for drawing some geometry. -#[allow(missing_debug_implementations)] pub struct Frame { clip_bounds: Rectangle, buffers: BufferStack, diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index f31457a7..97a3216b 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -72,7 +72,6 @@ use crate::graphics::text::{Editor, Paragraph}; /// /// [`wgpu`]: https://github.com/gfx-rs/wgpu-rs /// [`iced`]: https://github.com/iced-rs/iced -#[allow(missing_debug_implementations)] pub struct Renderer { engine: Engine, @@ -709,7 +708,6 @@ impl core::text::Renderer for Renderer { type Paragraph = Paragraph; type Editor = Editor; - const MONOSPACE_FONT: Font = Font::MONOSPACE; const ICON_FONT: Font = Font::with_name("Iced-Icons"); const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; diff --git a/wgpu/src/primitive.rs b/wgpu/src/primitive.rs index dd661e7e..cb2c02c7 100644 --- a/wgpu/src/primitive.rs +++ b/wgpu/src/primitive.rs @@ -197,7 +197,6 @@ pub trait Renderer: core::Renderer { /// Stores custom, user-provided types. #[derive(Default)] -#[allow(missing_debug_implementations)] pub struct Storage { pipelines: FxHashMap>, } diff --git a/wgpu/src/text.rs b/wgpu/src/text.rs index 6fcc8e45..16efa092 100644 --- a/wgpu/src/text.rs +++ b/wgpu/src/text.rs @@ -269,7 +269,6 @@ impl Viewport { } #[derive(Clone)] -#[allow(missing_debug_implementations)] pub struct Pipeline { format: wgpu::TextureFormat, cache: cryoglyph::Cache, diff --git a/wgpu/src/window/compositor.rs b/wgpu/src/window/compositor.rs index c912ef1d..dab96531 100644 --- a/wgpu/src/window/compositor.rs +++ b/wgpu/src/window/compositor.rs @@ -8,7 +8,6 @@ use crate::settings::{self, Settings}; use crate::{Engine, Renderer}; /// A window graphics backend for iced powered by `wgpu`. -#[allow(missing_debug_implementations)] pub struct Compositor { instance: wgpu::Instance, adapter: wgpu::Adapter, diff --git a/widget/Cargo.toml b/widget/Cargo.toml index 024314ff..dc64b61d 100644 --- a/widget/Cargo.toml +++ b/widget/Cargo.toml @@ -31,7 +31,6 @@ crisp = [] [dependencies] iced_renderer.workspace = true -iced_runtime.workspace = true num-traits.workspace = true log.workspace = true diff --git a/widget/src/button.rs b/widget/src/button.rs index 6ae8d3e5..060762a2 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -68,7 +68,6 @@ use crate::core::{ /// button("I am disabled!").into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Button<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where Renderer: crate::core::Renderer, @@ -262,7 +261,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.content.as_widget_mut().operate( &mut tree.children[0], layout.children().next().unwrap(), diff --git a/widget/src/checkbox.rs b/widget/src/checkbox.rs index 32e81538..80be7180 100644 --- a/widget/src/checkbox.rs +++ b/widget/src/checkbox.rs @@ -77,7 +77,6 @@ use crate::core::{ /// } /// ``` /// ![Checkbox drawn by `iced_wgpu`](https://github.com/iced-rs/iced/blob/7760618fb112074bc40b148944521f312152012a/docs/images/checkbox.png?raw=true) -#[allow(missing_debug_implementations)] pub struct Checkbox< 'a, Message, diff --git a/widget/src/column.rs b/widget/src/column.rs index 39507a78..8e9745bc 100644 --- a/widget/src/column.rs +++ b/widget/src/column.rs @@ -32,7 +32,6 @@ use crate::core::{ /// ].into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Column<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { spacing: f32, @@ -234,7 +233,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.children .iter_mut() .zip(&mut tree.children) diff --git a/widget/src/combo_box.rs b/widget/src/combo_box.rs index f94705d8..dd87c5a1 100644 --- a/widget/src/combo_box.rs +++ b/widget/src/combo_box.rs @@ -64,8 +64,8 @@ use crate::core::text; use crate::core::time::Instant; use crate::core::widget::{self, Widget}; use crate::core::{ - Clipboard, Element, Event, Length, Padding, Rectangle, Shell, Size, Theme, - Vector, + Clipboard, Element, Event, Length, Padding, Pixels, Rectangle, Shell, Size, + Theme, Vector, }; use crate::overlay::menu; use crate::text::LineHeight; @@ -130,7 +130,6 @@ use std::fmt::Display; /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct ComboBox< 'a, T, @@ -249,9 +248,12 @@ where } /// Sets the text sixe of the [`ComboBox`]. - pub fn size(mut self, size: f32) -> Self { + pub fn size(mut self, size: impl Into) -> Self { + let size = size.into(); + self.text_input = self.text_input.size(size); - self.size = Some(size); + self.size = Some(size.0); + self } diff --git a/widget/src/container.rs b/widget/src/container.rs index 286c5a63..3e054ea6 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -31,10 +31,9 @@ use crate::core::widget::tree::{self, Tree}; use crate::core::widget::{self, Operation}; use crate::core::{ self, Background, Clipboard, Color, Element, Event, Layout, Length, - Padding, Pixels, Point, Rectangle, Shadow, Shell, Size, Theme, Vector, - Widget, color, + Padding, Pixels, Rectangle, Shadow, Shell, Size, Theme, Vector, Widget, + color, }; -use crate::runtime::task::{self, Task}; /// A widget that aligns its contents inside of its boundaries. /// @@ -57,7 +56,6 @@ use crate::runtime::task::{self, Task}; /// .into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Container< 'a, Message, @@ -67,7 +65,7 @@ pub struct Container< Theme: Catalog, Renderer: core::Renderer, { - id: Option, + id: Option, padding: Padding, width: Length, height: Length, @@ -107,8 +105,8 @@ where } } - /// Sets the [`Id`] of the [`Container`]. - pub fn id(mut self, id: impl Into) -> Self { + /// Sets the [`widget::Id`] of the [`Container`]. + pub fn id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); self } @@ -286,18 +284,15 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container( - self.id.as_ref().map(|id| &id.0), - layout.bounds(), - &mut |operation| { - self.content.as_widget_mut().operate( - tree, - layout.children().next().unwrap(), - renderer, - operation, - ); - }, - ); + operation.container(self.id.as_ref(), layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + tree, + layout.children().next().unwrap(), + renderer, + operation, + ); + }); } fn update( @@ -462,128 +457,6 @@ pub fn draw_background( } } -/// The identifier of a [`Container`]. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Id(widget::Id); - -impl Id { - /// Creates a custom [`Id`]. - pub fn new(id: impl Into>) -> Self { - Self(widget::Id::new(id)) - } - - /// Creates a unique [`Id`]. - /// - /// This function produces a different [`Id`] every time it is called. - pub fn unique() -> Self { - Self(widget::Id::unique()) - } -} - -impl From for widget::Id { - fn from(id: Id) -> Self { - id.0 - } -} - -impl From<&'static str> for Id { - fn from(value: &'static str) -> Self { - Id::new(value) - } -} - -/// Produces a [`Task`] that queries the visible screen bounds of the -/// [`Container`] with the given [`Id`]. -pub fn visible_bounds(id: impl Into) -> Task> { - let id = id.into(); - - struct VisibleBounds { - target: widget::Id, - depth: usize, - scrollables: Vec<(Vector, Rectangle, usize)>, - bounds: Option, - } - - impl Operation> for VisibleBounds { - fn scrollable( - &mut self, - _id: Option<&widget::Id>, - bounds: Rectangle, - _content_bounds: Rectangle, - translation: Vector, - _state: &mut dyn widget::operation::Scrollable, - ) { - match self.scrollables.last() { - Some((last_translation, last_viewport, _depth)) => { - let viewport = last_viewport - .intersection(&(bounds - *last_translation)) - .unwrap_or(Rectangle::new(Point::ORIGIN, Size::ZERO)); - - self.scrollables.push(( - translation + *last_translation, - viewport, - self.depth, - )); - } - None => { - self.scrollables.push((translation, bounds, self.depth)); - } - } - } - - fn container( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - operate_on_children: &mut dyn FnMut( - &mut dyn Operation>, - ), - ) { - if self.bounds.is_some() { - return; - } - - if id == Some(&self.target) { - match self.scrollables.last() { - Some((translation, viewport, _)) => { - self.bounds = - viewport.intersection(&(bounds - *translation)); - } - None => { - self.bounds = Some(bounds); - } - } - - return; - } - - self.depth += 1; - - operate_on_children(self); - - self.depth -= 1; - - match self.scrollables.last() { - Some((_, _, depth)) if self.depth == *depth => { - let _ = self.scrollables.pop(); - } - _ => {} - } - } - - fn finish(&self) -> widget::operation::Outcome> { - widget::operation::Outcome::Some(self.bounds) - } - } - - task::widget(VisibleBounds { - target: id.into(), - depth: 0, - scrollables: Vec::new(), - bounds: None, - }) -} - /// The appearance of a container. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct Style { @@ -700,6 +573,7 @@ pub fn rounded_box(theme: &Theme) -> Style { Style { background: Some(palette.background.weak.color.into()), + text_color: Some(palette.background.weak.text), border: border::rounded(2), ..Style::default() } @@ -711,6 +585,7 @@ pub fn bordered_box(theme: &Theme) -> Style { Style { background: Some(palette.background.weakest.color.into()), + text_color: Some(palette.background.weakest.text), border: Border { width: 1.0, radius: 5.0.into(), diff --git a/widget/src/float.rs b/widget/src/float.rs index 1ff6ab10..c04adee6 100644 --- a/widget/src/float.rs +++ b/widget/src/float.rs @@ -13,7 +13,6 @@ use crate::core::{ }; /// A widget that can make its contents float over other widgets. -#[allow(missing_debug_implementations)] pub struct Float<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where Theme: Catalog, diff --git a/widget/src/grid.rs b/widget/src/grid.rs index 25e3ebe0..b1d73050 100644 --- a/widget/src/grid.rs +++ b/widget/src/grid.rs @@ -10,7 +10,6 @@ use crate::core::{ }; /// A container that distributes its contents on a responsive grid. -#[allow(missing_debug_implementations)] pub struct Grid<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { spacing: f32, columns: Constraint, @@ -257,7 +256,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.children .iter_mut() .zip(&mut tree.children) diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index e34bcebc..a30e8b97 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -4,9 +4,10 @@ use crate::checkbox::{self, Checkbox}; use crate::combo_box::{self, ComboBox}; use crate::container::{self, Container}; use crate::core; +use crate::core::theme; use crate::core::widget::operation::{self, Operation}; use crate::core::window; -use crate::core::{Element, Length, Pixels, Size, Widget}; +use crate::core::{Element, Length, Size, Widget}; use crate::float::{self, Float}; use crate::keyed; use crate::overlay; @@ -14,9 +15,6 @@ use crate::pane_grid::{self, PaneGrid}; use crate::pick_list::{self, PickList}; use crate::progress_bar::{self, ProgressBar}; use crate::radio::{self, Radio}; -use crate::rule::{self, Rule}; -use crate::runtime::Action; -use crate::runtime::task::{self, Task}; use crate::scrollable::{self, Scrollable}; use crate::slider::{self, Slider}; use crate::text::{self, Text}; @@ -1005,7 +1003,6 @@ pub fn sensor<'a, Message, Theme, Renderer>( ) -> Sensor<'a, (), Message, Theme, Renderer> where Renderer: core::Renderer, - Message: Clone, { Sensor::new(content) } @@ -1019,7 +1016,7 @@ where /// # mod iced { pub mod widget { pub use iced_widget::*; } } /// # pub type State = (); /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -/// use iced::widget::{column, scrollable, vertical_space}; +/// use iced::widget::{column, scrollable, space}; /// /// enum Message { /// // ... @@ -1028,7 +1025,7 @@ where /// fn view(state: &State) -> Element<'_, Message> { /// scrollable(column![ /// "Scroll me!", -/// vertical_space().height(3000), +/// space().height(3000), /// "You did it!", /// ]).into() /// } @@ -1731,70 +1728,12 @@ where ComboBox::new(state, placeholder, selection, on_selected) } -/// Creates a new [`Space`] widget that fills the available -/// horizontal space. +/// Creates some empty [`Space`] with no size. /// -/// This can be useful to separate widgets in a [`Row`]. -pub fn horizontal_space() -> Space { - Space::with_width(Length::Fill) -} - -/// Creates a new [`Space`] widget that fills the available -/// vertical space. -/// -/// This can be useful to separate widgets in a [`Column`]. -pub fn vertical_space() -> Space { - Space::with_height(Length::Fill) -} - -/// Creates a horizontal [`Rule`] with the given height. -/// -/// # Example -/// ```no_run -/// # mod iced { pub mod widget { pub use iced_widget::*; } } -/// # pub type State = (); -/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -/// use iced::widget::horizontal_rule; -/// -/// #[derive(Clone)] -/// enum Message { -/// // ..., -/// } -/// -/// fn view(state: &State) -> Element<'_, Message> { -/// horizontal_rule(2).into() -/// } -/// ``` -pub fn horizontal_rule<'a, Theme>(height: impl Into) -> Rule<'a, Theme> -where - Theme: rule::Catalog + 'a, -{ - Rule::horizontal(height) -} - -/// Creates a vertical [`Rule`] with the given width. -/// -/// # Example -/// ```no_run -/// # mod iced { pub mod widget { pub use iced_widget::*; } } -/// # pub type State = (); -/// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -/// use iced::widget::vertical_rule; -/// -/// #[derive(Clone)] -/// enum Message { -/// // ..., -/// } -/// -/// fn view(state: &State) -> Element<'_, Message> { -/// vertical_rule(2).into() -/// } -/// ``` -pub fn vertical_rule<'a, Theme>(width: impl Into) -> Rule<'a, Theme> -where - Theme: rule::Catalog + 'a, -{ - Rule::vertical(width) +/// This is considered the "identity" widget. It will take +/// no space and do nothing. +pub fn space() -> Space { + Space::new() } /// Creates a new [`ProgressBar`]. @@ -1899,7 +1838,7 @@ where /// for instance. #[cfg(feature = "svg")] pub fn iced<'a, Message, Theme, Renderer>( - text_size: impl Into, + text_size: impl Into, ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, @@ -2044,16 +1983,6 @@ where crate::Shader::new(program) } -/// Focuses the previous focusable widget. -pub fn focus_previous() -> Task { - task::effect(Action::widget(operation::focusable::focus_previous())) -} - -/// Focuses the next focusable widget. -pub fn focus_next() -> Task { - task::effect(Action::widget(operation::focusable::focus_next())) -} - /// Creates a new [`MouseArea`]. pub fn mouse_area<'a, Message, Theme, Renderer>( widget: impl Into>, @@ -2065,22 +1994,15 @@ where } /// A widget that applies any `Theme` to its contents. -pub fn themer<'a, Message, OldTheme, NewTheme, Renderer>( - to_theme: impl Fn(&OldTheme) -> NewTheme, - content: impl Into>, -) -> Themer< - 'a, - Message, - OldTheme, - NewTheme, - impl Fn(&OldTheme) -> NewTheme, - Renderer, -> +pub fn themer<'a, Message, Theme, Renderer>( + theme: Option, + content: impl Into>, +) -> Themer<'a, Message, Theme, Renderer> where + Theme: theme::Base, Renderer: core::Renderer, - NewTheme: Clone, { - Themer::new(to_theme, content) + Themer::new(theme, content) } /// Creates a [`PaneGrid`] with the given [`pane_grid::State`] and view function. diff --git a/widget/src/image.rs b/widget/src/image.rs index 3b29e4f5..18b7a3a3 100644 --- a/widget/src/image.rs +++ b/widget/src/image.rs @@ -54,7 +54,6 @@ pub fn viewer(handle: Handle) -> Viewer { /// } /// ``` /// -#[allow(missing_debug_implementations)] pub struct Image { handle: Handle, width: Length, diff --git a/widget/src/image/viewer.rs b/widget/src/image/viewer.rs index 205f84d5..29da5d6d 100644 --- a/widget/src/image/viewer.rs +++ b/widget/src/image/viewer.rs @@ -10,7 +10,6 @@ use crate::core::{ }; /// A frame that displays an image with the ability to zoom in/out and pan. -#[allow(missing_debug_implementations)] pub struct Viewer { padding: f32, width: Length, diff --git a/widget/src/keyed/column.rs b/widget/src/keyed/column.rs index 6ca6f485..bdfa82e1 100644 --- a/widget/src/keyed/column.rs +++ b/widget/src/keyed/column.rs @@ -29,7 +29,6 @@ use crate::core::{ /// })).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Column< 'a, Key, @@ -284,7 +283,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.children .iter_mut() .zip(&mut tree.children) diff --git a/widget/src/lazy.rs b/widget/src/lazy.rs index ec3c9604..85d87f10 100644 --- a/widget/src/lazy.rs +++ b/widget/src/lazy.rs @@ -18,7 +18,6 @@ use crate::core::widget::{self, Widget}; use crate::core::{ self, Clipboard, Event, Length, Rectangle, Shell, Size, Vector, }; -use crate::runtime::overlay::Nested; use ouroboros::self_referencing; use rustc_hash::FxHasher; @@ -28,7 +27,6 @@ use std::rc::Rc; /// A widget that only rebuilds its contents when necessary. #[cfg(feature = "lazy")] -#[allow(missing_debug_implementations)] pub struct Lazy<'a, Message, Theme, Renderer, Dependency, View> { dependency: Dependency, view: Box View + 'a>, @@ -286,7 +284,7 @@ where element .as_widget_mut() .overlay(tree, *layout, renderer, viewport, translation) - .map(|overlay| RefCell::new(Nested::new(overlay))) + .map(|overlay| RefCell::new(overlay::Nested::new(overlay))) }, } .build(); @@ -317,7 +315,7 @@ struct Inner<'a, Message: 'a, Theme: 'a, Renderer: 'a> { #[borrows(mut element, mut tree, layout)] #[not_covariant] - overlay: Option>>, + overlay: Option>>, } struct Overlay<'a, Message, Theme, Renderer>( @@ -334,7 +332,7 @@ impl Drop for Overlay<'_, Message, Theme, Renderer> { impl Overlay<'_, Message, Theme, Renderer> { fn with_overlay_maybe( &self, - f: impl FnOnce(&mut Nested<'_, Message, Theme, Renderer>) -> T, + f: impl FnOnce(&mut overlay::Nested<'_, Message, Theme, Renderer>) -> T, ) -> Option { self.0.as_ref().unwrap().with_overlay(|overlay| { overlay.as_ref().map(|nested| (f)(&mut nested.borrow_mut())) @@ -343,7 +341,7 @@ impl Overlay<'_, Message, Theme, Renderer> { fn with_overlay_mut_maybe( &mut self, - f: impl FnOnce(&mut Nested<'_, Message, Theme, Renderer>) -> T, + f: impl FnOnce(&mut overlay::Nested<'_, Message, Theme, Renderer>) -> T, ) -> Option { self.0.as_mut().unwrap().with_overlay_mut(|overlay| { overlay.as_mut().map(|nested| (f)(nested.get_mut())) diff --git a/widget/src/lazy/component.rs b/widget/src/lazy/component.rs index 1b582dee..b42ef170 100644 --- a/widget/src/lazy/component.rs +++ b/widget/src/lazy/component.rs @@ -9,7 +9,6 @@ use crate::core::widget::tree::{self, Tree}; use crate::core::{ self, Clipboard, Element, Length, Rectangle, Shell, Size, Vector, Widget, }; -use crate::runtime::overlay::Nested; use ouroboros::self_referencing; use std::cell::RefCell; @@ -472,7 +471,9 @@ where viewport, translation, ) - .map(|overlay| RefCell::new(Nested::new(overlay))) + .map(|overlay| { + RefCell::new(overlay::Nested::new(overlay)) + }) }, ) }, @@ -519,7 +520,7 @@ struct Inner<'a, 'b, Message, Theme, Renderer, Event, S> { #[borrows(mut instance, mut tree)] #[not_covariant] - overlay: Option>>, + overlay: Option>>, } struct OverlayInstance<'a, 'b, Message, Theme, Renderer, Event, S> { @@ -531,7 +532,7 @@ impl { fn with_overlay_maybe( &self, - f: impl FnOnce(&mut Nested<'_, Event, Theme, Renderer>) -> T, + f: impl FnOnce(&mut overlay::Nested<'_, Event, Theme, Renderer>) -> T, ) -> Option { self.overlay .as_ref() @@ -546,7 +547,7 @@ impl fn with_overlay_mut_maybe( &mut self, - f: impl FnOnce(&mut Nested<'_, Event, Theme, Renderer>) -> T, + f: impl FnOnce(&mut overlay::Nested<'_, Event, Theme, Renderer>) -> T, ) -> Option { self.overlay .as_mut() diff --git a/widget/src/lib.rs b/widget/src/lib.rs index 3818c92f..73f1ed22 100644 --- a/widget/src/lib.rs +++ b/widget/src/lib.rs @@ -4,16 +4,16 @@ )] #![cfg_attr(docsrs, feature(doc_auto_cfg))] pub use iced_renderer as renderer; +pub use iced_renderer::core; pub use iced_renderer::graphics; -pub use iced_runtime as runtime; -pub use iced_runtime::core; + +pub use core::widget::Id; mod action; mod column; mod mouse_area; mod pin; mod responsive; -mod space; mod stack; mod themer; @@ -34,6 +34,7 @@ pub mod rule; pub mod scrollable; pub mod sensor; pub mod slider; +pub mod space; pub mod table; pub mod text; pub mod text_editor; diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 35e7549c..d778dd6a 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -51,10 +51,7 @@ use crate::core::theme; use crate::core::{ self, Color, Element, Length, Padding, Pixels, Theme, color, }; -use crate::{ - column, container, horizontal_rule, rich_text, row, rule, scrollable, span, - text, vertical_rule, -}; +use crate::{column, container, rich_text, row, rule, scrollable, span, text}; use std::borrow::BorrowMut; use std::cell::{Cell, RefCell}; @@ -1391,7 +1388,7 @@ where Renderer: core::text::Renderer + 'a, { row![ - vertical_rule(4), + rule::vertical(4), column( contents .iter() @@ -1413,7 +1410,7 @@ where Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { - horizontal_rule(2).into() + rule::horizontal(2).into() } /// Displays a table using the default look. @@ -1637,8 +1634,8 @@ where pub trait Catalog: container::Catalog + scrollable::Catalog - + rule::Catalog + text::Catalog + + crate::rule::Catalog + crate::table::Catalog { /// The styling class of a Markdown code block. diff --git a/widget/src/mouse_area.rs b/widget/src/mouse_area.rs index f4a76d3b..8ca6f882 100644 --- a/widget/src/mouse_area.rs +++ b/widget/src/mouse_area.rs @@ -11,7 +11,6 @@ use crate::core::{ }; /// Emit messages on mouse events. -#[allow(missing_debug_implementations)] pub struct MouseArea< 'a, Message, diff --git a/widget/src/overlay/menu.rs b/widget/src/overlay/menu.rs index 6f8cc00b..fb2fe9e9 100644 --- a/widget/src/overlay/menu.rs +++ b/widget/src/overlay/menu.rs @@ -17,7 +17,6 @@ use crate::core::{Element, Shell, Widget}; use crate::scrollable::{self, Scrollable}; /// A list of selectable options. -#[allow(missing_debug_implementations)] pub struct Menu< 'a, 'b, diff --git a/widget/src/pane_grid.rs b/widget/src/pane_grid.rs index 8c63fe63..67cb20d4 100644 --- a/widget/src/pane_grid.rs +++ b/widget/src/pane_grid.rs @@ -147,7 +147,6 @@ const THICKNESS_RATIO: f32 = 25.0; /// .into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct PaneGrid< 'a, Message, @@ -467,7 +466,8 @@ where renderer: &Renderer, operation: &mut dyn widget::Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.panes .iter_mut() .zip(&mut self.contents) diff --git a/widget/src/pane_grid/content.rs b/widget/src/pane_grid/content.rs index 6592694b..b6f27203 100644 --- a/widget/src/pane_grid/content.rs +++ b/widget/src/pane_grid/content.rs @@ -13,7 +13,6 @@ use crate::pane_grid::{Draggable, TitleBar}; /// The content of a [`Pane`]. /// /// [`Pane`]: super::Pane -#[allow(missing_debug_implementations)] pub struct Content< 'a, Message, diff --git a/widget/src/pane_grid/controls.rs b/widget/src/pane_grid/controls.rs index 13b57acb..4ba32101 100644 --- a/widget/src/pane_grid/controls.rs +++ b/widget/src/pane_grid/controls.rs @@ -4,7 +4,6 @@ use crate::core::{self, Element}; /// The controls of a [`Pane`]. /// /// [`Pane`]: super::Pane -#[allow(missing_debug_implementations)] pub struct Controls< 'a, Message, diff --git a/widget/src/pane_grid/title_bar.rs b/widget/src/pane_grid/title_bar.rs index 7d15bf80..14a15c5f 100644 --- a/widget/src/pane_grid/title_bar.rs +++ b/widget/src/pane_grid/title_bar.rs @@ -13,7 +13,6 @@ use crate::pane_grid::controls::Controls; /// The title bar of a [`Pane`]. /// /// [`Pane`]: super::Pane -#[allow(missing_debug_implementations)] pub struct TitleBar< 'a, Message, diff --git a/widget/src/pick_list.rs b/widget/src/pick_list.rs index 86dbdaf6..06e938ec 100644 --- a/widget/src/pick_list.rs +++ b/widget/src/pick_list.rs @@ -142,7 +142,6 @@ use std::f32; /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct PickList< 'a, T, diff --git a/widget/src/pin.rs b/widget/src/pin.rs index 04ed0324..77df188e 100644 --- a/widget/src/pin.rs +++ b/widget/src/pin.rs @@ -52,7 +52,6 @@ use crate::core::{ /// .into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Pin<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where Renderer: core::Renderer, diff --git a/widget/src/progress_bar.rs b/widget/src/progress_bar.rs index d364f5df..25973027 100644 --- a/widget/src/progress_bar.rs +++ b/widget/src/progress_bar.rs @@ -52,7 +52,6 @@ use std::ops::RangeInclusive; /// progress_bar(0.0..=100.0, state.progress).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct ProgressBar<'a, Theme = crate::Theme> where Theme: Catalog, diff --git a/widget/src/qr_code.rs b/widget/src/qr_code.rs index e8a278fd..6995cc9f 100644 --- a/widget/src/qr_code.rs +++ b/widget/src/qr_code.rs @@ -60,7 +60,6 @@ const QUIET_ZONE: usize = 2; /// qr_code(&state.data).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct QRCode<'a, Theme = crate::Theme> where Theme: Catalog, diff --git a/widget/src/radio.rs b/widget/src/radio.rs index 3bb2e806..17d0426a 100644 --- a/widget/src/radio.rs +++ b/widget/src/radio.rs @@ -129,7 +129,6 @@ use crate::core::{ /// column![a, b, c, all].into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Radio<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where Theme: Catalog, diff --git a/widget/src/responsive.rs b/widget/src/responsive.rs index a987a430..76e9c62c 100644 --- a/widget/src/responsive.rs +++ b/widget/src/responsive.rs @@ -8,13 +8,12 @@ use crate::core::{ self, Clipboard, Element, Event, Length, Rectangle, Shell, Size, Vector, Widget, }; -use crate::horizontal_space; +use crate::space; /// A widget that is aware of its dimensions. /// /// A [`Responsive`] widget will always try to fill all the available space of /// its parent. -#[allow(missing_debug_implementations)] pub struct Responsive< 'a, Message, @@ -44,7 +43,7 @@ where view: Box::new(view), width: Length::Fill, height: Length::Fill, - content: Element::new(horizontal_space().width(0)), + content: Element::new(space()), } } diff --git a/widget/src/row.rs b/widget/src/row.rs index af1f490c..6dabc926 100644 --- a/widget/src/row.rs +++ b/widget/src/row.rs @@ -32,7 +32,6 @@ use crate::core::{ /// ].into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Row<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { spacing: f32, padding: Padding, @@ -234,7 +233,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.children .iter_mut() .zip(&mut tree.children) @@ -360,7 +360,6 @@ where /// obtain a [`Row`] that wraps its contents. /// /// The original alignment of the [`Row`] is preserved per row wrapped. -#[allow(missing_debug_implementations)] pub struct Wrapping< 'a, Message, diff --git a/widget/src/rule.rs b/widget/src/rule.rs index 163d2757..1472caed 100644 --- a/widget/src/rule.rs +++ b/widget/src/rule.rs @@ -5,7 +5,7 @@ //! # mod iced { pub mod widget { pub use iced_widget::*; } } //! # pub type State = (); //! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -//! use iced::widget::horizontal_rule; +//! use iced::widget::rule; //! //! #[derive(Clone)] //! enum Message { @@ -13,7 +13,7 @@ //! } //! //! fn view(state: &State) -> Element<'_, Message> { -//! horizontal_rule(2).into() +//! rule::horizontal(2).into() //! } //! ``` use crate::core; @@ -26,6 +26,30 @@ use crate::core::{ Color, Element, Layout, Length, Pixels, Rectangle, Size, Theme, Widget, }; +/// Creates a new horizontal [`Rule`] with the given height. +pub fn horizontal<'a, Theme>(height: impl Into) -> Rule<'a, Theme> +where + Theme: Catalog, +{ + Rule { + thickness: Length::Fixed(height.into().0), + is_vertical: false, + class: Theme::default(), + } +} + +/// Creates a new vertical [`Rule`] with the given width. +pub fn vertical<'a, Theme>(width: impl Into) -> Rule<'a, Theme> +where + Theme: Catalog, +{ + Rule { + thickness: Length::Fixed(width.into().0), + is_vertical: true, + class: Theme::default(), + } +} + /// Display a horizontal or vertical rule for dividing content. /// /// # Example @@ -33,7 +57,7 @@ use crate::core::{ /// # mod iced { pub mod widget { pub use iced_widget::*; } } /// # pub type State = (); /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -/// use iced::widget::horizontal_rule; +/// use iced::widget::rule; /// /// #[derive(Clone)] /// enum Message { @@ -41,17 +65,15 @@ use crate::core::{ /// } /// /// fn view(state: &State) -> Element<'_, Message> { -/// horizontal_rule(2).into() +/// rule::horizontal(2).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Rule<'a, Theme = crate::Theme> where Theme: Catalog, { - width: Length, - height: Length, - is_horizontal: bool, + thickness: Length, + is_vertical: bool, class: Theme::Class<'a>, } @@ -59,26 +81,6 @@ impl<'a, Theme> Rule<'a, Theme> where Theme: Catalog, { - /// Creates a horizontal [`Rule`] with the given height. - pub fn horizontal(height: impl Into) -> Self { - Rule { - width: Length::Fill, - height: Length::Fixed(height.into().0), - is_horizontal: true, - class: Theme::default(), - } - } - - /// Creates a vertical [`Rule`] with the given width. - pub fn vertical(width: impl Into) -> Self { - Rule { - width: Length::Fixed(width.into().0), - height: Length::Fill, - is_horizontal: false, - class: Theme::default(), - } - } - /// Sets the style of the [`Rule`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self @@ -105,9 +107,16 @@ where Theme: Catalog, { fn size(&self) -> Size { - Size { - width: self.width, - height: self.height, + if self.is_vertical { + Size { + width: self.thickness, + height: Length::Fill, + } + } else { + Size { + width: Length::Fill, + height: self.thickness, + } } } @@ -117,7 +126,9 @@ where _renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - layout::atomic(limits, self.width, self.height) + let size = >::size(self); + + layout::atomic(limits, size.width, size.height) } fn draw( @@ -133,19 +144,7 @@ where let bounds = layout.bounds(); let style = theme.style(&self.class); - let bounds = if self.is_horizontal { - let line_y = bounds.y.round(); - - let (offset, line_width) = style.fill_mode.fill(bounds.width); - let line_x = bounds.x + offset; - - Rectangle { - x: line_x, - y: line_y, - width: line_width, - height: bounds.height, - } - } else { + let bounds = if self.is_vertical { let line_x = bounds.x.round(); let (offset, line_height) = style.fill_mode.fill(bounds.height); @@ -157,6 +156,18 @@ where width: bounds.width, height: line_height, } + } else { + let line_y = bounds.y.round(); + + let (offset, line_width) = style.fill_mode.fill(bounds.width); + let line_x = bounds.x + offset; + + Rectangle { + x: line_x, + y: line_y, + width: line_width, + height: bounds.height, + } }; renderer.fill_quad( diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index c70c3cf2..75fd249c 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -5,7 +5,7 @@ //! # mod iced { pub mod widget { pub use iced_widget::*; } } //! # pub type State = (); //! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -//! use iced::widget::{column, scrollable, vertical_space}; +//! use iced::widget::{column, scrollable, space}; //! //! enum Message { //! // ... @@ -14,7 +14,7 @@ //! fn view(state: &State) -> Element<'_, Message> { //! scrollable(column![ //! "Scroll me!", -//! vertical_space().height(3000), +//! space().height(3000), //! "You did it!", //! ]).into() //! } @@ -37,8 +37,6 @@ use crate::core::{ Length, Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::Action; -use crate::runtime::task::{self, Task}; pub use operation::scrollable::{AbsoluteOffset, RelativeOffset}; @@ -50,7 +48,7 @@ pub use operation::scrollable::{AbsoluteOffset, RelativeOffset}; /// # mod iced { pub mod widget { pub use iced_widget::*; } } /// # pub type State = (); /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; -/// use iced::widget::{column, scrollable, vertical_space}; +/// use iced::widget::{column, scrollable, space}; /// /// enum Message { /// // ... @@ -59,12 +57,11 @@ pub use operation::scrollable::{AbsoluteOffset, RelativeOffset}; /// fn view(state: &State) -> Element<'_, Message> { /// scrollable(column![ /// "Scroll me!", -/// vertical_space().height(3000), +/// space().height(3000), /// "You did it!", /// ]).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Scrollable< 'a, Message, @@ -74,7 +71,7 @@ pub struct Scrollable< Theme: Catalog, Renderer: core::Renderer, { - id: Option, + id: Option, width: Length, height: Length, direction: Direction, @@ -139,8 +136,8 @@ where self.enclose() } - /// Sets the [`Id`] of the [`Scrollable`]. - pub fn id(mut self, id: impl Into) -> Self { + /// Sets the [`widget::Id`] of the [`Scrollable`]. + pub fn id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); self } @@ -536,25 +533,21 @@ where state.translation(self.direction, bounds, content_bounds); operation.scrollable( - self.id.as_ref().map(|id| &id.0), + self.id.as_ref(), bounds, content_bounds, translation, state, ); - operation.container( - self.id.as_ref().map(|id| &id.0), - bounds, - &mut |operation| { - self.content.as_widget_mut().operate( - &mut tree.children[0], - layout.children().next().unwrap(), - renderer, - operation, - ); - }, - ); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + operation, + ); + }); } fn update( @@ -1260,63 +1253,6 @@ where } } -/// The identifier of a [`Scrollable`]. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Id(widget::Id); - -impl Id { - /// Creates a custom [`Id`]. - pub fn new(id: impl Into>) -> Self { - Self(widget::Id::new(id)) - } - - /// Creates a unique [`Id`]. - /// - /// This function produces a different [`Id`] every time it is called. - pub fn unique() -> Self { - Self(widget::Id::unique()) - } -} - -impl From for widget::Id { - fn from(id: Id) -> Self { - id.0 - } -} - -impl From<&'static str> for Id { - fn from(id: &'static str) -> Self { - Self::new(id) - } -} - -/// Produces a [`Task`] that snaps the [`Scrollable`] with the given [`Id`] -/// to the provided [`RelativeOffset`]. -pub fn snap_to(id: impl Into, offset: RelativeOffset) -> Task { - task::effect(Action::widget(operation::scrollable::snap_to( - id.into().0, - offset, - ))) -} - -/// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] -/// to the provided [`AbsoluteOffset`]. -pub fn scroll_to(id: impl Into, offset: AbsoluteOffset) -> Task { - task::effect(Action::widget(operation::scrollable::scroll_to( - id.into().0, - offset, - ))) -} - -/// Produces a [`Task`] that scrolls the [`Scrollable`] with the given [`Id`] -/// by the provided [`AbsoluteOffset`]. -pub fn scroll_by(id: impl Into, offset: AbsoluteOffset) -> Task { - task::effect(Action::widget(operation::scrollable::scroll_by( - id.into().0, - offset, - ))) -} - fn notify_scroll( state: &mut State, on_scroll: &Option Message + '_>>, diff --git a/widget/src/sensor.rs b/widget/src/sensor.rs index e0122b2e..ff8a8f12 100644 --- a/widget/src/sensor.rs +++ b/widget/src/sensor.rs @@ -15,7 +15,6 @@ use crate::core::{ /// A widget that can generate messages when its content pops in and out of view. /// /// It can even notify you with anticipation at a given distance! -#[allow(missing_debug_implementations)] pub struct Sensor< 'a, Key, @@ -34,7 +33,6 @@ pub struct Sensor< impl<'a, Message, Theme, Renderer> Sensor<'a, (), Message, Theme, Renderer> where - Message: Clone, Renderer: core::Renderer, { /// Creates a new [`Sensor`] widget with the given content. @@ -56,7 +54,6 @@ where impl<'a, Key, Message, Theme, Renderer> Sensor<'a, Key, Message, Theme, Renderer> where - Message: Clone, Key: self::Key, Renderer: core::Renderer, { @@ -164,7 +161,6 @@ impl Widget for Sensor<'_, Key, Message, Theme, Renderer> where Key: self::Key, - Message: Clone, Renderer: core::Renderer, { fn tag(&self) -> tree::Tag { @@ -253,8 +249,8 @@ where if let Some(on_show) = &self.on_show { shell.publish(on_show(layout.bounds().size())); } - } else if let Some(on_hide) = &self.on_hide { - shell.publish(on_hide.clone()); + } else if let Some(on_hide) = self.on_hide.take() { + shell.publish(on_hide); } state.should_notify_at = None; @@ -374,7 +370,7 @@ impl<'a, Key, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where - Message: Clone + 'a, + Message: 'a, Key: self::Key + 'a, Renderer: core::Renderer + 'a, Theme: 'a, diff --git a/widget/src/shader.rs b/widget/src/shader.rs index cf165e59..7ce06964 100644 --- a/widget/src/shader.rs +++ b/widget/src/shader.rs @@ -22,7 +22,6 @@ pub use primitive::{Primitive, Storage}; /// /// Must be initialized with a [`Program`], which describes the internal widget state & how /// its [`Program::Primitive`]s are drawn. -#[allow(missing_debug_implementations)] pub struct Shader> { width: Length, height: Length, diff --git a/widget/src/slider.rs b/widget/src/slider.rs index db6bc175..2bace845 100644 --- a/widget/src/slider.rs +++ b/widget/src/slider.rs @@ -80,7 +80,6 @@ use std::ops::RangeInclusive; /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Slider<'a, T, Message, Theme = crate::Theme> where Theme: Catalog, diff --git a/widget/src/space.rs b/widget/src/space.rs index 949ea3ce..231d4704 100644 --- a/widget/src/space.rs +++ b/widget/src/space.rs @@ -1,4 +1,4 @@ -//! Distribute content vertically. +//! Add some explicit spacing between elements. use crate::core; use crate::core::layout; use crate::core::mouse; @@ -6,6 +6,22 @@ use crate::core::renderer; use crate::core::widget::Tree; use crate::core::{Element, Layout, Length, Rectangle, Size, Widget}; +/// Creates a new [`Space`] widget that fills the available +/// horizontal space. +/// +/// This can be useful to separate widgets in a [`Row`](crate::Row). +pub fn horizontal() -> Space { + Space::new().width(Length::Fill) +} + +/// Creates a new [`Space`] widget that fills the available +/// vertical space. +/// +/// This can be useful to separate widgets in a [`Column`](crate::Column). +pub fn vertical() -> Space { + Space::new().height(Length::Fill) +} + /// An amount of empty space. /// /// It can be useful if you want to fill some space with nothing. @@ -16,27 +32,11 @@ pub struct Space { } impl Space { - /// Creates an amount of empty [`Space`] with the given width and height. - pub fn new(width: impl Into, height: impl Into) -> Self { - Space { - width: width.into(), - height: height.into(), - } - } - - /// Creates an amount of horizontal [`Space`]. - pub fn with_width(width: impl Into) -> Self { - Space { - width: width.into(), - height: Length::Shrink, - } - } - - /// Creates an amount of vertical [`Space`]. - pub fn with_height(height: impl Into) -> Self { + /// Creates some empty [`Space`] with no size. + pub fn new() -> Self { Space { width: Length::Shrink, - height: height.into(), + height: Length::Shrink, } } @@ -53,6 +53,12 @@ impl Space { } } +impl Default for Space { + fn default() -> Self { + Space::new() + } +} + impl Widget for Space where Renderer: core::Renderer, diff --git a/widget/src/stack.rs b/widget/src/stack.rs index c0fb3147..2a062ee4 100644 --- a/widget/src/stack.rs +++ b/widget/src/stack.rs @@ -17,7 +17,6 @@ use crate::core::{ /// /// Keep in mind that too much layering will normally produce bad UX as well as /// introduce certain rendering overhead. Use this widget sparingly! -#[allow(missing_debug_implementations)] pub struct Stack<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { width: Length, @@ -85,30 +84,20 @@ where child: impl Into>, ) -> Self { let child = child.into(); + let child_size = child.as_widget().size_hint(); - if self.children.is_empty() { - let child_size = child.as_widget().size_hint(); + if !child_size.is_void() { + if self.children.is_empty() { + self.width = self.width.enclose(child_size.width); + self.height = self.height.enclose(child_size.height); + } - self.width = self.width.enclose(child_size.width); - self.height = self.height.enclose(child_size.height); + self.children.push(child); } - self.children.push(child); self } - /// Adds an element to the [`Stack`], if `Some`. - pub fn push_maybe( - self, - child: Option>>, - ) -> Self { - if let Some(child) = child { - self.push(child) - } else { - self - } - } - /// Extends the [`Stack`] with the given children. pub fn extend( self, @@ -203,7 +192,8 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds(), &mut |operation| { + operation.container(None, layout.bounds()); + operation.traverse(&mut |operation| { self.children .iter_mut() .zip(&mut tree.children) @@ -227,6 +217,10 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { + if self.children.is_empty() { + return; + } + let is_over = cursor.is_over(layout.bounds()); let end = self.children.len() - 1; diff --git a/widget/src/svg.rs b/widget/src/svg.rs index ab69b2dc..dabe499f 100644 --- a/widget/src/svg.rs +++ b/widget/src/svg.rs @@ -51,7 +51,6 @@ pub use crate::core::svg::Handle; /// svg("tiger.svg").into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Svg<'a, Theme = crate::Theme> where Theme: Catalog, diff --git a/widget/src/table.rs b/widget/src/table.rs index 06b47124..44faa86d 100644 --- a/widget/src/table.rs +++ b/widget/src/table.rs @@ -49,7 +49,6 @@ where } /// A grid-like visual representation of data distributed in columns and rows. -#[allow(missing_debug_implementations)] pub struct Table<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> where Theme: Catalog, @@ -630,7 +629,6 @@ where } /// A vertical visualization of some data with a header. -#[allow(missing_debug_implementations)] pub struct Column< 'a, 'b, diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index e11f406b..648ffe82 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -13,7 +13,6 @@ use crate::core::{ }; /// A bunch of [`Rich`] text. -#[allow(missing_debug_implementations)] pub struct Rich< 'a, Link, diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index 45622257..ef3a1429 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -93,7 +93,6 @@ pub use text::editor::{Action, Edit, Line, LineEnding, Motion}; /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct TextEditor< 'a, Highlighter, diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index 4cdd1d2d..0783f7c2 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -61,8 +61,6 @@ use crate::core::{ Length, Padding, Pixels, Point, Rectangle, Shell, Size, Theme, Vector, Widget, }; -use crate::runtime::Action; -use crate::runtime::task::{self, Task}; /// A field that can be filled with text. /// @@ -96,7 +94,6 @@ use crate::runtime::task::{self, Task}; /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct TextInput< 'a, Message, @@ -106,7 +103,7 @@ pub struct TextInput< Theme: Catalog, Renderer: text::Renderer, { - id: Option, + id: Option, placeholder: String, value: Value, is_secure: bool, @@ -156,8 +153,8 @@ where } } - /// Sets the [`Id`] of the [`TextInput`]. - pub fn id(mut self, id: impl Into) -> Self { + /// Sets the [`widget::Id`] of the [`TextInput`]. + pub fn id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); self } @@ -687,17 +684,8 @@ where ) { let state = tree.state.downcast_mut::>(); - operation.focusable( - self.id.as_ref().map(|id| &id.0), - layout.bounds(), - state, - ); - - operation.text_input( - self.id.as_ref().map(|id| &id.0), - layout.bounds(), - state, - ); + operation.text_input(self.id.as_ref(), layout.bounds(), state); + operation.focusable(self.id.as_ref(), layout.bounds(), state); } fn update( @@ -1453,84 +1441,6 @@ pub enum Side { Right, } -/// The identifier of a [`TextInput`]. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Id(widget::Id); - -impl Id { - /// Creates a custom [`Id`]. - pub fn new(id: impl Into>) -> Self { - Self(widget::Id::new(id)) - } - - /// Creates a unique [`Id`]. - /// - /// This function produces a different [`Id`] every time it is called. - pub fn unique() -> Self { - Self(widget::Id::unique()) - } -} - -impl From for widget::Id { - fn from(id: Id) -> Self { - id.0 - } -} - -impl From<&'static str> for Id { - fn from(id: &'static str) -> Self { - Self::new(id) - } -} - -impl From for Id { - fn from(id: String) -> Self { - Self::new(id) - } -} - -/// Produces a [`Task`] that returns whether the [`TextInput`] with the given [`Id`] is focused or not. -pub fn is_focused(id: impl Into) -> Task { - task::widget(operation::focusable::is_focused(id.into().into())) -} - -/// Produces a [`Task`] that focuses the [`TextInput`] with the given [`Id`]. -pub fn focus(id: impl Into) -> Task { - task::effect(Action::widget(operation::focusable::focus(id.into().0))) -} - -/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the -/// end. -pub fn move_cursor_to_end(id: impl Into) -> Task { - task::effect(Action::widget(operation::text_input::move_cursor_to_end( - id.into().0, - ))) -} - -/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the -/// front. -pub fn move_cursor_to_front(id: impl Into) -> Task { - task::effect(Action::widget(operation::text_input::move_cursor_to_front( - id.into().0, - ))) -} - -/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the -/// provided position. -pub fn move_cursor_to(id: impl Into, position: usize) -> Task { - task::effect(Action::widget(operation::text_input::move_cursor_to( - id.into().0, - position, - ))) -} - -/// Produces a [`Task`] that selects all the content of the [`TextInput`] with the given [`Id`]. -pub fn select_all(id: impl Into) -> Task { - task::effect(Action::widget(operation::text_input::select_all( - id.into().0, - ))) -} - /// The state of a [`TextInput`]. #[derive(Debug, Default, Clone)] pub struct State { @@ -1630,6 +1540,14 @@ impl operation::Focusable for State

{ } impl operation::TextInput for State

{ + fn text(&self) -> &str { + if self.value.content().is_empty() { + self.placeholder.content() + } else { + self.value.content() + } + } + fn move_cursor_to_front(&mut self) { State::move_cursor_to_front(self); } diff --git a/widget/src/themer.rs b/widget/src/themer.rs index f335cd01..ccc03484 100644 --- a/widget/src/themer.rs +++ b/widget/src/themer.rs @@ -3,6 +3,7 @@ use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; +use crate::core::theme; use crate::core::widget::Operation; use crate::core::widget::tree::{self, Tree}; use crate::core::{ @@ -10,63 +11,56 @@ use crate::core::{ Shell, Size, Vector, Widget, }; -use std::marker::PhantomData; - /// A widget that applies any `Theme` to its contents. /// /// This widget can be useful to leverage multiple `Theme` /// types in an application. -#[allow(missing_debug_implementations)] -pub struct Themer<'a, Message, Theme, NewTheme, F, Renderer = crate::Renderer> +pub struct Themer<'a, Message, Theme, Renderer = crate::Renderer> where - F: Fn(&Theme) -> NewTheme, Renderer: crate::core::Renderer, { - content: Element<'a, Message, NewTheme, Renderer>, - to_theme: F, - text_color: Option Color>, - background: Option Background>, - old_theme: PhantomData, + content: Element<'a, Message, Theme, Renderer>, + theme: Option, + text_color: Option Color>, + background: Option Background>, } -impl<'a, Message, Theme, NewTheme, F, Renderer> - Themer<'a, Message, Theme, NewTheme, F, Renderer> +impl<'a, Message, Theme, Renderer> Themer<'a, Message, Theme, Renderer> where - F: Fn(&Theme) -> NewTheme, Renderer: crate::core::Renderer, { /// Creates an empty [`Themer`] that applies the given `Theme` /// to the provided `content`. - pub fn new(to_theme: F, content: T) -> Self - where - T: Into>, - { + pub fn new( + theme: Option, + content: impl Into>, + ) -> Self { Self { content: content.into(), - to_theme, + theme, text_color: None, background: None, - old_theme: PhantomData, } } /// Sets the default text [`Color`] of the [`Themer`]. - pub fn text_color(mut self, f: fn(&NewTheme) -> Color) -> Self { + pub fn text_color(mut self, f: fn(&Theme) -> Color) -> Self { self.text_color = Some(f); self } /// Sets the [`Background`] of the [`Themer`]. - pub fn background(mut self, f: fn(&NewTheme) -> Background) -> Self { + pub fn background(mut self, f: fn(&Theme) -> Background) -> Self { self.background = Some(f); self } } -impl Widget - for Themer<'_, Message, Theme, NewTheme, F, Renderer> +impl Widget + for Themer<'_, Message, Theme, Renderer> where - F: Fn(&Theme) -> NewTheme, + Theme: theme::Base, + AnyTheme: theme::Base, Renderer: crate::core::Renderer, { fn tag(&self) -> tree::Tag { @@ -143,19 +137,20 @@ where &self, tree: &Tree, renderer: &mut Renderer, - theme: &Theme, + theme: &AnyTheme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { - let theme = (self.to_theme)(theme); + let default_theme = theme::Base::default(theme.mode()); + let theme = self.theme.as_ref().unwrap_or(&default_theme); if let Some(background) = self.background { container::draw_background( renderer, &container::Style { - background: Some(background(&theme)), + background: Some(background(theme)), ..container::Style::default() }, layout.bounds(), @@ -164,7 +159,7 @@ where let style = if let Some(text_color) = self.text_color { renderer::Style { - text_color: text_color(&theme), + text_color: text_color(theme), } } else { *style @@ -172,7 +167,7 @@ where self.content .as_widget() - .draw(tree, renderer, &theme, &style, layout, cursor, viewport); + .draw(tree, renderer, theme, &style, layout, cursor, viewport); } fn overlay<'b>( @@ -182,16 +177,18 @@ where renderer: &Renderer, viewport: &Rectangle, translation: Vector, - ) -> Option> { - struct Overlay<'a, Message, Theme, NewTheme, Renderer> { - to_theme: &'a dyn Fn(&Theme) -> NewTheme, - content: overlay::Element<'a, Message, NewTheme, Renderer>, + ) -> Option> { + struct Overlay<'a, Message, Theme, Renderer> { + theme: &'a Option, + content: overlay::Element<'a, Message, Theme, Renderer>, } - impl - overlay::Overlay - for Overlay<'_, Message, Theme, NewTheme, Renderer> + impl + overlay::Overlay + for Overlay<'_, Message, Theme, Renderer> where + Theme: theme::Base, + AnyTheme: theme::Base, Renderer: crate::core::Renderer, { fn layout( @@ -205,18 +202,17 @@ where fn draw( &self, renderer: &mut Renderer, - theme: &Theme, + theme: &AnyTheme, style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, ) { - self.content.as_overlay().draw( - renderer, - &(self.to_theme)(theme), - style, - layout, - cursor, - ); + let default_theme = theme::Base::default(theme.mode()); + let theme = self.theme.as_ref().unwrap_or(&default_theme); + + self.content + .as_overlay() + .draw(renderer, theme, style, layout, cursor); } fn update( @@ -259,13 +255,13 @@ where &'b mut self, layout: Layout<'b>, renderer: &Renderer, - ) -> Option> + ) -> Option> { self.content .as_overlay_mut() .overlay(layout, renderer) .map(|content| Overlay { - to_theme: &self.to_theme, + theme: self.theme, content, }) .map(|overlay| overlay::Element::new(Box::new(overlay))) @@ -276,26 +272,25 @@ where .as_widget_mut() .overlay(tree, layout, renderer, viewport, translation) .map(|content| Overlay { - to_theme: &self.to_theme, + theme: &self.theme, content, }) .map(|overlay| overlay::Element::new(Box::new(overlay))) } } -impl<'a, Message, Theme, NewTheme, F, Renderer> - From> - for Element<'a, Message, Theme, Renderer> +impl<'a, Message, Theme, Renderer, AnyTheme> + From> + for Element<'a, Message, AnyTheme, Renderer> where Message: 'a, - Theme: 'a, - NewTheme: 'a, - F: Fn(&Theme) -> NewTheme + 'a, + Theme: theme::Base + 'a, + AnyTheme: theme::Base, Renderer: 'a + crate::core::Renderer, { fn from( - themer: Themer<'a, Message, Theme, NewTheme, F, Renderer>, - ) -> Element<'a, Message, Theme, Renderer> { + themer: Themer<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, AnyTheme, Renderer> { Element::new(themer) } } diff --git a/widget/src/toggler.rs b/widget/src/toggler.rs index 0ad2d22c..7910c210 100644 --- a/widget/src/toggler.rs +++ b/widget/src/toggler.rs @@ -76,7 +76,6 @@ use crate::core::{ /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Toggler< 'a, Message, diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index 900585fa..866f58d0 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -56,7 +56,6 @@ use crate::core::{ /// ).into() /// } /// ``` -#[allow(missing_debug_implementations)] pub struct Tooltip< 'a, Message, diff --git a/widget/src/vertical_slider.rs b/widget/src/vertical_slider.rs index 8e8ca1c0..35e0217e 100644 --- a/widget/src/vertical_slider.rs +++ b/widget/src/vertical_slider.rs @@ -84,7 +84,6 @@ use crate::core::{ /// } /// } /// ``` -#[allow(missing_debug_implementations)] pub struct VerticalSlider<'a, T, Message, Theme = crate::Theme> where Theme: Catalog, diff --git a/winit/Cargo.toml b/winit/Cargo.toml index 85f235f7..ebac340b 100644 --- a/winit/Cargo.toml +++ b/winit/Cargo.toml @@ -17,7 +17,6 @@ workspace = true default = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] debug = ["iced_debug/enable"] sysinfo = ["dep:sysinfo"] -program = [] x11 = ["winit/x11"] wayland = ["winit/wayland"] wayland-dlopen = ["winit/wayland-dlopen"] diff --git a/winit/src/clipboard.rs b/winit/src/clipboard.rs index d54a1fe0..b1463fdc 100644 --- a/winit/src/clipboard.rs +++ b/winit/src/clipboard.rs @@ -6,7 +6,6 @@ use winit::window::{Window, WindowId}; /// A buffer for short-term storage and transfer within and between /// applications. -#[allow(missing_debug_implementations)] pub struct Clipboard { state: State, } diff --git a/winit/src/lib.rs b/winit/src/lib.rs index 413bdab3..8be0e924 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -42,7 +42,7 @@ use crate::core::renderer; use crate::core::theme; use crate::core::time::Instant; use crate::core::widget::operation; -use crate::core::{Point, Settings, Size}; +use crate::core::{Point, Size}; use crate::futures::futures::channel::mpsc; use crate::futures::futures::channel::oneshot; use crate::futures::futures::task; @@ -64,11 +64,7 @@ use std::slice; use std::sync::Arc; /// Runs a [`Program`] with the provided settings. -pub fn run

( - program: P, - settings: Settings, - window_settings: Option, -) -> Result<(), Error> +pub fn run

(program: P) -> Result<(), Error> where P: Program + 'static, P::Theme: theme::Base, @@ -76,6 +72,8 @@ where use winit::event_loop::EventLoop; let boot_span = debug::boot(); + let settings = program.settings(); + let window_settings = program.window(); let graphics_settings = settings.clone().into(); let event_loop = EventLoop::with_user_event() @@ -171,7 +169,6 @@ where impl winit::application::ApplicationHandler> for Runner where - Message: std::fmt::Debug, F: Future, { fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { diff --git a/winit/src/proxy.rs b/winit/src/proxy.rs index dcfcacff..92758c5f 100644 --- a/winit/src/proxy.rs +++ b/winit/src/proxy.rs @@ -77,10 +77,7 @@ impl Proxy { /// /// Note: This skips the backpressure mechanism with an unbounded /// channel. Use sparingly! - pub fn send(&self, value: T) - where - T: std::fmt::Debug, - { + pub fn send(&self, value: T) { self.send_action(Action::Output(value)); } @@ -88,10 +85,7 @@ impl Proxy { /// /// Note: This skips the backpressure mechanism with an unbounded /// channel. Use sparingly! - pub fn send_action(&self, action: Action) - where - T: std::fmt::Debug, - { + pub fn send_action(&self, action: Action) { let _ = self.raw.send_event(action); } diff --git a/winit/src/window.rs b/winit/src/window.rs index 419c50e1..a1e8c8b3 100644 --- a/winit/src/window.rs +++ b/winit/src/window.rs @@ -24,7 +24,6 @@ use winit::monitor::MonitorHandle; use std::collections::BTreeMap; use std::sync::Arc; -#[allow(missing_debug_implementations)] pub struct WindowManager where P: Program, @@ -157,7 +156,6 @@ where } } -#[allow(missing_debug_implementations)] pub struct Window where P: Program,