diff --git a/.crate-docs.md b/.crate-docs.md index 36caa9349..088a382b5 100644 --- a/.crate-docs.md +++ b/.crate-docs.md @@ -16,16 +16,20 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -// Create a dynamic usize. -let count = Dynamic::new(0_usize); - -// Create a new button with a label that is produced by mapping the contents -// of `count`. -Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Run the button as an an application. - .run() +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); + + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Run the application + .run() +} ``` [widget]: crate::widget::Widget @@ -33,7 +37,7 @@ Button::new(count.map_each(ToString::to_string)) [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: mod@crate::widgets -[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/basic-button.rs ## Open-source Licenses diff --git a/.rustme/docs.md b/.rustme/docs.md index 7859fb646..25438703a 100644 --- a/.rustme/docs.md +++ b/.rustme/docs.md @@ -16,7 +16,7 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -$../examples/button.rs:readme$ +$../examples/basic-button.rs:readme$ ``` [widget]: $widget$ @@ -24,4 +24,4 @@ $../examples/button.rs:readme$ [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: $widgets$ -[button-example]: https://github.com/khonsulabs/gooey/tree/$ref-name$/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/$ref-name$/examples/basic-button.rs diff --git a/Cargo.lock b/Cargo.lock index 8ac28abab..616a34711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac57f2b058a76363e357c056e4f74f1945bf734d37b8b3ef49066c4787dde0fc" +dependencies = [ + "clipboard-win", + "core-graphics", + "image", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot", + "thiserror", + "winapi", + "x11rb 0.10.1", +] + [[package]] name = "arrayref" version = "0.3.7" @@ -309,10 +328,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.84" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8e7c90afad890484a21653d08b6e209ae34770fb5ee298f9c699fcc1e5c856" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ + "jobserver", "libc", ] @@ -334,6 +354,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clipboard-win" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -445,6 +476,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "cursor-icon" version = "1.1.0" @@ -473,6 +513,26 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "1.0.0-beta.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7abbfc297053be59290e3152f8cbcd52c8642e0728b69ee187d991d4c1af08d" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0-beta.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bba3e9872d7c58ce7ef0fcf1844fcc3e23ef2a58377b50df35dd98e42a5726e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -508,14 +568,24 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c18ee0ed65a5f1f81cac6b1d213b69c35fa47d4252ad41f1486dbd8226fe36e" +checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" dependencies = [ "libc", "windows-sys 0.48.0", ] +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + [[package]] name = "etagere" version = "0.2.8" @@ -541,10 +611,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fdeflate" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d6dafc854908ff5da46ff3f8f473c6984119a2876a383a860246dd7841a868" +dependencies = [ + "simd-adler32", +] + [[package]] name = "figures" version = "0.1.0" -source = "git+https://github.com/khonsulabs/figures#7b41393c44d4def606790e340c98450b603010b4" +source = "git+https://github.com/khonsulabs/figures#52d06f3623cdb47128f1537fdadfe190f7afa88e" dependencies = [ "bytemuck", "euclid", @@ -553,6 +632,16 @@ dependencies = [ "winit", ] +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float_next_after" version = "1.0.0" @@ -654,6 +743,16 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "gethostname" version = "0.3.0" @@ -721,6 +820,8 @@ version = "0.1.0" dependencies = [ "ahash", "alot", + "arboard", + "derive_more", "gooey-macros", "intentional", "interner", @@ -738,7 +839,7 @@ version = "0.1.0" dependencies = [ "attribute-derive", "insta", - "manyhow 0.9.0", + "manyhow 0.10.0", "prettyplease", "proc-macro2", "quote", @@ -858,6 +959,8 @@ dependencies = [ "color_quant", "num-rational", "num-traits", + "png", + "tiff", ] [[package]] @@ -934,6 +1037,21 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + [[package]] name = "js-sys" version = "0.3.65" @@ -969,7 +1087,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#09790aafb5a9c3b0da034387adead9960eb06bc7" +source = "git+https://github.com/khonsulabs/kludgine#ec57aacb2ac1df099ac0abd5f0848a01ce843225" dependencies = [ "ahash", "alot", @@ -983,6 +1101,7 @@ dependencies = [ "lyon_tessellation", "pollster", "smallvec", + "unicode-bidi", "wgpu", ] @@ -1118,11 +1237,11 @@ dependencies = [ [[package]] name = "manyhow" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aebef87880bafc898c6bed1435e8fdc58634275ff97693a4bb96ad561c73c43" +checksum = "4efde575f79afb9c637eb4663aa451f0bf227413aa734fbbec077cab5900be85" dependencies = [ - "manyhow-macros 0.9.0", + "manyhow-macros 0.10.0", "proc-macro2", "quote", "syn", @@ -1141,9 +1260,9 @@ dependencies = [ [[package]] name = "manyhow-macros" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74cc8a0d8b05a7e919011c78a2744e7dea66567c05fb046666f3bae383d8d04" +checksum = "fcee04599474650eb26ae5a5c7837e30e55242267ff1bf0adc760b6fcdc3fa2a" dependencies = [ "proc-macro-utils", "proc-macro2", @@ -1229,13 +1348,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", + "simd-adler32", ] [[package]] name = "naga" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61d829abac9f5230a85d8cc83ec0879b4c09790208ae25b5ea031ef84562e071" +checksum = "6cd05939c491da968a42986204b7431678be21fdcd4b10cc84997ba130ada5a4" dependencies = [ "bit-set", "bitflags 2.4.1", @@ -1290,6 +1410,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.25.1" @@ -1397,6 +1529,17 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.1" @@ -1428,6 +1571,15 @@ dependencies = [ "cc", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.32.1" @@ -1579,6 +1731,19 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "png" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "pollster" version = "0.3.0" @@ -1807,9 +1972,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.38.21" +version = "0.38.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234" dependencies = [ "bitflags 2.4.1", "errno", @@ -1904,6 +2069,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "similar" version = "2.3.0" @@ -1987,6 +2158,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + [[package]] name = "strict-num" version = "0.1.1" @@ -2068,6 +2245,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d172b0f4d3fba17ba89811858b9d3d97f928aece846475bbda076ca46736211" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tiny-skia" version = "0.11.2" @@ -2465,6 +2653,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + [[package]] name = "wgpu" version = "0.18.0" @@ -2492,9 +2686,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "837e02ddcdc6d4a9b56ba4598f7fd4202a7699ab03f6ef4dcdebfad2c966aea6" +checksum = "ef91c1d62d1e9e81c79e600131a258edf75c9531cbdbde09c44a011a47312726" dependencies = [ "arrayvec", "bit-vec", @@ -2807,7 +3001,7 @@ dependencies = [ "web-time", "windows-sys 0.48.0", "x11-dl", - "x11rb", + "x11rb 0.12.0", "xkbcommon-dl", ] @@ -2831,6 +3025,19 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b4883219f345e712b3209c62654ebda0bb50887f330cbd018d0f654bfd507" +dependencies = [ + "gethostname 0.2.3", + "nix 0.24.3", + "winapi", + "winapi-wsapoll", + "x11rb-protocol 0.10.0", +] + [[package]] name = "x11rb" version = "0.12.0" @@ -2838,14 +3045,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" dependencies = [ "as-raw-xcb-connection", - "gethostname", + "gethostname 0.3.0", "libc", "libloading 0.7.4", "nix 0.26.4", "once_cell", "winapi", "winapi-wsapoll", - "x11rb-protocol", + "x11rb-protocol 0.12.0", +] + +[[package]] +name = "x11rb-protocol" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56b245751c0ac9db0e006dc812031482784e434630205a93c73cfefcaabeac67" +dependencies = [ + "nix 0.24.3", ] [[package]] @@ -2920,18 +3136,18 @@ checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" [[package]] name = "zerocopy" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f140bda219a26ccc0cdb03dba58af72590c53b22642577d88a927bc5c87d6b" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f8dbfa878..3b3f92a11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ tracing-subscriber = { version = "0.3", optional = true, features = [ palette = "0.7.3" ahash = "0.8.6" gooey-macros = { version = "0.1.0", path = "gooey-macros" } +derive_more = { version = "1.0.0-beta.6", features = ["from"] } +arboard = "3.2.1" # [patch."https://github.com/khonsulabs/kludgine"] diff --git a/README.md b/README.md index 30bebeb50..33a9ff528 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,20 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -// Create a dynamic usize. -let count = Dynamic::new(0_usize); - -// Create a new button with a label that is produced by mapping the contents -// of `count`. -Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Run the button as an an application. - .run() +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); + + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Run the application + .run() +} ``` [widget]: https://gooey.rs/main/gooey/widget/trait.Widget.html @@ -35,7 +39,7 @@ Button::new(count.map_each(ToString::to_string)) [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: https://gooey.rs/main/gooey/widgets/index.html -[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/basic-button.rs ## Open-source Licenses diff --git a/examples/animation.rs b/examples/animation.rs index 33a144408..a7eb1018e 100644 --- a/examples/animation.rs +++ b/examples/animation.rs @@ -3,7 +3,6 @@ use std::time::Duration; use gooey::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn}; use gooey::value::Dynamic; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Label, Stack}; use gooey::{Run, WithClone}; fn main() -> gooey::Result { @@ -18,13 +17,17 @@ fn main() -> gooey::Result { .on_complete(|| println!("Gooey animations are neat!")) .launch(); - Stack::columns( - Button::new("To 0") - .on_click(animate_to(&animation, &value, 0)) - .and(Label::new(label)) - .and(Button::new("To 100").on_click(animate_to(&animation, &value, 100))), - ) - .run() + "To 0" + .into_button() + .on_click(animate_to(&animation, &value, 0)) + .and(label) + .and( + "To 100" + .into_button() + .on_click(animate_to(&animation, &value, 100)), + ) + .into_columns() + .run() } fn animate_to( diff --git a/examples/basic-button.rs b/examples/basic-button.rs new file mode 100644 index 000000000..cdccc9f83 --- /dev/null +++ b/examples/basic-button.rs @@ -0,0 +1,20 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::Run; + +// begin rustme snippet: readme +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); + + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Run the application + .run() +} +// end rustme snippet diff --git a/examples/button.rs b/examples/button.rs deleted file mode 100644 index 170ffa6b0..000000000 --- a/examples/button.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gooey::value::Dynamic; -use gooey::widget::MakeWidget; -use gooey::widgets::button::ButtonOutline; -use gooey::widgets::Button; -use gooey::Run; -use kludgine::Color; - -// begin rustme snippet: readme -fn main() -> gooey::Result { - // Create a dynamic usize. - let count = Dynamic::new(0_isize); - - // Create a new button with a label that is produced by mapping the contents - // of `count`. - Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - .and( - // Creates a second, outlined button - Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that decrements the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() - 1))) - .with(&ButtonOutline, Color::DARKRED), - ) - .into_columns() - // Run the button as an an application. - .run() -} -// end rustme snippet diff --git a/examples/buttons.rs b/examples/buttons.rs new file mode 100644 index 000000000..12ebd4b0b --- /dev/null +++ b/examples/buttons.rs @@ -0,0 +1,56 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::button::ButtonKind; +use gooey::widgets::{Button, Checkbox}; +use gooey::Run; + +fn main() -> gooey::Result { + let clicked_label = Dynamic::new(String::from("Click a Button")); + let default_is_outline = Dynamic::new(false); + let default_button_style = default_is_outline.map_each(|is_outline| { + if *is_outline { + ButtonKind::Outline + } else { + ButtonKind::Solid + } + }); + + clicked_label + .clone() + .and( + Button::new("Normal Button") + .on_click( + clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Normal Button")) + }), + ) + .and( + Button::new("Outline Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Outline Button")) + })) + .kind(ButtonKind::Outline), + ) + .and( + Button::new("Transparent Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Transparent Button")) + })) + .kind(ButtonKind::Transparent), + ) + .and( + Button::new("Default Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Default Button")) + })) + .kind(default_button_style) + .into_default(), + ) + .and(Checkbox::new(default_is_outline, "Set Default to Outline")) + .into_columns(), + ) + .into_rows() + .centered() + .expand() + .run() +} diff --git a/examples/checkbox.rs b/examples/checkbox.rs new file mode 100644 index 000000000..18a5df442 --- /dev/null +++ b/examples/checkbox.rs @@ -0,0 +1,19 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::checkbox::CheckboxState; +use gooey::widgets::Checkbox; +use gooey::Run; + +fn main() -> gooey::Result { + let checkbox_state = Dynamic::new(CheckboxState::Checked); + let label = checkbox_state.map_each(|state| format!("Check Me! Current: {state:?}")); + + Checkbox::new(checkbox_state.clone(), label) + .and("Maybe".into_button().on_click(move |()| { + checkbox_state.update(CheckboxState::Indeterminant); + })) + .into_columns() + .centered() + .expand() + .run() +} diff --git a/examples/containers.rs b/examples/containers.rs index 2f2c36392..f67e098b9 100644 --- a/examples/containers.rs +++ b/examples/containers.rs @@ -1,15 +1,15 @@ use gooey::value::Dynamic; use gooey::widget::{MakeWidget, WidgetInstance}; -use gooey::widgets::{Button, Label}; use gooey::window::ThemeMode; use gooey::Run; fn main() -> gooey::Result { let theme_mode = Dynamic::default(); - set_of_containers(1, theme_mode.clone()) + set_of_containers(3, theme_mode.clone()) .centered() + .expand() .into_window() - .with_theme_mode(theme_mode) + .themed_mode(theme_mode) .run() } @@ -17,20 +17,21 @@ fn set_of_containers(repeat: usize, theme_mode: Dynamic) -> WidgetIns let inner = if let Some(remaining_iters) = repeat.checked_sub(1) { set_of_containers(remaining_iters, theme_mode) } else { - Button::new("Toggle Theme Mode") + "Toggle Theme Mode" + .into_button() .on_click(move |_| { theme_mode.map_mut(|mode| mode.toggle()); }) .make_widget() }; - Label::new("Lowest") + "Lowest" .and( - Label::new("Low") + "Low" .and( - Label::new("Mid") + "Mid" .and( - Label::new("High") - .and(Label::new("Highest").and(inner).into_rows().contain()) + "High" + .and("Highest".and(inner).into_rows().contain()) .into_rows() .contain(), ) diff --git a/examples/counter.rs b/examples/counter.rs index cabdb9338..8f9fabda1 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -2,7 +2,6 @@ use std::string::ToString; use gooey::value::Dynamic; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Label}; use gooey::Run; use kludgine::figures::units::Lp; @@ -10,14 +9,14 @@ fn main() -> gooey::Result { let counter = Dynamic::new(0i32); let label = counter.map_each(ToString::to_string); - Label::new(label) + label .width(Lp::points(100)) - .and(Button::new("+").on_click(counter.with_clone(|counter| { + .and("+".into_button().on_click(counter.with_clone(|counter| { move |_| { *counter.lock() += 1; } }))) - .and(Button::new("-").on_click(counter.with_clone(|counter| { + .and("-".into_button().on_click(counter.with_clone(|counter| { move |_| { *counter.lock() -= 1; } diff --git a/examples/focus-order.rs b/examples/focus-order.rs new file mode 100644 index 000000000..24f573fbb --- /dev/null +++ b/examples/focus-order.rs @@ -0,0 +1,84 @@ +use std::process::exit; + +use gooey::value::{Dynamic, MapEach, StringValue}; +use gooey::widget::{MakeWidget, MakeWidgetWithId, WidgetTag}; +use gooey::widgets::Expand; +use gooey::Run; +use kludgine::figures::units::Lp; + +/// This example is the same as login, but it has an explicit tab order to +/// change from the default order (username, password, cancel, log in) to +/// username, password, log in, cancel. +fn main() -> gooey::Result { + let username = Dynamic::default(); + let password = Dynamic::default(); + + let valid = + (&username, &password).map_each(|(username, password)| validate(username, password)); + + let (login_tag, login_id) = WidgetTag::new(); + let (cancel_tag, cancel_id) = WidgetTag::new(); + let (username_tag, username_id) = WidgetTag::new(); + + // TODO this should be a grid layout to ensure proper visual alignment. + let username_row = "Username" + .and( + username + .clone() + .into_input() + .make_with_id(username_tag) + .expand(), + ) + .into_columns(); + + let password_row = "Password" + .and( + // TODO secure input + password + .clone() + .into_input() + .with_next_focus(login_id) + .expand(), + ) + .into_columns(); + + let buttons = "Cancel" + .into_button() + .on_click(|_| { + eprintln!("Login cancelled"); + exit(0) + }) + .make_with_id(cancel_tag) + .into_escape() + .with_next_focus(username_id) + .and(Expand::empty()) + .and( + "Log In" + .into_button() + .on_click(move |_| { + println!("Welcome, {}", username.get()); + exit(0); + }) + .make_with_id(login_tag) + .with_enabled(valid) + .into_default() + .with_next_focus(cancel_id), + ) + .into_columns(); + + username_row + .pad() + .and(password_row.pad()) + .and(buttons.pad()) + .into_rows() + .contain() + .width(Lp::points(300)..Lp::points(600)) + .scroll() + .centered() + .expand() + .run() +} + +fn validate(username: &String, password: &String) -> bool { + !username.is_empty() && !password.is_empty() +} diff --git a/examples/gameui.rs b/examples/gameui.rs index 50c5a7eff..f3e172d1c 100644 --- a/examples/gameui.rs +++ b/examples/gameui.rs @@ -1,6 +1,6 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::{MakeWidget, HANDLED, IGNORED}; -use gooey::widgets::{Input, Label, Space}; +use gooey::widgets::Space; use gooey::Run; use kludgine::app::winit::event::ElementState; use kludgine::app::winit::keyboard::Key; @@ -10,13 +10,14 @@ fn main() -> gooey::Result { let chat_log = Dynamic::new("Chat log goes here.\n".repeat(100)); let chat_message = Dynamic::new(String::new()); - Label::new(chat_log.clone()) + chat_log + .clone() .vertical_scroll() .expand() .and(Space::colored(Color::RED).expand_weighted(2)) .into_columns() .expand() - .and(Input::new(chat_message.clone()).on_key(move |input| { + .and(chat_message.clone().into_input().on_key(move |input| { match (input.state, input.logical_key) { (ElementState::Pressed, Key::Enter) => { let new_message = chat_message.map_mut(std::mem::take); diff --git a/examples/input.rs b/examples/input.rs index 48c1cefe9..65e39823f 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -1,7 +1,7 @@ +use gooey::value::StringValue; use gooey::widget::MakeWidget; -use gooey::widgets::Input; use gooey::Run; fn main() -> gooey::Result { - Input::new("Hello").expand().run() + "Hello".into_input().expand().run() } diff --git a/examples/login.rs b/examples/login.rs index 60a7be9d6..22a762e5e 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -1,8 +1,8 @@ use std::process::exit; -use gooey::value::{Dynamic, MapEach}; +use gooey::value::{Dynamic, MapEach, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Expand, Input, Label}; +use gooey::widgets::Expand; use gooey::Run; use kludgine::figures::units::Lp; @@ -14,18 +14,19 @@ fn main() -> gooey::Result { (&username, &password).map_each(|(username, password)| validate(username, password)); // TODO this should be a grid layout to ensure proper visual alignment. - let username_row = Label::new("Username") - .and(Input::new(username.clone()).expand()) + let username_row = "Username" + .and(username.clone().into_input().expand()) .into_columns(); - let password_row = Label::new("Password") + let password_row = "Password" .and( // TODO secure input - Input::new(password.clone()).expand(), + password.clone().into_input().expand(), ) .into_columns(); - let buttons = Button::new("Cancel") + let buttons = "Cancel" + .into_button() .on_click(|_| { eprintln!("Login cancelled"); exit(0) @@ -33,13 +34,14 @@ fn main() -> gooey::Result { .into_escape() .and(Expand::empty()) .and( - Button::new("Log In") - .enabled(valid) + "Log In" + .into_button() .on_click(move |_| { println!("Welcome, {}", username.get()); exit(0); }) - .into_default(), + .into_default() + .with_enabled(valid), ) .into_columns(); diff --git a/examples/nested-scroll.rs b/examples/nested-scroll.rs new file mode 100644 index 000000000..0dc035872 --- /dev/null +++ b/examples/nested-scroll.rs @@ -0,0 +1,18 @@ +use gooey::widget::MakeWidget; +use gooey::Run; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + include_str!("./nested-scroll.rs") + .vertical_scroll() + .height(Lp::inches(3)) + .and( + include_str!("./canvas.rs") + .vertical_scroll() + .height(Lp::inches(3)), + ) + .into_rows() + .vertical_scroll() + .expand() + .run() +} diff --git a/examples/scroll.rs b/examples/scroll.rs index 745ec1f40..89daf674a 100644 --- a/examples/scroll.rs +++ b/examples/scroll.rs @@ -1,9 +1,8 @@ use gooey::widget::MakeWidget; -use gooey::widgets::Label; use gooey::Run; fn main() -> gooey::Result { - Label::new(include_str!("../src/widgets/scroll.rs")) + include_str!("../src/widgets/scroll.rs") .scroll() .expand() .run() diff --git a/examples/slider.rs b/examples/slider.rs new file mode 100644 index 000000000..2c6d83220 --- /dev/null +++ b/examples/slider.rs @@ -0,0 +1,67 @@ +use gooey::animation::{LinearInterpolate, PercentBetween}; +use gooey::value::{Dynamic, StringValue}; +use gooey::widget::MakeWidget; +use gooey::widgets::slider::Slidable; +use gooey::Run; +use kludgine::figures::units::Lp; +use kludgine::figures::Ranged; + +fn main() -> gooey::Result { + u8_slider() + .and(enum_slider()) + .into_rows() + .expand_horizontally() + .width(..Lp::points(800)) + .centered() + .expand() + .run() +} + +fn u8_slider() -> impl MakeWidget { + let min_text = Dynamic::new(u8::MIN.to_string()); + let min = min_text.map_each(|min| min.parse().unwrap_or(u8::MIN)); + let max_text = Dynamic::new(u8::MAX.to_string()); + let max = max_text.map_each(|max| max.parse().unwrap_or(u8::MAX)); + let value = Dynamic::new(128_u8); + let value_text = value.map_each(ToString::to_string); + + "Min" + .and(min_text.into_input()) + .and("Max") + .and(max_text.into_input()) + .into_columns() + .centered() + .and(value.slider_between(min, max)) + .and(value_text.centered()) + .into_rows() +} + +#[derive(LinearInterpolate, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +enum SlidableEnum { + A, + B, + C, +} + +impl PercentBetween for SlidableEnum { + fn percent_between(&self, min: &Self, max: &Self) -> gooey::animation::ZeroToOne { + let min = *min as u8; + let max = *max as u8; + let value = *self as u8; + value.percent_between(&min, &max) + } +} + +impl Ranged for SlidableEnum { + const MAX: Self = Self::C; + const MIN: Self = Self::A; +} + +fn enum_slider() -> impl MakeWidget { + let enum_value = Dynamic::new(SlidableEnum::A); + let enum_text = enum_value.map_each(|value| format!("{value:?}")); + "Custom Enum" + .and(enum_value.slider()) + .and(enum_text) + .into_rows() +} diff --git a/examples/stack-align-test.rs b/examples/stack-align-test.rs new file mode 100644 index 000000000..f7453a227 --- /dev/null +++ b/examples/stack-align-test.rs @@ -0,0 +1,52 @@ +use gooey::widget::MakeWidget; +use gooey::Run; + +/// This example shows a tricky layout problem. The hierarchy of widgets is +/// this: +/// +/// ```text +/// Expand (.expand()) +/// | Align (.centered()) +/// | | Stack (.into_rows()) +/// | | | Label +/// | | | Align (.centered()) +/// | | | | Button +/// ``` +/// +/// When the Stack widget attempted to implmement a single-pass layout, this +/// caused the Button to be aligned to the left inside of the stack. The Stack +/// widget now utilizes two `layout()` operations for layouts like this. Here's +/// the reasoning: +/// +/// At the window root, we have an Align wrapped by an Expand. The Align widget +/// during layout asks its children to size-to-fit. This means the Stack is +/// asking its children to size-to-fit as well. +/// +/// The Stack's orientation is Rows, and since the children are Resizes or +/// Expands, the widgets are size-to-fit. This means that the Stack will measure +/// these widgets asking them to size to fit. +/// +/// After running this pass of measurement, we can assign the heights of each of +/// the rows to the measurements we received. The width of the stack becomes the +/// maximum width of all children measured. +/// +/// In a single-pass layout, this means the Align widget inside of the Stack +/// never receives an opportunity to lay its children out with the final width. +/// The Button does end up centered because of this. Fixing it also becomes +/// tricky, because if surround the button in an Expand, it now instructs the +/// Stack to expand to fill its parent. +/// +/// After some careful deliberation, @ecton reasoned that in the situation where +/// a Stack is asked to layout with the Stack's non-primary being a size-to-fit +/// measurement, a second layout call for all children is required with Known +/// measurements to allow layouts like this example to work correctly. +fn main() -> gooey::Result { + // TODO once we have offscreen rendering, turn this into a test case + "Really Long Label" + .and("Short".into_button().centered()) + .into_rows() + .contain() + .centered() + .expand() + .run() +} diff --git a/examples/style.rs b/examples/style.rs index 36c2f8db7..d2c1ffa75 100644 --- a/examples/style.rs +++ b/examples/style.rs @@ -1,12 +1,12 @@ use gooey::styles::components::TextColor; use gooey::widget::MakeWidget; use gooey::widgets::stack::Stack; -use gooey::widgets::{Button, Style}; +use gooey::widgets::Style; use gooey::Run; use kludgine::Color; fn main() -> gooey::Result { - Stack::rows(Button::new("Green").and(red_text(Button::new("Red")))) + Stack::rows("Green".and(red_text("Red"))) .with(&TextColor, Color::GREEN) .run() } diff --git a/examples/switcher.rs b/examples/switcher.rs index ea43ea6ac..0d3505265 100644 --- a/examples/switcher.rs +++ b/examples/switcher.rs @@ -1,6 +1,5 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, Switchable}; use gooey::widget::{MakeWidget, WidgetInstance}; -use gooey::widgets::{Button, Label, Switcher}; use gooey::Run; #[derive(Debug)] @@ -12,21 +11,23 @@ enum ActiveContent { fn main() -> gooey::Result { let active = Dynamic::new(ActiveContent::Intro); - Switcher::new(active.clone(), move |content| match content { - ActiveContent::Intro => intro(active.clone()), - ActiveContent::Success => success(active.clone()), - }) - .contain() - .centered() - .expand() - .run() + active + .switcher(|current, active| match current { + ActiveContent::Intro => intro(active.clone()), + ActiveContent::Success => success(active.clone()), + }) + .contain() + .centered() + .expand() + .run() } fn intro(active: Dynamic) -> WidgetInstance { const INTRO: &str = "This example demonstrates the Switcher widget, which uses a mapping function to convert from a generic type to the widget it uses for its contents."; - Label::new(INTRO) + INTRO .and( - Button::new("Switch!") + "Switch!" + .into_button() .on_click(move |_| active.set(ActiveContent::Success)) .centered(), ) @@ -35,11 +36,12 @@ fn intro(active: Dynamic) -> WidgetInstance { } fn success(active: Dynamic) -> WidgetInstance { - Label::new("The value changed to `ActiveContent::Success`!") + "The value changed to `ActiveContent::Success`!" .and( - Button::new("Start Over") + "Start Over" + .into_button() .on_click(move |_| active.set(ActiveContent::Intro)) - // .centered(), + .centered(), ) .into_rows() .make_widget() diff --git a/examples/theme.rs b/examples/theme.rs index 65b809011..948c53bb6 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -1,13 +1,12 @@ -use std::str::FromStr; - use gooey::animation::ZeroToOne; use gooey::styles::components::{TextColor, WidgetBackground}; use gooey::styles::{ ColorScheme, ColorSource, ColorTheme, FixedTheme, SurfaceTheme, Theme, ThemePair, }; -use gooey::value::{Dynamic, MapEach}; +use gooey::value::{Dynamic, MapEach, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Input, Label, ModeSwitch, Scroll, Slider, Stack, Themed}; +use gooey::widgets::slider::Slidable; +use gooey::widgets::{Slider, Stack}; use gooey::window::ThemeMode; use gooey::Run; use kludgine::Color; @@ -44,38 +43,37 @@ fn main() -> gooey::Result { }, ); - Themed::new( - default_theme.clone(), - Stack::columns( - Scroll::vertical(Stack::rows( - theme_switcher - .and(primary_editor) - .and(secondary_editor) - .and(tertiary_editor) - .and(error_editor) - .and(neutral_editor) - .and(neutral_variant_editor), - )) - .and(fixed_themes( - default_theme.map_each(|theme| theme.primary_fixed), - default_theme.map_each(|theme| theme.secondary_fixed), - default_theme.map_each(|theme| theme.tertiary_fixed), - )) - .and(theme( - default_theme.map_each(|theme| theme.dark), - ThemeMode::Dark, - )) - .and(theme( - default_theme.map_each(|theme| theme.light), - ThemeMode::Light, - )), - ), - ) - .pad() - .expand() - .into_window() - .with_theme_mode(theme_mode) - .run() + let editors = theme_switcher + .and(primary_editor) + .and(secondary_editor) + .and(tertiary_editor) + .and(error_editor) + .and(neutral_editor) + .and(neutral_variant_editor) + .into_rows() + .vertical_scroll(); + + editors + .and(fixed_themes( + default_theme.map_each(|theme| theme.primary_fixed), + default_theme.map_each(|theme| theme.secondary_fixed), + default_theme.map_each(|theme| theme.tertiary_fixed), + )) + .and(theme( + default_theme.map_each(|theme| theme.dark), + ThemeMode::Dark, + )) + .and(theme( + default_theme.map_each(|theme| theme.light), + ThemeMode::Light, + )) + .into_columns() + .themed(default_theme) + .pad() + .expand() + .into_window() + .themed_mode(theme_mode) + .run() } fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { @@ -83,30 +81,18 @@ fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { ( theme_mode.clone(), - Stack::rows(Label::new("Theme Mode").and(Slider::::from_value(theme_mode))), + "Theme Mode".and(theme_mode.slider()).into_rows(), ) } -fn create_paired_string(initial_value: T) -> (Dynamic, Dynamic) -where - T: ToString + PartialEq + FromStr + Default + Send + Sync + 'static, -{ - let float = Dynamic::new(initial_value); - let text = float.map_each_unique(|f| f.to_string()); - text.for_each(float.with_clone(|float| { - move |text: &String| { - let _result = float.try_update(text.parse().unwrap_or_default()); - } - })); - (float, text) -} - fn color_editor( initial_color: ColorSource, label: &str, ) -> (Dynamic, impl MakeWidget) { - let (hue, hue_text) = create_paired_string(initial_color.hue.into_degrees()); - let (saturation, saturation_text) = create_paired_string(initial_color.saturation); + let hue = Dynamic::new(initial_color.hue.into_degrees()); + let hue_text = hue.linked_string(); + let saturation = Dynamic::new(initial_color.saturation); + let saturation_text = saturation.linked_string(); let color = (&hue, &saturation).map_each(|(hue, saturation)| ColorSource::new(*hue, *saturation)); @@ -114,11 +100,11 @@ fn color_editor( ( color, Stack::rows( - Label::new(label) - .and(Slider::::new(hue, 0., 360.)) - .and(Input::new(hue_text)) + label + .and(hue.slider_between(0., 360.)) + .and(hue_text.into_input()) .and(Slider::::from_value(saturation)) - .and(Input::new(saturation_text)), + .and(saturation_text.into_input()), ), ) } @@ -128,69 +114,64 @@ fn fixed_themes( secondary: Dynamic, tertiary: Dynamic, ) -> impl MakeWidget { - Stack::rows( - Label::new("Fixed") - .and(fixed_theme(primary, "Primary")) - .and(fixed_theme(secondary, "Secondary")) - .and(fixed_theme(tertiary, "Tertiary")), - ) - .contain() - .expand() + "Fixed" + .and(fixed_theme(primary, "Primary")) + .and(fixed_theme(secondary, "Secondary")) + .and(fixed_theme(tertiary, "Tertiary")) + .into_rows() + .contain() + .expand() } fn fixed_theme(theme: Dynamic, label: &str) -> impl MakeWidget { let color = theme.map_each(|theme| theme.color); let on_color = theme.map_each(|theme| theme.on_color); - Stack::columns( - swatch(color.clone(), &format!("{label} Fixed"), on_color.clone()) - .and(swatch( - theme.map_each(|theme| theme.dim_color), - &format!("Dim {label}"), - on_color.clone(), - )) - .and(swatch( - on_color.clone(), - &format!("On {label} Fixed"), - color.clone(), - )) - .and(swatch( - theme.map_each(|theme| theme.on_color_variant), - &format!("Variant On {label} Fixed"), - color, - )), - ) - .contain() - .expand() + + swatch(color.clone(), &format!("{label} Fixed"), on_color.clone()) + .and(swatch( + theme.map_each(|theme| theme.dim_color), + &format!("Dim {label}"), + on_color.clone(), + )) + .and(swatch( + on_color.clone(), + &format!("On {label} Fixed"), + color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.on_color_variant), + &format!("Variant On {label} Fixed"), + color, + )) + .into_columns() + .contain() + .expand() } fn theme(theme: Dynamic, mode: ThemeMode) -> impl MakeWidget { - ModeSwitch::new( - mode, - Stack::rows( - Label::new(match mode { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - }) - .and( - Stack::columns( - color_theme(theme.map_each(|theme| theme.primary), "Primary") - .and(color_theme( - theme.map_each(|theme| theme.secondary), - "Secondary", - )) - .and(color_theme( - theme.map_each(|theme| theme.tertiary), - "Tertiary", - )) - .and(color_theme(theme.map_each(|theme| theme.error), "Error")), - ) - .contain() - .expand(), - ) - .and(surface_theme(theme.map_each(|theme| theme.surface))), - ) - .contain(), + match mode { + ThemeMode::Light => "Light", + ThemeMode::Dark => "Dark", + } + .and( + color_theme(theme.map_each(|theme| theme.primary), "Primary") + .and(color_theme( + theme.map_each(|theme| theme.secondary), + "Secondary", + )) + .and(color_theme( + theme.map_each(|theme| theme.tertiary), + "Tertiary", + )) + .and(color_theme(theme.map_each(|theme| theme.error), "Error")) + .into_columns() + .contain() + .expand(), ) + .and(surface_theme(theme.map_each(|theme| theme.surface))) + .into_rows() + .contain() + .themed_mode(mode) .expand() } @@ -279,6 +260,7 @@ fn surface_theme(theme: Dynamic) -> impl MakeWidget { fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { let color = theme.map_each(|theme| theme.color); let dim_color = theme.map_each(|theme| theme.color_dim); + let bright_color = theme.map_each(|theme| theme.color_bright); let on_color = theme.map_each(|theme| theme.on_color); let container = theme.map_each(|theme| theme.container); let on_container = theme.map_each(|theme| theme.on_container); @@ -289,6 +271,11 @@ fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { &format!("{label} Dim"), on_color.clone(), )) + .and(swatch( + bright_color.clone(), + &format!("{label} bright"), + on_color.clone(), + )) .and(swatch( on_color.clone(), &format!("On {label}"), @@ -310,7 +297,7 @@ fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { } fn swatch(background: Dynamic, label: &str, text: Dynamic) -> impl MakeWidget { - Label::new(label) + label .with(&TextColor, text) .with(&WidgetBackground, background) .fit_horizontally() diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs new file mode 100644 index 000000000..b28de4cc9 --- /dev/null +++ b/examples/tic-tac-toe.rs @@ -0,0 +1,204 @@ +use std::fmt::Display; +use std::iter; +use std::ops::Not; +use std::time::SystemTime; + +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::button::ButtonKind; +use gooey::{Run, WithClone}; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let app = Dynamic::default(); + app.map_each(app.with_clone(|app| { + move |state: &AppState| match state { + AppState::Playing => play_screen(&app).make_widget(), + AppState::Winner(winner) => game_end(*winner, &app).make_widget(), + } + })) + .switcher() + .contain() + .width(Lp::inches(2)..Lp::inches(6)) + .height(Lp::inches(2)..Lp::inches(6)) + .centered() + .expand() + .run() +} + +#[derive(Default, Debug)] +enum AppState { + #[default] + Playing, + Winner(Option), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Player { + X, + O, +} + +impl Display for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Player::X => f.write_str("X"), + Player::O => f.write_str("O"), + } + } +} + +impl Not for Player { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::X => Self::O, + Self::O => Self::X, + } + } +} + +struct GameState { + app: Dynamic, + current_player: Player, + cells: Vec>, +} + +impl GameState { + fn new_game(app: &Dynamic) -> Self { + Self { + app: app.clone(), + // Bad RNG: if we have an even milliseconds in the current + // timestamp, it's O's turn first. + current_player: if SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid system time") + .as_millis() + % 2 + == 0 + { + Player::O + } else { + Player::X + }, + cells: iter::repeat(None).take(9).collect(), + } + } + + fn play(&mut self, row: usize, column: usize) { + let player = self.current_player; + self.current_player = !player; + + self.cells[row * 3 + column] = Some(player); + + if let Some(winner) = self.check_for_winner() { + self.app.set(AppState::Winner(Some(winner))); + } else if self.cells.iter().all(Option::is_some) { + self.app.set(AppState::Winner(None)); + } + } + + fn check_for_winner(&self) -> Option { + // Rows and columns + for i in 0..3 { + if let Some(winner) = self + .winner_in_cells([[i, 0], [i, 1], [i, 2]]) + .or_else(|| self.winner_in_cells([[0, i], [1, i], [2, i]])) + { + return Some(winner); + } + } + + // Diagonals + self.winner_in_cells([[0, 0], [1, 1], [2, 2]]) + .or_else(|| self.winner_in_cells([[2, 0], [1, 1], [0, 2]])) + } + + fn winner_in_cells(&self, cells: [[usize; 2]; 3]) -> Option { + match ( + self.cell(cells[0][0], cells[0][1]), + self.cell(cells[1][0], cells[1][1]), + self.cell(cells[2][0], cells[2][1]), + ) { + (Some(a), Some(b), Some(c)) if a == b && b == c => Some(a), + _ => None, + } + } + + fn cell(&self, row: usize, column: usize) -> Option { + self.cells[row * 3 + column] + } +} + +fn game_end(winner: Option, app: &Dynamic) -> impl MakeWidget { + // TODO we need typography styles + let app = app.clone(); + let label = if let Some(winner) = winner { + format!("{winner:?} wins!") + } else { + String::from("No winner") + }; + + label + .and( + "Play Again" + .into_button() + .on_click(move |_| { + app.set(AppState::Playing); + }) + .into_default(), + ) + .into_rows() + .centered() + .expand() +} + +fn play_screen(app: &Dynamic) -> impl MakeWidget { + let game = Dynamic::new(GameState::new_game(app)); + let current_player_label = game.map_each(|state| format!("{}'s Turn", state.current_player)); + + current_player_label.and(play_grid(&game)).into_rows() +} + +fn play_grid(game: &Dynamic) -> impl MakeWidget { + row_of_squares(0, game) + .expand() + .and(row_of_squares(1, game).expand()) + .and(row_of_squares(2, game).expand()) + .into_rows() +} + +fn row_of_squares(row: usize, game: &Dynamic) -> impl MakeWidget { + square(row, 0, game) + .expand() + .and(square(row, 1, game).expand()) + .and(square(row, 2, game).expand()) + .into_columns() +} + +fn square(row: usize, column: usize, game: &Dynamic) -> impl MakeWidget { + let game = game.clone(); + let enabled = Dynamic::new(true); + let label = Dynamic::default(); + (&enabled, &label).with_clone(|(enabled, label)| { + game.for_each(move |state| { + let Some(player) = state.cell(row, column) else { + return; + }; + + if enabled.update(false) { + label.update(player.to_string()); + } + }); + }); + + label + .clone() + .into_button() + .kind(ButtonKind::Outline) + .on_click(move |_| game.lock().play(row, column)) + .with_enabled(enabled) + .pad() + .expand() +} diff --git a/gooey-macros/Cargo.toml b/gooey-macros/Cargo.toml index a38fa69e8..e9c650d6f 100644 --- a/gooey-macros/Cargo.toml +++ b/gooey-macros/Cargo.toml @@ -10,7 +10,7 @@ proc-macro = true [dependencies] attribute-derive = "0.8.1" -manyhow = "0.9.0" +manyhow = "0.10.0" proc-macro2 = "1.0.69" quote = "1.0.33" quote-use = "0.7.2" diff --git a/gooey-macros/src/animation.rs b/gooey-macros/src/animation.rs index 2ef277a9e..15a7a12da 100644 --- a/gooey-macros/src/animation.rs +++ b/gooey-macros/src/animation.rs @@ -1,66 +1,114 @@ -use manyhow::bail; +use manyhow::{bail, ensure}; use quote::ToTokens; -use syn::{Field, ItemStruct}; +use syn::{Data, DeriveInput, Field, Variant}; use crate::*; pub fn linear_interpolate( - ItemStruct { - ident, + DeriveInput { + ident: item_ident, generics, - fields, + data, .. - }: ItemStruct, + }: DeriveInput, ) -> Result { if let Some(generic) = generics.type_params().next() { bail!(generic, "generics not supported"); } - let fields = match fields { - syn::Fields::Unit => bail!(ident, "unit structs are not supported"), - fields => fields - .into_iter() - .enumerate() - .map(|(idx, Field { ident, .. })| { - let ident = ident - .map(ToTokens::into_token_stream) - .unwrap_or_else(|| proc_macro2::Literal::usize_unsuffixed(idx).into_token_stream()); - quote!(#ident: ::gooey::animation::LinearInterpolate::lerp(&self.#ident, &__target.#ident, __percent),) - }), + let doc; + + let body = match data { + Data::Struct(data) => { + let fields = match data.fields { + syn::Fields::Unit => bail!(item_ident, "unit structs are not supported"), + fields => fields + .into_iter() + .enumerate() + .map(|(idx, Field { ident, .. })| { + let ident = ident + .map(ToTokens::into_token_stream) + .unwrap_or_else(|| proc_macro2::Literal::usize_unsuffixed(idx).into_token_stream()); + quote!(#ident: ::gooey::animation::LinearInterpolate::lerp(&self.#ident, &__target.#ident, __percent),) + }), + }; + doc = "# Panics\n Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range)."; + quote!(#item_ident{#(#fields)*}) + } + Data::Enum(data) => { + let variants = data + .variants + .into_iter() + .map( + |Variant { + ident, + fields, + discriminant, + .. + }| { + if let Some(discriminant) = discriminant { + bail!(discriminant, "discriminants are not supported"); + } + ensure!(fields.is_empty(), fields, "enum fields are not supported"); + Ok(quote!(#item_ident::#ident #fields)) + }, + ) + .collect::>>()?; + let last = variants + .last() + .map(ToTokens::to_token_stream) + .unwrap_or_else(|| quote!(unreachable!())); + + let idx: Vec<_> = (0..variants.len()).collect(); + doc = "# Panics\n Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range)."; + quote! { + # use ::gooey::animation::LinearInterpolate; + fn variant_to_index(__v: &#item_ident) -> usize { + match __v { + #(#variants => #idx,)* + } + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&__target); + match LinearInterpolate::lerp(&__self, &__target, __percent) { + #(#idx => #variants,)* + _ => #last, + } + } + } + Data::Union(union) => bail!((union.union_token, union.fields), "unions not supported"), }; Ok(quote! { - impl ::gooey::animation::LinearInterpolate for #ident { + impl ::gooey::animation::LinearInterpolate for #item_ident { + #[doc = #doc] fn lerp(&self, __target: &Self, __percent: f32) -> Self { - #ident{#(#fields)*} + #body } } }) } #[cfg(test)] -macro_rules! expansion_snapshot { - (#[derive($fn:expr)]$($tokens:tt)*) => {{ - use insta::assert_snapshot; - use prettyplease::unparse; - use syn::{parse2, parse_quote}; - let input = parse_quote!($($tokens)*); - let output = $fn(input).unwrap(); - assert_snapshot!(unparse(&parse2(output).unwrap())) - }}; -} - -#[test] -fn test() { - expansion_snapshot! { +mod test { + use super::*; + expansion_snapshot! {struct_ #[derive(linear_interpolate)] struct HelloWorld { fielda: Hello, fieldb: World, } - }; - expansion_snapshot! { + } + expansion_snapshot! {tuple_struct #[derive(linear_interpolate)] struct HelloWorld(Hello, World); - }; + } + expansion_snapshot! {enum_ + #[derive(linear_interpolate)] + enum Enum{A, B} + } + expansion_snapshot! {empty_enum + #[derive(linear_interpolate)] + enum Enum{} + } } diff --git a/gooey-macros/src/lib.rs b/gooey-macros/src/lib.rs index 051be8cf9..2c793ce11 100644 --- a/gooey-macros/src/lib.rs +++ b/gooey-macros/src/lib.rs @@ -1,6 +1,28 @@ use manyhow::{manyhow, Result}; -use quote_use::quote_use as quote; use proc_macro2::TokenStream; +use quote_use::quote_use as quote; + +#[cfg(test)] +macro_rules! expansion_snapshot { + ($name:ident $($tokens:tt)*) => { + #[test] + fn $name() { + expansion_snapshot!{$($tokens)*} + } + }; + (#[derive($fn:expr)]$($tokens:tt)*) => {{ + use insta::assert_snapshot; + use prettyplease::unparse; + use syn::{parse2, parse_quote}; + let input = parse_quote!($($tokens)*); + let output = $fn(input).unwrap(); + match &parse2(output.clone()) { + Ok(ok) => assert_snapshot!(unparse(ok)), + Err(_) => panic!("{output}"), + } + }}; +} + mod animation; #[manyhow(proc_macro_derive(LinearInterpolate))] diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap new file mode 100644 index 000000000..792d90389 --- /dev/null +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap @@ -0,0 +1,23 @@ +--- +source: gooey-macros/src/animation.rs +expression: unparse(ok) +--- +impl ::gooey::animation::LinearInterpolate for Enum { + /**# Panics + Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range).*/ + fn lerp(&self, __target: &Self, __percent: f32) -> Self { + fn variant_to_index(__v: &Enum) -> usize { + match __v {} + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&self); + match ::gooey::animation::LinearInterpolate::lerp( + &__self, + &__target, + __percent, + ) { + _ => unreachable!(), + } + } +} + diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap new file mode 100644 index 000000000..70767444a --- /dev/null +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap @@ -0,0 +1,28 @@ +--- +source: gooey-macros/src/animation.rs +expression: unparse(ok) +--- +impl ::gooey::animation::LinearInterpolate for Enum { + /**# Panics + Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range).*/ + fn lerp(&self, __target: &Self, __percent: f32) -> Self { + fn variant_to_index(__v: &Enum) -> usize { + match __v { + Enum::A => 0usize, + Enum::B => 1usize, + } + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&self); + match ::gooey::animation::LinearInterpolate::lerp( + &__self, + &__target, + __percent, + ) { + 0usize => Enum::A, + 1usize => Enum::B, + _ => Enum::B, + } + } +} + diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap similarity index 74% rename from gooey-macros/src/snapshots/gooey_macros__animation__test.snap rename to gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap index 8a133d1bf..f32fcd0ae 100644 --- a/gooey-macros/src/snapshots/gooey_macros__animation__test.snap +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap @@ -1,8 +1,10 @@ --- -source: src/animation.rs -expression: unparse(&parse2(output).unwrap()) +source: gooey-macros/src/animation.rs +expression: unparse(ok) --- impl ::gooey::animation::LinearInterpolate for HelloWorld { + /**# Panics + Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range).*/ fn lerp(&self, __target: &Self, __percent: f32) -> Self { HelloWorld { fielda: ::gooey::animation::LinearInterpolate::lerp( diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap similarity index 79% rename from gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap rename to gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap index 117910fca..6152743a9 100644 --- a/gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap @@ -1,8 +1,10 @@ --- source: gooey-macros/src/animation.rs -expression: unparse(&parse2(output).unwrap()) +expression: unparse(ok) --- impl ::gooey::animation::LinearInterpolate for HelloWorld { + /**# Panics + Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range).*/ fn lerp(&self, __target: &Self, __percent: f32) -> Self { HelloWorld { 0: ::gooey::animation::LinearInterpolate::lerp( diff --git a/src/animation.rs b/src/animation.rs index ba9979838..1200daed4 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -39,22 +39,25 @@ pub mod easings; +use std::cmp::Ordering; use std::fmt::{Debug, Display}; use std::ops::{ControlFlow, Deref, Div, Mul}; use std::panic::{RefUnwindSafe, UnwindSafe}; use std::str::FromStr; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; use alot::{LotId, Lots}; +use derive_more::From; use intentional::Cast; use kempt::Set; use kludgine::figures::Ranged; use kludgine::Color; use crate::animation::easings::Linear; -use crate::styles::Component; +use crate::styles::{Component, RequireInvalidation}; +use crate::utils::IgnorePoison; use crate::value::Dynamic; static ANIMATIONS: Mutex = Mutex::new(Animating::new()); @@ -65,9 +68,7 @@ fn thread_state() -> MutexGuard<'static, Animating> { THREAD.get_or_init(|| { thread::spawn(animation_thread); }); - ANIMATIONS - .lock() - .map_or_else(PoisonError::into_inner, |g| g) + ANIMATIONS.lock().ignore_poison() } fn animation_thread() { @@ -75,9 +76,7 @@ fn animation_thread() { loop { if state.running.is_empty() { state.last_updated = None; - state = NEW_ANIMATIONS - .wait(state) - .map_or_else(PoisonError::into_inner, |g| g); + state = NEW_ANIMATIONS.wait(state).ignore_poison(); } else { let start = Instant::now(); let last_tick = state.last_updated.unwrap_or(start); @@ -610,12 +609,49 @@ impl Animate for Duration { } /// Performs a linear interpolation between two values. +/// +/// This trait can be derived for structs and fieldless enums. +/// +/// Note: for fields that don't implement [`LinerarInterpolate`](trait@LinearInterpolate) +/// the wrappers [`BinaryLerp`] and [`ImmediateLerp`] can be used. +/// +/// ``` +/// use gooey::animation::{BinaryLerp, ImmediateLerp, LinearInterpolate}; +/// use gooey::kludgine::Color; +/// +/// #[derive(LinearInterpolate, PartialEq, Debug)] +/// struct Struct(Color, BinaryLerp<&'static str>, ImmediateLerp<&'static str>); +/// +/// let from = Struct(Color::BLACK, "hello".into(), "hello".into()); +/// let to = Struct(Color::WHITE, "world".into(), "world".into()); +/// +/// assert_eq!( +/// from.lerp(&to, 0.41), +/// Struct(Color::DIMGRAY, "hello".into(), "world".into()) +/// ); +/// assert_eq!( +/// from.lerp(&to, 0.663), +/// Struct(Color::DARKGRAY, "world".into(), "world".into()) +/// ); +/// +/// #[derive(LinearInterpolate, PartialEq, Debug)] +/// enum Enum { +/// A, +/// B, +/// C, +/// } +/// assert_eq!(Enum::A.lerp(&Enum::B, 0.4), Enum::A); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.1), Enum::A); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.4), Enum::B); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.9), Enum::C); +/// ``` pub trait LinearInterpolate: PartialEq { /// Interpolate linearly between `self` and `target` using `percent`. #[must_use] fn lerp(&self, target: &Self, percent: f32) -> Self; } +/// Derives [`LinerarInterpolate`](trait@LinearInterpolate) for structs and fieldless enums. pub use gooey_macros::LinearInterpolate; macro_rules! impl_lerp_for_int { @@ -641,9 +677,9 @@ macro_rules! impl_lerp_for_uint { fn lerp(&self, target: &Self, percent: f32) -> Self { let percent = $float::from(percent); if let Some(delta) = target.checked_sub(*self) { - *self + (delta as $float * percent).round() as $type + self.saturating_add((delta as $float * percent).round() as $type) } else { - *self - ((*self - *target) as $float * percent).round() as $type + self.saturating_sub(((*self - *target) as $float * percent).round() as $type) } } } @@ -701,8 +737,10 @@ impl PercentBetween for bool { fn integer_lerps() { #[track_caller] fn test_lerps(a: &T, b: &T, mid: &T) { + assert_eq!(&b.lerp(a, 1.), a); assert_eq!(&a.lerp(b, 1.), b); assert_eq!(&a.lerp(b, 0.), a); + assert_eq!(&b.lerp(a, 0.), b); assert_eq!(&a.lerp(b, 0.5), mid); } @@ -733,7 +771,7 @@ impl LinearInterpolate for Color { /// /// This wrapper can be used to add [`LinearInterpolate`] to types that normally /// don't support interpolation. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, From)] pub struct BinaryLerp(T); impl LinearInterpolate for BinaryLerp @@ -754,7 +792,7 @@ where /// /// This wrapper can be used to add [`LinearInterpolate`] to types that normally /// don't support interpolation. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, From)] pub struct ImmediateLerp(T); impl LinearInterpolate for ImmediateLerp @@ -780,8 +818,14 @@ macro_rules! impl_percent_between { ($type:ident, $float:ident) => { impl PercentBetween for $type { fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + assert!(min <= max, "percent_between requires min <= max"); + assert!( + self >= min && self <= max, + "self must satisfy min <= self <= max" + ); + let range = *max - *min; - ZeroToOne::from(*self as $float / range as $float) + ZeroToOne::from((*self - *min) as $float / range as $float) } } }; @@ -809,17 +853,60 @@ impl PercentBetween for Color { min: Color, max: Color, func: impl Fn(Color) -> u8, - ) -> ZeroToOne { - func(value).percent_between(&func(min), &func(max)) + ) -> Option { + let value = func(value); + let min = func(min); + let max = func(max); + match min.cmp(&max) { + Ordering::Less => Some(value.percent_between(&min, &max)), + Ordering::Equal => None, + Ordering::Greater => Some(value.percent_between(&max, &min).one_minus()), + } } - channel_percent(*self, *min, *max, Color::red) - * channel_percent(*self, *min, *max, Color::green) - * channel_percent(*self, *min, *max, Color::blue) - * channel_percent(*self, *min, *max, Color::alpha) + let mut total_percent_change = 0.; + let mut different_channels = 0_u8; + + for func in [Color::red, Color::green, Color::blue, Color::alpha] { + if let Some(red) = channel_percent(*self, *min, *max, func) { + total_percent_change += *red; + different_channels += 1; + } + } + + if different_channels > 0 { + ZeroToOne::new(total_percent_change / f32::from(different_channels)) + } else { + ZeroToOne::ZERO + } } } +#[test] +fn int_percent_between() { + assert_eq!(1_u8.percent_between(&1_u8, &2_u8), ZeroToOne::ZERO); +} + +#[test] +fn color_lerp() { + let gray = Color::new(51, 51, 51, 51); + let percent_gray = gray.percent_between(&Color::CLEAR_BLACK, &Color::WHITE); + + assert_eq!(gray, Color::CLEAR_BLACK.lerp(&Color::WHITE, *percent_gray)); + + let gray = Color::new(51, 51, 51, 255); + let percent_gray = gray.percent_between(&Color::BLACK, &Color::WHITE); + + assert_eq!(gray, Color::BLACK.lerp(&Color::WHITE, *percent_gray)); + + let red_green = Color::RED.lerp(&Color::GREEN, 0.5); + let percent_between = red_green.percent_between(&Color::RED, &Color::GREEN); + // Why 1 / 255 / 4? This operation is working on u8s, and there are 4 + // channels that can be averaged. The percent is guaranteed to be within + // this range, which works out to be 0.0098 percent. + assert!((*percent_between - 0.5).abs() < 1. / 255. / 4.); +} + /// An `f32` that is clamped between 0.0 and 1.0 and cannot be NaN or Infinity. /// /// Because of these restrictions, this type implements `Ord` and `Eq`. @@ -918,19 +1005,19 @@ impl PartialEq for ZeroToOne { } impl Ord for ZeroToOne { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> Ordering { self.0.total_cmp(&other.0) } } impl PartialOrd for ZeroToOne { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl PartialOrd for ZeroToOne { - fn partial_cmp(&self, other: &f32) -> Option { + fn partial_cmp(&self, other: &f32) -> Option { Some(self.0.total_cmp(other)) } } @@ -1012,6 +1099,12 @@ impl TryFrom for EasingFunction { } } +impl RequireInvalidation for EasingFunction { + fn requires_invalidation(&self) -> bool { + false + } +} + /// Performs easing for value interpolation. pub trait Easing: Debug + Send + Sync + RefUnwindSafe + UnwindSafe + 'static { /// Eases a value ranging between zero and one. The resulting value does not diff --git a/src/context.rs b/src/context.rs index f0555dbf9..6c81f7c12 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,9 +1,11 @@ //! Types that provide access to the Gooey runtime. use std::borrow::Cow; +use std::hash::Hash; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; +use kempt::Set; use kludgine::app::winit::event::{ DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, }; @@ -13,12 +15,13 @@ use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::{Color, Kludgine}; use crate::graphics::Graphics; -use crate::styles::components::{HighlightColor, WidgetBackground}; -use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair, VisualOrder}; +use crate::styles::components::{HighlightColor, LayoutOrder, WidgetBackground}; +use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair}; +use crate::utils::IgnorePoison; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; use crate::window::sealed::WindowCommand; -use crate::window::{RunningWindow, ThemeMode}; +use crate::window::{CursorState, RunningWindow, ThemeMode}; use crate::ConstraintLimit; /// A context to an event function. @@ -182,7 +185,10 @@ impl<'context, 'window> EventContext<'context, 'window> { let mut activation_changes = 0; while activation_changes < MAX_ITERS { - let active = self.pending_state.active.clone(); + let active = self + .pending_state + .active + .and_then(|w| self.current_node.tree.widget(w)); if self.current_node.tree.active_widget() == active.as_ref().map(|w| w.node_id) { break; } @@ -199,13 +205,16 @@ impl<'context, 'window> EventContext<'context, 'window> { Err(()) => false, }; if new { - if let Some(active) = self.pending_state.active.clone() { + let active = self + .pending_state + .active + .and_then(|w| self.current_node.tree.widget(w)); + if let Some(active) = &active { active .lock() .as_widget() - .activate(&mut self.for_other(&active)); + .activate(&mut self.for_other(active)); } - self.pending_state.active = active; } else { break; } @@ -219,30 +228,36 @@ impl<'context, 'window> EventContext<'context, 'window> { let mut focus_changes = 0; while focus_changes < MAX_ITERS { - let focus = self.pending_state.focus.clone(); + let focus = match self + .pending_state + .focus + .and_then(|w| self.current_node.tree.widget(w)) + { + Some(focus) => self.for_other(&focus).enabled().then_some(focus), + None => None, + }; if self.current_node.tree.focused_widget() == focus.as_ref().map(|w| w.node_id) { break; } focus_changes += 1; self.pending_state.focus = focus.and_then(|mut focus| loop { - if focus - .lock() - .as_widget() - .accept_focus(&mut self.for_other(&focus)) + let mut focus_context = self.for_other(&focus); + let accept_focus = focus_context.enabled() + && focus.lock().as_widget().accept_focus(&mut focus_context); + drop(focus_context); + + if accept_focus { + break Some(focus.id()); + } else if let Some(next_focus) = + focus.explicit_focus_target(self.pending_state.focus_is_advancing) { - break Some(focus); - } else if let Some(next_focus) = focus.next_focus() { focus = next_focus; } else { - break self.next_focus_after(focus, VisualOrder::left_to_right()); + break self.next_focus_after(focus, self.pending_state.focus_is_advancing); } }); - let new = match self - .current_node - .tree - .focus(self.pending_state.focus.as_ref()) - { + let new = match self.current_node.tree.focus(self.pending_state.focus) { Ok(old) => { if let Some(old) = old { let mut old_context = self.for_other(&old); @@ -253,7 +268,11 @@ impl<'context, 'window> EventContext<'context, 'window> { Err(()) => false, }; if new { - if let Some(focus) = self.pending_state.focus.clone() { + if let Some(focus) = self + .pending_state + .focus + .and_then(|w| self.current_node.tree.widget(w)) + { focus.lock().as_widget().focus(&mut self.for_other(&focus)); } } else { @@ -264,22 +283,49 @@ impl<'context, 'window> EventContext<'context, 'window> { if focus_changes == MAX_ITERS { tracing::error!("focus change force stopped after {focus_changes} sequential changes"); } + + // Check that our hover widget still exists. If not, we should try to find a new one. + if let Some(hover) = self.current_node.tree.hovered_widget() { + if self.current_node.tree.widget_from_node(hover).is_none() { + self.update_hovered_widget(); + } + } } - fn next_focus_after( - &mut self, - mut focus: ManagedWidget, - order: VisualOrder, - ) -> Option { + pub(crate) fn update_hovered_widget(&mut self) { + self.cursor.widget = None; + if let Some(location) = self.cursor.location { + for widget in self.current_node.tree.widgets_under_point(location) { + let mut widget_context = self.for_other(&widget); + let Some(widget_layout) = widget_context.last_layout() else { + continue; + }; + let relative = location - widget_layout.origin; + + if widget_context.hit_test(relative) { + widget_context.hover(relative); + drop(widget_context); + self.cursor.widget = Some(widget.id()); + break; + } + } + } + + if self.cursor.widget.is_none() { + self.clear_hover(); + } + } + + fn next_focus_after(&mut self, mut focus: ManagedWidget, advance: bool) -> Option { // First, look within the current focus for any focusable children. let stop_at = focus.id(); - if let Some(focus) = self.next_focus_within(&focus, None, stop_at, order) { + if let Some(focus) = self.next_focus_within(&focus, None, stop_at, advance) { return Some(focus); } // Now, look for the next widget in each hierarchy let root = loop { - if let Some(focus) = self.next_focus_sibling(&focus, stop_at, order) { + if let Some(focus) = self.next_focus_sibling(&focus, stop_at, advance) { return Some(focus); } let Some(parent) = focus.parent() else { @@ -290,16 +336,16 @@ impl<'context, 'window> EventContext<'context, 'window> { // We've exhausted a forward scan, we can now start searching the final // parent, which is the root. - self.next_focus_within(&root, None, stop_at, order) + self.next_focus_within(&root, None, stop_at, advance) } fn next_focus_sibling( &mut self, focus: &ManagedWidget, stop_at: WidgetId, - order: VisualOrder, - ) -> Option { - self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, order) + advance: bool, + ) -> Option { + self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, advance) } /// Searches for the next focus inside of `focus`, returning `None` if @@ -310,10 +356,14 @@ impl<'context, 'window> EventContext<'context, 'window> { focus: &ManagedWidget, start_at: Option, stop_at: WidgetId, - order: VisualOrder, - ) -> Option { + advance: bool, + ) -> Option { + let mut visual_order = self.get(&LayoutOrder); + if !advance { + visual_order = visual_order.rev(); + } let mut children = focus - .visually_ordered_children(order) + .visually_ordered_children(visual_order) .into_iter() .peekable(); if let Some(start_at) = start_at { @@ -331,13 +381,15 @@ impl<'context, 'window> EventContext<'context, 'window> { break; } - if child - .lock() - .as_widget() - .accept_focus(&mut self.for_other(&child)) - { - return Some(child); - } else if let Some(focus) = self.next_focus_within(&child, None, stop_at, order) { + let mut child_context = self.for_other(&child); + let accept_focus = child_context.enabled() + && child.lock().as_widget().accept_focus(&mut child_context); + drop(child_context); + if accept_focus { + return Some(child.id()); + } else if let Some(next_focus) = self.widget().explicit_focus_target(advance) { + return Some(next_focus.id()); + } else if let Some(focus) = self.next_focus_within(&child, None, stop_at, advance) { return Some(focus); } } @@ -345,14 +397,31 @@ impl<'context, 'window> EventContext<'context, 'window> { None } - /// Advances the focus from this widget to the next widget in `direction`. + /// Advances the focus to the next widget after this widget in the + /// configured focus order. /// - /// This widget does not need to be focused. - pub fn advance_focus(&mut self, direction: VisualOrder) { - // TODO check to see if the current node has an explicit next_focus (or - // if we're going in the opposite direction, previous_focus). + /// To focus in the reverse order, use [`EventContext::return_focus()`]. + pub fn advance_focus(&mut self) { + self.move_focus(true); + } - self.pending_state.focus = self.next_focus_after(self.current_node.clone(), direction); + /// Returns the focus to the previous widget before this widget in the + /// configured fous order. + /// + /// To focus in the forward order, use [`EventContext::advance_focus()`]. + pub fn return_focus(&mut self) { + self.move_focus(false); + } + + fn move_focus(&mut self, advance: bool) { + if let Some(explicit_next_focus) = self.current_node.explicit_focus_target(advance) { + self.for_other(&explicit_next_focus).focus(); + } else { + self.pending_state.focus = self.next_focus_after(self.current_node.clone(), advance); + } + // It is important to set focus-is_advancing after `focus()` because it + // sets it to `true` explicitly. + self.pending_state.focus_is_advancing = advance; } } @@ -453,7 +522,7 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' /// Strokes an outline around this widget's contents. pub fn stroke_outline(&mut self, color: Color, options: StrokeOptions) where - Unit: ScreenScale, + Unit: ScreenScale, { let visible_rect = Rect::from(self.gfx.region().size - (Px(1), Px(1))); let focus_ring = @@ -572,14 +641,23 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> LayoutContext<'context, 'window, 'cl /// context's widget and returns the result. pub fn layout(&mut self, available_space: Size) -> Size { if self.persist_layout { - self.graphics.current_node.reset_child_layouts(); + if let Some(cached) = self.graphics.current_node.begin_layout(available_space) { + return cached; + } } - self.graphics + let result = self + .graphics .current_node .clone() .lock() .as_widget() - .layout(available_space, self) + .layout(available_space, self); + if self.persist_layout { + self.graphics + .current_node + .persist_layout(available_space, result); + } + result } /// Sets the layout for `child` to `layout`. @@ -664,38 +742,49 @@ impl<'window> AsEventContext<'window> for GraphicsContext<'_, 'window, '_, '_, ' /// specific widget. pub struct WidgetContext<'context, 'window> { current_node: ManagedWidget, - redraw_status: &'context RedrawStatus, + redraw_status: &'context InvalidationStatus, window: &'context mut RunningWindow<'window>, theme: Cow<'context, ThemePair>, + cursor: &'context mut CursorState, pending_state: PendingState<'context>, - theme_mode: ThemeMode, effective_styles: Styles, + cache: WidgetCacheKey, } impl<'context, 'window> WidgetContext<'context, 'window> { pub(crate) fn new( current_node: ManagedWidget, - redraw_status: &'context RedrawStatus, + redraw_status: &'context InvalidationStatus, theme: &'context ThemePair, window: &'context mut RunningWindow<'window>, theme_mode: ThemeMode, + cursor: &'context mut CursorState, ) -> Self { + let enabled = current_node.enabled(&WindowHandle { + kludgine: window.handle(), + redraw_status: redraw_status.clone(), + }); Self { pending_state: PendingState::Owned(PendingWidgetState { focus: current_node .tree .focused_widget() - .and_then(|id| current_node.tree.widget_from_node(id)), + .and_then(|id| current_node.tree.widget_from_node(id).map(|w| w.id())), active: current_node .tree .active_widget() - .and_then(|id| current_node.tree.widget_from_node(id)), + .and_then(|id| current_node.tree.widget_from_node(id).map(|w| w.id())), + focus_is_advancing: false, }), effective_styles: current_node.effective_styles(), + cache: WidgetCacheKey { + theme_mode, + enabled, + }, + cursor, current_node, redraw_status, theme: Cow::Borrowed(theme), - theme_mode, window, } } @@ -708,8 +797,9 @@ impl<'context, 'window> WidgetContext<'context, 'window> { window: &mut *self.window, theme: Cow::Borrowed(self.theme.as_ref()), pending_state: self.pending_state.borrowed(), - theme_mode: self.theme_mode, + cache: self.cache, effective_styles: self.effective_styles.clone(), + cursor: &mut *self.cursor, } } @@ -732,20 +822,30 @@ impl<'context, 'window> WidgetContext<'context, 'window> { let theme_mode = if let Some(mode) = theme_mode { mode.get_tracked(self) } else { - self.theme_mode + self.cache.theme_mode }; WidgetContext { effective_styles, + cache: WidgetCacheKey { + theme_mode, + enabled: current_node.enabled(&self.handle()), + }, current_node, redraw_status: self.redraw_status, window: &mut *self.window, theme, pending_state: self.pending_state.borrowed(), - theme_mode, + cursor: &mut *self.cursor, } }) } + /// Returns true if this widget is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + self.cache.enabled + } + pub(crate) fn parent(&self) -> Option { self.current_node.parent() } @@ -755,6 +855,11 @@ impl<'context, 'window> WidgetContext<'context, 'window> { value.redraw_when_changed(self.handle()); } + /// Ensures that this widget will be redrawn when `value` has been updated. + pub fn invalidate_when_changed(&self, value: &Dynamic) { + value.invalidate_when_changed(self.handle(), self.current_node.id()); + } + /// Returns the last layout of this widget. #[must_use] pub fn last_layout(&self) -> Option> { @@ -766,7 +871,8 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Widget events relating to focus changes are deferred until after the all /// contexts for the currently firing event are dropped. pub fn focus(&mut self) { - self.pending_state.focus = Some(self.current_node.clone()); + self.pending_state.focus_is_advancing = true; + self.pending_state.focus = Some(self.current_node.id()); } pub(crate) fn clear_focus(&mut self) { @@ -796,16 +902,11 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Widget events relating to activation changes are deferred until after /// the all contexts for the currently firing event are dropped. pub fn activate(&mut self) -> bool { - if self - .pending_state - .active - .as_ref() - .map_or(true, |active| active != &self.current_node) - { - self.pending_state.active = Some(self.current_node.clone()); - true - } else { + if self.pending_state.active == Some(self.current_node.id()) { false + } else { + self.pending_state.active = Some(self.current_node.id()); + true } } @@ -832,7 +933,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns true if this widget is currently the active widget. #[must_use] pub fn active(&self) -> bool { - self.pending_state.active.as_ref() == Some(&self.current_node) + self.pending_state.active == Some(self.current_node.id()) } /// Returns true if this widget is currently hovered, even if the cursor is @@ -851,7 +952,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns true if this widget is currently focused for user input. #[must_use] pub fn focused(&self) -> bool { - self.pending_state.focus.as_ref() == Some(&self.current_node) + self.pending_state.focus == Some(self.current_node.id()) } /// Returns true if this widget is the target to activate when the user @@ -945,7 +1046,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns the current theme in either light or dark mode. #[must_use] pub fn theme(&self) -> &Theme { - match self.theme_mode { + match self.cache.theme_mode { ThemeMode::Light => &self.theme.light, ThemeMode::Dark => &self.theme.dark, } @@ -954,16 +1055,41 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns the opposite theme of [`Self::theme()`]. #[must_use] pub fn inverse_theme(&self) -> &Theme { - match self.theme_mode { + match self.cache.theme_mode { ThemeMode::Light => &self.theme.dark, ThemeMode::Dark => &self.theme.light, } } + + /// Returns a key that can be checked to see if a widget should invalidate + /// caches it stores. + #[must_use] + pub fn cache_key(&self) -> WidgetCacheKey { + self.cache + } } +#[derive(Clone)] pub(crate) struct WindowHandle { kludgine: kludgine::app::WindowHandle, - redraw_status: RedrawStatus, + redraw_status: InvalidationStatus, +} + +impl Eq for WindowHandle {} + +impl PartialEq for WindowHandle { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq( + &self.redraw_status.invalidated, + &other.redraw_status.invalidated, + ) + } +} + +impl Hash for WindowHandle { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.redraw_status.invalidated).hash(state); + } } impl WindowHandle { @@ -972,6 +1098,12 @@ impl WindowHandle { let _result = self.kludgine.send(WindowCommand::Redraw); } } + + pub fn invalidate(&self, widget: WidgetId) { + if self.redraw_status.invalidate(widget) { + self.redraw(); + } + } } impl dyn AsEventContext<'_> {} @@ -1005,8 +1137,9 @@ enum PendingState<'a> { #[derive(Default)] struct PendingWidgetState { - focus: Option, - active: Option, + focus_is_advancing: bool, + focus: Option, + active: Option, } impl PendingState<'_> { @@ -1036,11 +1169,12 @@ impl DerefMut for PendingState<'_> { } #[derive(Default, Clone)] -pub(crate) struct RedrawStatus { +pub(crate) struct InvalidationStatus { refresh_sent: Arc, + invalidated: Arc>>, } -impl RedrawStatus { +impl InvalidationStatus { pub fn should_send_refresh(&self) -> bool { self.refresh_sent .compare_exchange(false, true, Ordering::Release, Ordering::Acquire) @@ -1050,6 +1184,15 @@ impl RedrawStatus { pub fn refresh_received(&self) { self.refresh_sent.store(false, Ordering::Release); } + + pub fn invalidate(&self, widget: WidgetId) -> bool { + let mut invalidated = self.invalidated.lock().ignore_poison(); + invalidated.insert(widget) + } + + pub fn invalidations(&self) -> MutexGuard<'_, Set> { + self.invalidated.lock().ignore_poison() + } } /// A type chat can convert to a [`ManagedWidget`] through a [`WidgetContext`]. @@ -1113,3 +1256,22 @@ impl MapManagedWidget for ManagedWidget { map(self) } } + +/// An type that contains information about the state of a widget. +/// +/// This value can be stored and compared in future widget events. If the cache +/// keys are not equal, the widget should clear all caches. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct WidgetCacheKey { + theme_mode: ThemeMode, + enabled: bool, +} + +impl Default for WidgetCacheKey { + fn default() -> Self { + Self { + theme_mode: ThemeMode::default().inverse(), + enabled: false, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7309d9b0e..908878ff7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ use std::ops::Sub; pub use kludgine; use kludgine::app::winit::error::EventLoopError; use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoUnsigned, ScreenUnit}; +use kludgine::figures::{Fraction, ScreenUnit}; pub use names::Name; pub use utils::{Lazy, WithClone}; @@ -60,7 +60,7 @@ impl ConstraintLimit { where Unit: ScreenUnit, { - let measured = measured.into_px(scale).into_unsigned(); + let measured = measured.into_upx(scale); match self { ConstraintLimit::Known(size) => size.max(measured), ConstraintLimit::ClippedAfter(_) => measured, diff --git a/src/styles.rs b/src/styles.rs index 650e9d632..ddc560ed9 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use ahash::AHashMap; use kludgine::figures::units::{Lp, Px, UPx}; -use kludgine::figures::{Fraction, IntoUnsigned, Rect, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Rect, ScreenScale, Size}; use kludgine::Color; use palette::{IntoColor, Okhsl, OklabHue, Srgb}; @@ -86,8 +86,17 @@ impl Styles { self.0 .get(&name) .and_then(|component| { - component.redraw_when_changed(context); - ::try_from_component(component.get()).ok() + match ::try_from_component(component.get()) { + Ok(value) => { + if value.requires_invalidation() { + component.invalidate_when_changed(context); + } else { + component.redraw_when_changed(context); + } + Some(value) + } + Err(_) => None, + } }) .unwrap_or_else(|| component.default_value(context)) } @@ -219,6 +228,12 @@ impl TryFrom for Color { } } +impl RequireInvalidation for Color { + fn requires_invalidation(&self) -> bool { + false + } +} + impl From for Component { fn from(value: Dimension) -> Self { Self::Dimension(value) @@ -236,6 +251,12 @@ impl TryFrom for Dimension { } } +impl RequireInvalidation for Dimension { + fn requires_invalidation(&self) -> bool { + true + } +} + impl From for Component { fn from(value: Px) -> Self { Self::from(Dimension::from(value)) @@ -253,6 +274,12 @@ impl TryFrom for Px { } } +impl RequireInvalidation for Px { + fn requires_invalidation(&self) -> bool { + true + } +} + impl From for Component { fn from(value: Lp) -> Self { Self::from(Dimension::from(value)) @@ -270,6 +297,12 @@ impl TryFrom for Lp { } } +impl RequireInvalidation for Lp { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A 1-dimensional measurement that may be automatically calculated. #[derive(Debug, Clone, Copy)] pub enum FlexibleDimension { @@ -343,6 +376,7 @@ impl From for Dimension { impl ScreenScale for Dimension { type Lp = Lp; type Px = Px; + type UPx = UPx; fn into_px(self, scale: kludgine::figures::Fraction) -> Px { match self { @@ -365,6 +399,17 @@ impl ScreenScale for Dimension { fn from_lp(lp: Lp, _scale: kludgine::figures::Fraction) -> Self { Self::from(lp) } + + fn into_upx(self, scale: Fraction) -> Self::UPx { + match self { + Dimension::Px(px) => px.into_unsigned(), + Dimension::Lp(lp) => lp.into_upx(scale), + } + } + + fn from_upx(px: Self::UPx, _scale: Fraction) -> Self { + Self::from(px.into_signed()) + } } impl Mul for Dimension { @@ -436,10 +481,10 @@ impl DimensionRange { #[must_use] pub fn clamp(&self, mut size: UPx, scale: Fraction) -> UPx { if let Some(min) = self.minimum() { - size = size.max(min.into_px(scale).into_unsigned()); + size = size.max(min.into_upx(scale)); } if let Some(max) = self.maximum() { - size = size.min(max.into_px(scale).into_unsigned()); + size = size.min(max.into_upx(scale)); } size } @@ -563,6 +608,12 @@ impl TryFrom for DimensionRange { } } +impl RequireInvalidation for DimensionRange { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A custom component value. #[derive(Debug, Clone)] pub struct CustomComponent(Arc); @@ -571,7 +622,7 @@ impl CustomComponent { /// Wraps an arbitrary value so that it can be used as a [`Component`]. pub fn new(value: T) -> Self where - T: RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, + T: RequireInvalidation + RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, { Self(Arc::new(value)) } @@ -587,6 +638,12 @@ impl CustomComponent { } } +impl RequireInvalidation for CustomComponent { + fn requires_invalidation(&self) -> bool { + self.0.requires_invalidation() + } +} + impl ComponentType for CustomComponent { fn into_component(self) -> Component { Component::Custom(self) @@ -600,13 +657,13 @@ impl ComponentType for CustomComponent { } } -trait AnyComponent: Send + Sync + RefUnwindSafe + UnwindSafe + Debug { +trait AnyComponent: RequireInvalidation + Send + Sync + RefUnwindSafe + UnwindSafe + Debug { fn as_any(&self) -> &dyn Any; } impl AnyComponent for T where - T: RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, + T: RequireInvalidation + RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, { fn as_any(&self) -> &dyn Any { self @@ -654,8 +711,20 @@ pub trait ComponentDefinition: NamedComponent { fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType; } +/// Describes whether a type should invalidate a widget. +pub trait RequireInvalidation { + /// Gooey tracks two different states: + /// + /// - Whether to repaint the window + /// - Whether to relayout a widget + /// + /// If a value change of `self` may require a relayout, this should return + /// true. + fn requires_invalidation(&self) -> bool; +} + /// A type that can be converted to and from [`Component`]. -pub trait ComponentType: Sized { +pub trait ComponentType: RequireInvalidation + Sized { /// Returns this type, wrapped in a [`Component`]. fn into_component(self) -> Component; /// Attempts to extract this type from `component`. If `component` does not @@ -665,7 +734,7 @@ pub trait ComponentType: Sized { impl ComponentType for T where - T: Into + TryFrom, + T: RequireInvalidation + Into + TryFrom, { fn into_component(self) -> Component { self.into() @@ -1095,6 +1164,8 @@ pub struct ColorTheme { pub color: Color, /// The primary color, dimmed for de-emphasized or disabled content. pub color_dim: Color, + /// The primary color, brightened for highlighting content. + pub color_bright: Color, /// The color for content that sits atop the primary color. pub on_color: Color, /// The backgrond color for containers. @@ -1110,6 +1181,7 @@ impl ColorTheme { Self { color: source.color(40), color_dim: source.color(30), + color_bright: source.color(45), on_color: source.color(100), container: source.color(90), on_container: source.color(10), @@ -1122,6 +1194,7 @@ impl ColorTheme { Self { color: source.color(70), color_dim: source.color(60), + color_bright: source.color(75), on_color: source.color(10), container: source.color(30), on_container: source.color(90), @@ -1324,12 +1397,19 @@ impl ColorExt for Color { let (other_source, other_lightness) = self.into_source_and_lightness(); let lightness_delta = other_lightness.difference_between(check_lightness); + let average_lightness = ZeroToOne::new((*check_lightness + *other_lightness) / 2.); + let source_change = check_source.contrast_between(other_source); let other_alpha = ZeroToOne::new(self.alpha_f32()); let alpha_delta = check_alpha.difference_between(other_alpha); - ZeroToOne::new((*lightness_delta + *source_change + *alpha_delta) / 3.) + ZeroToOne::new( + (*lightness_delta + + *average_lightness * *source_change + + *average_lightness * *alpha_delta) + / 3., + ) } fn most_contrasting(self, others: &[Self]) -> Self @@ -1411,6 +1491,12 @@ impl TryFrom for VisualOrder { } } +impl RequireInvalidation for VisualOrder { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A horizontal direction. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum HorizontalOrder { @@ -1514,6 +1600,12 @@ impl TryFrom for FocusableWidgets { } } +impl RequireInvalidation for FocusableWidgets { + fn requires_invalidation(&self) -> bool { + false + } +} + /// A description of the level of depth a /// [`Container`](crate::widgets::Container) is nested at. #[derive(Default, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] @@ -1563,6 +1655,12 @@ impl TryFrom for ContainerLevel { } } +impl RequireInvalidation for ContainerLevel { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A builder of [`ColorScheme`]s. #[derive(Clone, Copy, Debug)] pub struct ColorSchemeBuilder { @@ -1583,27 +1681,21 @@ pub struct ColorSchemeBuilder { /// The neutral variant color of the scheme. If not provided, a mostly /// desaturated variation of the primary color will be used. pub neutral_variant: Option, - hue_shift: f32, + hue_shift: OklabHue, } impl ColorSchemeBuilder { - /// Returns a builder for the provided hue, in degrees. - #[must_use] - pub fn from_hue(hue: impl Into) -> Self { - Self::new(ColorSource::new(hue, 0.8)) - } - /// Returns a builder for the provided primary color. #[must_use] - pub fn new(primary: ColorSource) -> Self { + pub fn new(primary: impl ProtoColor) -> Self { Self { - primary, + primary: primary.into_source(ZeroToOne::new(0.8)), secondary: None, tertiary: None, error: None, neutral: None, neutral_variant: None, - hue_shift: 30., + hue_shift: OklabHue::new(30.), } } @@ -1615,7 +1707,8 @@ impl ColorSchemeBuilder { } fn generate_tertiary(&self, secondary: ColorSource) -> ColorSource { - let hue_shift = (secondary.hue - self.primary.hue).into_degrees().signum() * self.hue_shift; + let hue_shift = (secondary.hue - self.primary.hue).into_degrees().signum() + * self.hue_shift.into_degrees(); ColorSource { hue: self.primary.hue - hue_shift, saturation: self.primary.saturation / 3., @@ -1644,10 +1737,59 @@ impl ColorSchemeBuilder { fn generate_neutral_variant(&self) -> ColorSource { ColorSource { hue: self.primary.hue, - saturation: ZeroToOne::new(0.1), + saturation: self.primary.saturation / 10., } } + /// Sets the secondary color and returns self. + /// + /// If `secondary` doesn't specify a saturation, a saturation value that is + /// 50% of the primary saturation will be picked. + #[must_use] + pub fn secondary(mut self, secondary: impl ProtoColor) -> Self { + self.secondary = Some(secondary.into_source(self.primary.saturation / 2.)); + self + } + + /// Sets the tertiary color and returns self. + /// + /// If `tertiary` doesn't specify a saturation, a saturation value that is + /// 33% of the primary saturation will be picked. + #[must_use] + pub fn tertiary(mut self, tertiary: impl ProtoColor) -> Self { + self.secondary = Some(tertiary.into_source(self.primary.saturation / 3.)); + self + } + + /// Sets the neutral color and returns self. + /// + /// If `neutral` doesn't specify a saturation, a saturation of 1%. + #[must_use] + pub fn neutral(mut self, neutral: impl ProtoColor) -> Self { + self.neutral = Some(neutral.into_source(0.01)); + self + } + + /// Sets the neutral color and returns self. + /// + /// If `neutral_variant` doesn't specify a saturation, a saturation value + /// that is 10% of the primary saturation will be picked. + #[must_use] + pub fn neutral_variant(mut self, neutral_variant: impl ProtoColor) -> Self { + self.neutral_variant = Some(neutral_variant.into_source(self.primary.saturation / 10.)); + self + } + + /// Sets the amount the hue component is shifted when auto-generating colors + /// to fill in the palette. + /// + /// The default hue shift is 30 degrees. + #[must_use] + pub fn hue_shift(mut self, hue_shift: impl Into) -> Self { + self.hue_shift = hue_shift.into(); + self + } + /// Builds a color scheme from the provided colors, generating any /// unspecified colors. #[must_use] @@ -1671,6 +1813,69 @@ impl ColorSchemeBuilder { } } +/// A type that can be interpretted as a hue or hue and saturation. +pub trait ProtoColor: Sized { + /// Returns the hue of this prototype color. + #[must_use] + fn hue(&self) -> OklabHue; + /// Returns the saturation of this prototype color, if available. + #[must_use] + fn saturation(&self) -> Option; + + /// Returns a color source built from this prototype color + #[must_use] + fn into_source(self, saturation_if_not_provided: impl Into) -> ColorSource { + let saturation = self + .saturation() + .unwrap_or_else(|| saturation_if_not_provided.into()); + ColorSource::new(self.hue(), saturation) + } +} + +impl ProtoColor for f32 { + fn hue(&self) -> OklabHue { + (*self).into() + } + + fn saturation(&self) -> Option { + None + } +} + +impl ProtoColor for OklabHue { + fn hue(&self) -> OklabHue { + *self + } + + fn saturation(&self) -> Option { + None + } +} + +impl ProtoColor for ColorSource { + fn hue(&self) -> OklabHue { + self.hue + } + + fn saturation(&self) -> Option { + Some(self.saturation) + } +} + +impl ProtoColor for (Hue, Saturation) +where + Hue: Into + Copy, + Saturation: Into + Copy, +{ + fn hue(&self) -> OklabHue { + self.0.into() + } + + fn saturation(&self) -> Option { + Some(self.1.into()) + } +} + /// A color scheme for a Gooey application. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ColorScheme { @@ -1691,20 +1896,14 @@ pub struct ColorScheme { impl ColorScheme { /// Returns a generated color scheme based on a `primary` color. #[must_use] - pub fn from_primary(primary: ColorSource) -> Self { + pub fn from_primary(primary: impl ProtoColor) -> Self { ColorSchemeBuilder::new(primary).build() } - - /// Returns a generated color scheme based on a `primary` hue, in degrees. - #[must_use] - pub fn from_primary_hue(hue: impl Into) -> Self { - ColorSchemeBuilder::from_hue(hue).build() - } } impl Default for ColorScheme { fn default() -> Self { - Self::from_primary_hue(138.5) + Self::from_primary(138.5) } } diff --git a/src/styles/components.rs b/src/styles/components.rs index 2e95ef4c2..ae50004bf 100644 --- a/src/styles/components.rs +++ b/src/styles/components.rs @@ -96,6 +96,8 @@ define_components! { SurfaceColor(Color, "surface_color", .surface.color) /// The [`Color`] to use when rendering text. TextColor(Color, "text_color", .surface.on_color) + /// The [`Color`] to use when rendering text in a more subdued tone. + TextColorVariant(Color, "text_color_variant", .surface.on_color_variant) /// A [`Color`] to be used as a highlight color. HighlightColor(Color,"highlight_color",.primary.color.with_alpha(128)) /// Intrinsic, uniform padding for a widget. @@ -122,6 +124,8 @@ define_components! { AutoFocusableControls(FocusableWidgets, "focus", FocusableWidgets::default()) /// A [`Color`] to be used as the background color of a widget. WidgetBackground(Color, "widget_backgrond_color", Color::CLEAR_WHITE) + /// A [`Color`] to be used to accent a widget. + WidgetAccentColor(Color, "widget_accent_color", .primary.color) /// A [`Color`] to be used as an outline color. OutlineColor(Color, "outline_color", .surface.outline) /// A [`Color`] to be used as an outline color. diff --git a/src/tick.rs b/src/tick.rs index b1978da2e..b2d8d2b2e 100644 --- a/src/tick.rs +++ b/src/tick.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use kludgine::app::winit::event::KeyEvent; @@ -9,6 +9,7 @@ use kludgine::figures::Point; use kludgine::figures::units::Px; use crate::context::WidgetContext; +use crate::utils::IgnorePoison; use crate::value::Dynamic; use crate::widget::{EventHandling, HANDLED, IGNORED}; @@ -138,9 +139,7 @@ struct TickData { impl TickData { fn state(&self) -> MutexGuard<'_, TickState> { - self.state - .lock() - .map_or_else(PoisonError::into_inner, |g| g) + self.state.lock().ignore_poison() } } @@ -189,10 +188,7 @@ where while state.keep_running { let current_frame = data.rendered_frame.load(Ordering::Acquire); if state.frame == current_frame { - state = data - .sync - .wait(state) - .map_or_else(PoisonError::into_inner, |g| g); + state = data.sync.wait(state).ignore_poison(); } else { break; } diff --git a/src/tree.rs b/src/tree.rs index 58c5e719b..afdf43f0f 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,15 +1,18 @@ use std::mem; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex}; use ahash::AHashMap; use alot::{LotId, Lots}; -use kludgine::figures::units::Px; -use kludgine::figures::{Point, Rect}; +use kludgine::figures::units::{Px, UPx}; +use kludgine::figures::{Point, Rect, Size}; +use crate::context::WindowHandle; use crate::styles::{Styles, ThemePair, VisualOrder}; +use crate::utils::IgnorePoison; use crate::value::Value; use crate::widget::{ManagedWidget, WidgetId, WidgetInstance}; use crate::window::ThemeMode; +use crate::ConstraintLimit; #[derive(Clone, Default)] pub struct Tree { @@ -22,7 +25,7 @@ impl Tree { widget: WidgetInstance, parent: Option<&ManagedWidget>, ) -> ManagedWidget { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let id = widget.id(); let (effective_styles, parent_id) = if let Some(parent) = parent { ( @@ -36,6 +39,7 @@ impl Tree { widget: widget.clone(), children: Vec::new(), parent: parent_id, + last_layout_query: None, layout: None, associated_styles: None, effective_styles, @@ -53,12 +57,8 @@ impl Tree { let parent = &mut data.nodes[parent]; parent.children.push(node_id); } - if let Some(next_focus) = widget - .next_focus() - .and_then(|id| data.nodes_by_id.get(&id)) - .copied() - { - data.previous_focuses.insert(next_focus, node_id); + if let Some(next_focus) = widget.next_focus() { + data.previous_focuses.insert(next_focus, id); } ManagedWidget { node_id, @@ -68,7 +68,7 @@ impl Tree { } pub fn remove_child(&self, child: &ManagedWidget, parent: &ManagedWidget) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.remove_child(child.node_id, parent.node_id); if child.widget.is_default() { @@ -80,7 +80,7 @@ impl Tree { } pub(crate) fn set_layout(&self, widget: LotId, rect: Rect) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let node = &mut data.nodes[widget]; node.layout = Some(rect); @@ -98,26 +98,65 @@ impl Tree { } pub(crate) fn layout(&self, widget: LotId) -> Option> { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes.get(widget).and_then(|widget| widget.layout) } - pub(crate) fn reset_render_order(&self) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - data.render_order.clear(); + pub(crate) fn new_frame(&self, invalidations: impl IntoIterator) { + let mut data = self.data.lock().ignore_poison(); + data.render_info.clear(); + + for id in invalidations { + let Some(id) = data.nodes_by_id.get(&id).copied() else { + continue; + }; + + data.invalidate(id, true); + } } pub(crate) fn note_widget_rendered(&self, widget: LotId) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - data.render_order.push(widget); + let mut data = self.data.lock().ignore_poison(); + let Some(layout) = data.nodes.get(widget).and_then(|node| node.layout) else { + return; + }; + data.render_info.push(widget, layout); } - pub(crate) fn reset_child_layouts(&self, parent: LotId) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - let children = data.nodes[parent].children.clone(); + pub(crate) fn begin_layout( + &self, + parent: LotId, + constraints: Size, + ) -> Option> { + let mut data = self.data.lock().ignore_poison(); + + let node = &mut data.nodes[parent]; + if let Some(cached_layout) = &node.last_layout_query { + if constraints.width.max() < cached_layout.constraints.width.max() + && constraints.height.max() < cached_layout.constraints.height.max() + { + return Some(cached_layout.size); + } + + node.last_layout_query = None; + } + + let children = node.children.clone(); for child in children { - data.nodes.get_mut(child).expect("missing widget").layout = None; + data.invalidate(child, false); } + + None + } + + pub(crate) fn persist_layout( + &self, + id: LotId, + constraints: Size, + size: Size, + ) { + let mut data = self.data.lock().ignore_poison(); + data.nodes[id].last_layout_query = Some(CachedLayoutQuery { constraints, size }); } pub(crate) fn visually_ordered_children( @@ -125,7 +164,7 @@ impl Tree { parent: LotId, order: VisualOrder, ) -> Vec { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let node = &data.nodes[parent]; let mut unordered = node.children.clone(); let mut ordered = Vec::::with_capacity(unordered.len()); @@ -182,89 +221,103 @@ impl Tree { } pub(crate) fn effective_styles(&self, id: LotId) -> Styles { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes[id].effective_styles.clone() } pub(crate) fn hover(&self, new_hover: Option<&ManagedWidget>) -> HoverResults { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let hovered = new_hover .map(|new_hover| data.widget_hierarchy(new_hover.node_id, self)) .unwrap_or_default(); - let unhovered = match data.update_tracked_widget(new_hover, self, |data| &mut data.hover) { - Ok(Some(old_hover)) => { - let mut old_hovered = data.widget_hierarchy(old_hover.node_id, self); - // For any widgets that were shared, remove them, as they don't - // need to have their events fired again. - let mut new_index = 0; - while !old_hovered.is_empty() && old_hovered.get(0) == hovered.get(new_index) { - old_hovered.remove(0); - new_index += 1; + let unhovered = + match data.update_tracked_widget(new_hover.map(ManagedWidget::id), self, |data| { + &mut data.hover + }) { + Ok(Some(old_hover)) => { + let mut old_hovered = data.widget_hierarchy(old_hover.node_id, self); + // For any widgets that were shared, remove them, as they don't + // need to have their events fired again. + let mut new_index = 0; + while !old_hovered.is_empty() && old_hovered.get(0) == hovered.get(new_index) { + old_hovered.remove(0); + new_index += 1; + } + old_hovered } - old_hovered - } - _ => Vec::new(), - }; + _ => Vec::new(), + }; HoverResults { unhovered, hovered } } - pub fn focus(&self, new_focus: Option<&ManagedWidget>) -> Result, ()> { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + pub fn focus(&self, new_focus: Option) -> Result, ()> { + let mut data = self.data.lock().ignore_poison(); data.update_tracked_widget(new_focus, self, |data| &mut data.focus) } + pub fn previous_focus(&self, focus: WidgetId) -> Option { + let data = self.data.lock().ignore_poison(); + let previous = *data.previous_focuses.get(&focus)?; + data.widget_from_id(previous, self) + } + pub fn activate( &self, new_active: Option<&ManagedWidget>, ) -> Result, ()> { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - data.update_tracked_widget(new_active, self, |data| &mut data.active) + let mut data = self.data.lock().ignore_poison(); + data.update_tracked_widget(new_active.map(ManagedWidget::id), self, |data| { + &mut data.active + }) } pub fn widget(&self, id: WidgetId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.widget_from_id(id, self) } pub(crate) fn widget_from_node(&self, id: LotId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.widget_from_node(id, self) } + pub(crate) fn is_enabled(&self, mut id: LotId, context: &WindowHandle) -> bool { + let data = self.data.lock().ignore_poison(); + loop { + let Some(node) = data.nodes.get(id) else { + return false; + }; + + if !node.widget.enabled(context) { + return false; + } + + let Some(parent) = node.parent else { break }; + + id = parent; + } + + true + } + pub(crate) fn active_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .active + self.data.lock().ignore_poison().active } pub(crate) fn hovered_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .hover + self.data.lock().ignore_poison().hover } pub(crate) fn default_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .defaults - .last() - .copied() + self.data.lock().ignore_poison().defaults.last().copied() } pub(crate) fn escape_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .escapes - .last() - .copied() + self.data.lock().ignore_poison().escapes.last().copied() } pub(crate) fn is_hovered(&self, id: LotId) -> bool { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let mut search = data.hover; while let Some(hovered) = search { if hovered == id { @@ -277,42 +330,31 @@ impl Tree { } pub(crate) fn focused_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .focus + self.data.lock().ignore_poison().focus } - pub(crate) fn widgets_at_point(&self, point: Point) -> Vec { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - let mut hits = Vec::new(); - for id in data.render_order.iter().rev() { - if let Some(last_rendered) = data.nodes.get(*id).and_then(|widget| widget.layout) { - if last_rendered.contains(point) { - hits.push(data.widget_from_node(*id, self).expect("just accessed")); - } - } - } - hits + pub(crate) fn widgets_under_point(&self, point: Point) -> Vec { + let data = self.data.lock().ignore_poison(); + data.render_info.widgets_under_point(point, &data, self) } pub(crate) fn parent(&self, id: LotId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes.get(id).expect("missing widget").parent } pub(crate) fn attach_styles(&self, id: LotId, styles: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.attach_styles(id, styles); } pub(crate) fn attach_theme(&self, id: LotId, theme: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.nodes.get_mut(id).expect("missing widget").theme = Some(theme); } pub(crate) fn attach_theme_mode(&self, id: LotId, theme: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.nodes.get_mut(id).expect("missing widget").theme_mode = Some(theme); } @@ -320,7 +362,7 @@ impl Tree { &self, id: LotId, ) -> (Styles, Option>, Option>) { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let node = data.nodes.get(id).expect("missing widget"); ( node.effective_styles.clone(), @@ -328,6 +370,13 @@ impl Tree { node.theme_mode.clone(), ) } + + pub fn invalidate(&self, id: LotId, include_hierarchy: bool) { + self.data + .lock() + .ignore_poison() + .invalidate(id, include_hierarchy); + } } pub(crate) struct HoverResults { @@ -344,8 +393,8 @@ struct TreeData { hover: Option, defaults: Vec, escapes: Vec, - render_order: Vec, - previous_focuses: AHashMap, + render_info: RenderInfo, + previous_focuses: AHashMap, } impl TreeData { @@ -408,17 +457,16 @@ impl TreeData { parent.children.remove(index); let mut detached_nodes = removed_node.children; - if let Some(next_focus) = removed_node - .widget - .next_focus() - .and_then(|id| self.nodes_by_id.get(&id)) - { - self.previous_focuses.remove(next_focus); + if let Some(next_focus) = removed_node.widget.next_focus() { + self.previous_focuses.remove(&next_focus); } while let Some(node) = detached_nodes.pop() { let mut node = self.nodes.remove(node).expect("detached node missing"); self.nodes_by_id.remove(&node.widget.id()); + if let Some(next_focus) = node.widget.next_focus() { + self.previous_focuses.remove(&next_focus); + } detached_nodes.append(&mut node.children); } } @@ -440,12 +488,13 @@ impl TreeData { fn update_tracked_widget( &mut self, - new_widget: Option<&ManagedWidget>, + new_widget: Option, tree: &Tree, property: impl FnOnce(&mut Self) -> &mut Option, ) -> Result, ()> { + let new_widget = new_widget.and_then(|w| self.widget_from_id(w, tree)); match ( - mem::replace(property(self), new_widget.map(|w| w.node_id)), + mem::replace(property(self), new_widget.as_ref().map(|w| w.node_id)), new_widget, ) { (Some(old_widget), Some(new_widget)) if old_widget == new_widget.node_id => Err(()), @@ -453,17 +502,87 @@ impl TreeData { (None, _) => Ok(None), } } + + fn invalidate(&mut self, id: LotId, include_hierarchy: bool) { + let mut node = &mut self.nodes[id]; + while node.layout.is_some() { + node.layout = None; + node.last_layout_query = None; + + let (true, Some(parent)) = (include_hierarchy, node.parent) else { + break; + }; + node = &mut self.nodes[parent]; + } + } +} + +#[derive(Default)] +struct RenderInfo { + order: Vec, +} + +impl RenderInfo { + pub fn push(&mut self, node: LotId, region: Rect) { + let area = RenderArea::new(node, region); + self.order.push(area); + } + + pub fn clear(&mut self) { + self.order.clear(); + } + + fn widgets_under_point( + &self, + point: Point, + tree_data: &TreeData, + tree: &Tree, + ) -> Vec { + // We pessimistically allocate a vector as if all widgets match, up to a + // reasonable limit. This should ensure minimal allocations in all but + // extreme circumstances where widgets are nested with a significant + // amount of depth. + let mut hits = Vec::with_capacity(self.order.len().min(256)); + for area in self.order.iter().rev() { + if area.min.x <= point.x + && area.min.y <= point.y + && area.max.x >= point.x + && area.max.y >= point.y + { + let Some(widget) = tree_data.widget_from_node(area.node, tree) else { + continue; + }; + hits.push(widget); + } + } + hits + } +} + +#[derive(Eq, PartialEq, Clone, Copy)] +struct RenderArea { + node: LotId, + min: Point, + max: Point, +} + +impl RenderArea { + fn new(node: LotId, area: Rect) -> Self { + let (min, max) = area.extents(); + Self { node, min, max } + } } -pub struct Node { - pub widget: WidgetInstance, - pub children: Vec, - pub parent: Option, - pub layout: Option>, - pub associated_styles: Option>, - pub effective_styles: Styles, - pub theme: Option>, - pub theme_mode: Option>, +struct Node { + widget: WidgetInstance, + children: Vec, + parent: Option, + layout: Option>, + last_layout_query: Option, + associated_styles: Option>, + effective_styles: Styles, + theme: Option>, + theme_mode: Option>, } impl Node { @@ -475,3 +594,8 @@ impl Node { effective_styles } } + +struct CachedLayoutQuery { + constraints: Size, + size: Size, +} diff --git a/src/utils.rs b/src/utils.rs index 8811dcc88..228efa7d3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use std::ops::Deref; -use std::sync::OnceLock; +use std::sync::{OnceLock, PoisonError}; use kludgine::app::winit::event::Modifiers; use kludgine::app::winit::keyboard::ModifiersState; @@ -129,3 +129,16 @@ impl Deref for Lazy { self.once.get_or_init(self.init) } } + +pub trait IgnorePoison { + type Unwrapped; + fn ignore_poison(self) -> Self::Unwrapped; +} + +impl IgnorePoison for Result> { + type Unwrapped = T; + + fn ignore_poison(self) -> Self::Unwrapped { + self.map_or_else(PoisonError::into_inner, |g| g) + } +} diff --git a/src/value.rs b/src/value.rs index 3a08a6a1d..c37875592 100644 --- a/src/value.rs +++ b/src/value.rs @@ -5,15 +5,19 @@ use std::fmt::{Debug, Display}; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::panic::AssertUnwindSafe; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, TryLockError}; +use std::str::FromStr; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, TryLockError}; use std::task::{Poll, Waker}; use std::thread::ThreadId; +use ahash::AHashSet; use intentional::Assert; use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; -use crate::utils::WithClone; +use crate::utils::{IgnorePoison, WithClone}; +use crate::widget::{WidgetId, WidgetInstance}; +use crate::widgets::{Input, Switcher}; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -30,15 +34,84 @@ impl Dynamic { generation: Generation::default(), }, callbacks: Vec::new(), - windows: Vec::new(), + windows: AHashSet::new(), readers: 0, wakers: Vec::new(), + widgets: AHashSet::new(), }), during_callback_state: Mutex::default(), sync: AssertUnwindSafe(Condvar::new()), })) } + /// Returns a new dynamic that has its contents linked with `self` by the + /// pair of mapping functions provided. + /// + /// When the returned dynamic is updated, `r_into_t` will be invoked. This + /// function accepts `&R` and can return `T`, or `Option`. If a value is + /// produced, `self` will be updated with the new value. + /// + /// When `self` is updated, `t_into_r` will be invoked. This function + /// accepts `&T` and can return `R` or `Option`. If a value is produced, + /// the returned dynamic will be updated with the new value. + /// + /// # Panics + /// + /// This function panics if calling `t_into_r` with the current contents of + /// the Dynamic produces a `None` value. This requirement is only for the + /// first invocation, and it is guaranteed to occur before this function + /// returns. + pub fn linked( + &self, + mut t_into_r: TIntoR, + mut r_into_t: RIntoT, + ) -> Dynamic + where + T: PartialEq + Send + 'static, + R: PartialEq + Send + 'static, + TIntoRResult: Into> + Send + 'static, + RIntoTResult: Into> + Send + 'static, + TIntoR: FnMut(&T) -> TIntoRResult + Send + 'static, + RIntoT: FnMut(&R) -> RIntoTResult + Send + 'static, + { + let initial_r = self + .map_ref(&mut t_into_r) + .into() + .expect("t_into_r must succeed with the current value"); + let r = Dynamic::new(initial_r); + r.with_clone(move |r| { + self.for_each(move |t| { + if let Some(update) = t_into_r(t).into() { + let _result = r.try_update(update); + } + }); + }); + + self.with_clone(|t| { + r.with_for_each(move |r| { + if let Some(update) = r_into_t(r).into() { + let _result = t.try_update(update); + } + }) + }) + } + + /// Creates a [linked](Self::linked) dynamic containing a `String`. + /// + /// When `self` is updated, [`ToString::to_string()`] will be called to + /// produce a new string value to store in the returned dynamic. + /// + /// When the returned dynamic is updated, [`str::parse`](std::str) is called + /// to produce a new `T`. If an error is returned, `self` will not be + /// updated. Otherwise, `self` will be updated with the produced value. + #[must_use] + pub fn linked_string(&self) -> Dynamic + where + T: ToString + FromStr + PartialEq + Send + 'static, + { + self.linked(ToString::to_string, |s: &String| s.parse().ok()) + } + /// Maps the contents with read-only access. /// /// # Panics @@ -158,6 +231,10 @@ impl Dynamic { self.0.redraw_when_changed(window); } + pub(crate) fn invalidate_when_changed(&self, window: WindowHandle, widget: WidgetId) { + self.0.invalidate_when_changed(window, widget); + } + /// Returns a clone of the currently contained value. /// /// # Panics @@ -181,7 +258,7 @@ impl Dynamic { /// This function panics if this value is already locked by the current /// thread. #[must_use] - pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T + pub fn get_tracking_refresh(&self, context: &WidgetContext<'_, '_>) -> T where T: Clone, { @@ -189,6 +266,23 @@ impl Dynamic { self.get() } + /// Returns a clone of the currently contained value. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_invalidate(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + context.invalidate_when_changed(self); + self.get() + } + /// Returns the currently stored value, replacing the current contents with /// `T::default()`. /// @@ -370,6 +464,15 @@ impl Dynamic { } } +impl Dynamic { + /// Returns a new [`Switcher`] widget whose contents is the value of this + /// dynamic. + #[must_use] + pub fn switcher(self) -> Switcher { + Switcher::new(self) + } +} + impl Default for Dynamic where T: Default, @@ -409,11 +512,7 @@ struct DynamicMutexGuard<'a, T> { impl<'a, T> Drop for DynamicMutexGuard<'a, T> { fn drop(&mut self) { - let mut during_state = self - .dynamic - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut during_state = self.dynamic.during_callback_state.lock().ignore_poison(); *during_state = None; drop(during_state); self.dynamic.sync.notify_all(); @@ -450,10 +549,7 @@ struct DynamicData { impl DynamicData { fn state(&self) -> Result, DeadlockError> { - let mut during_sync = self - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut during_sync = self.during_callback_state.lock().ignore_poison(); let current_thread_id = std::thread::current().id(); let guard = loop { @@ -466,10 +562,7 @@ impl DynamicData { return Err(DeadlockError) } Some(_) => { - during_sync = self - .sync - .wait(during_sync) - .map_or_else(PoisonError::into_inner, |g| g); + during_sync = self.sync.wait(during_sync).ignore_poison(); } None => break, } @@ -487,7 +580,12 @@ impl DynamicData { pub fn redraw_when_changed(&self, window: WindowHandle) { let mut state = self.state().expect("deadlocked"); - state.windows.push(window); + state.windows.insert(window); + } + + pub fn invalidate_when_changed(&self, window: WindowHandle, widget: WidgetId) { + let mut state = self.state().expect("deadlocked"); + state.widgets.insert((window, widget)); } pub fn get(&self) -> Result, DeadlockError> @@ -579,7 +677,8 @@ impl Display for DeadlockError { struct State { wrapped: GenerationalValue, callbacks: Vec>>, - windows: Vec, + windows: AHashSet, + widgets: AHashSet<(WindowHandle, WidgetId)>, wakers: Vec, readers: usize, } @@ -591,7 +690,10 @@ impl State { for callback in &mut self.callbacks { callback.update(&self.wrapped); } - for window in self.windows.drain(..) { + for (window, widget) in self.widgets.drain() { + window.invalidate(widget); + } + for window in self.windows.drain() { window.redraw(); } for waker in self.wakers.drain(..) { @@ -716,6 +818,45 @@ impl DynamicReader { value } + /// Returns a clone of the currently contained value. + /// + /// This function marks the currently stored value as being read. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_refresh(&mut self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.source.redraw_when_changed(context.handle()); + self.get() + } + + /// Returns a clone of the currently contained value. + /// + /// This function marks the currently stored value as being read. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_invalidate(&mut self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.source + .invalidate_when_changed(context.handle(), context.widget().id()); + self.get() + } + /// Blocks the current thread until the contained value has been updated or /// there are no remaining writers for the value. /// @@ -726,11 +867,7 @@ impl DynamicReader { /// This function panics if this value is already locked by the current /// thread. pub fn block_until_updated(&mut self) -> bool { - let mut deadlock_state = self - .source - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut deadlock_state = self.source.during_callback_state.lock().ignore_poison(); assert!( deadlock_state .as_ref() @@ -739,11 +876,7 @@ impl DynamicReader { "deadlocked" ); loop { - let state = self - .source - .state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let state = self.source.state.lock().ignore_poison(); if state.wrapped.generation != self.read_generation { return true; } else if state.readers == Arc::strong_count(&self.source) { @@ -752,11 +885,7 @@ impl DynamicReader { drop(state); // Wait for a notification of a change, which is synch - deadlock_state = self - .source - .sync - .wait(deadlock_state) - .map_or_else(PoisonError::into_inner, |g| g); + deadlock_state = self.source.sync.wait(deadlock_state).ignore_poison(); } } @@ -909,6 +1038,21 @@ where } } +/// A type that can be the source of a [`Switcher`] widget. +pub trait Switchable: IntoDynamic + Sized { + /// Returns a new [`Switcher`] whose contents is the result of invoking + /// `map` each time `self` is updated. + fn switcher(self, map: F) -> Switcher + where + F: FnMut(&T, &Dynamic) -> WidgetInstance + Send + 'static, + T: Send + 'static, + { + Switcher::mapping(self, map) + } +} + +impl Switchable for W where W: IntoDynamic {} + /// A value that may be either constant or dynamic. #[derive(Debug)] pub enum Value { @@ -936,7 +1080,11 @@ impl Value { /// /// If `self` is a dynamic, `context` will be invalidated when the value is /// updated. - pub fn map_tracked(&self, context: &WidgetContext<'_, '_>, map: impl FnOnce(&T) -> R) -> R { + pub fn map_tracking_redraw( + &self, + context: &WidgetContext<'_, '_>, + map: impl FnOnce(&T) -> R, + ) -> R { match self { Value::Constant(value) => map(value), Value::Dynamic(dynamic) => { @@ -946,6 +1094,24 @@ impl Value { } } + /// Maps the current contents to `map` and returns the result. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn map_tracking_invalidate( + &self, + context: &WidgetContext<'_, '_>, + map: impl FnOnce(&T) -> R, + ) -> R { + match self { + Value::Constant(value) => map(value), + Value::Dynamic(dynamic) => { + context.invalidate_when_changed(dynamic); + dynamic.map_ref(map) + } + } + } + /// Maps the current contents with exclusive access and returns the result. pub fn map_mut(&mut self, map: impl FnOnce(&mut T) -> R) -> R { match self { @@ -984,7 +1150,7 @@ impl Value { where T: Clone, { - self.map_tracked(context, Clone::clone) + self.map_tracking_redraw(context, Clone::clone) } /// Returns the current generation of the data stored, if the contained @@ -1004,6 +1170,15 @@ impl Value { context.redraw_when_changed(dynamic); } } + + /// Marks the widget for redraw when this value is updated. + /// + /// This function has no effect if the value is constant. + pub fn invalidate_when_changed(&self, context: &WidgetContext<'_, '_>) { + if let Value::Dynamic(dynamic) = self { + context.invalidate_when_changed(dynamic); + } + } } impl Clone for Value where @@ -1155,7 +1330,7 @@ macro_rules! impl_tuple_for_each { move |$var: &$type| { $(let $rvar = $rvar.lock();)+ let mut for_each = - for_each.lock().map_or_else(PoisonError::into_inner, |g| g); + for_each.lock().ignore_poison(); (for_each)(($(&$avar,)+)); } })); @@ -1209,3 +1384,13 @@ macro_rules! impl_tuple_map_each { } impl_all_tuples!(impl_tuple_map_each); + +/// A type that can be converted into a [`Value`]. +pub trait StringValue: IntoValue + Sized { + /// Returns this string as a text input widget. + fn into_input(self) -> Input { + Input::new(self.into_value()) + } +} + +impl StringValue for T where T: IntoValue {} diff --git a/src/widget.rs b/src/widget.rs index 506e338b7..c063c23b1 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -6,7 +6,7 @@ use std::fmt::Debug; use std::ops::{ControlFlow, Deref, DerefMut}; use std::panic::UnwindSafe; use std::sync::atomic::{self, AtomicU64}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard}; use alot::LotId; use kludgine::app::winit::event::{ @@ -16,14 +16,19 @@ use kludgine::figures::units::{Px, UPx}; use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, Size}; use kludgine::Color; -use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::context::{ + AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext, WindowHandle, +}; use crate::styles::{ ContainerLevel, Dimension, DimensionRange, Edges, IntoComponentValue, NamedComponent, Styles, ThemePair, VisualOrder, }; use crate::tree::Tree; +use crate::utils::IgnorePoison; use crate::value::{IntoValue, Value}; -use crate::widgets::{Align, Container, Expand, Resize, Scroll, Stack, Style}; +use crate::widgets::{ + Align, Button, Container, Expand, Resize, Scroll, Stack, Style, Themed, ThemedMode, +}; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; @@ -225,6 +230,18 @@ pub trait WrapperWidget: Debug + Send + UnwindSafe + 'static { /// Returns the child widget. fn child_mut(&mut self) -> &mut WidgetRef; + /// Draws the background of the widget. + /// + /// This is invoked before the wrapped widget is drawn. + #[allow(unused_variables)] + fn redraw_background(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) {} + + /// Draws the foreground of the widget. + /// + /// This is invoked after the wrapped widget is drawn. + #[allow(unused_variables)] + fn redraw_foreground(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) {} + /// Returns the rectangle that the child widget should occupy given /// `available_space`. #[allow(unused_variables)] @@ -263,7 +280,7 @@ pub trait WrapperWidget: Debug + Send + UnwindSafe + 'static { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> WrappedLayout { - Size::::new( + Size::new( available_space .width .fit_measured(size.width, context.gfx.scale()), @@ -416,8 +433,12 @@ where context.gfx.fill(color); } + self.redraw_background(context); + let child = self.child_mut().mounted(&mut context.as_event_context()); context.for_other(&child).redraw(); + + self.redraw_foreground(context); } fn layout( @@ -561,6 +582,19 @@ pub trait MakeWidget: Sized { self.make_widget().with_next_focus(next_focus) } + /// Sets this widget to be enabled/disabled based on `enabled` and returns + /// self. + /// + /// If this widget is disabled, all children widgets will also be disabled. + /// + /// # Panics + /// + /// This function can only be called when one instance of the widget exists. + /// If any clones exist, a panic will occur. + fn with_enabled(self, enabled: impl IntoValue) -> WidgetInstance { + self.make_widget().with_enabled(enabled) + } + /// Sets this widget as a "default" widget. /// /// Default widgets are automatically activated when the user signals they @@ -624,9 +658,12 @@ pub trait MakeWidget: Sized { /// Resizes `self` to `width`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `width` can be an any of: + /// + /// - [`Dimension`] + /// - [`Px`] + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] fn width(self, width: impl Into) -> Resize { Resize::from_width(width, self) @@ -634,14 +671,22 @@ pub trait MakeWidget: Sized { /// Resizes `self` to `height`. /// - /// `height` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `height` can be an any of: + /// + /// - [`Dimension`] + /// - [`Px`] + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] fn height(self, height: impl Into) -> Resize { Resize::from_height(height, self) } + /// Returns this string as a clickable button. + fn into_button(self) -> Button { + Button::new(self) + } + /// Aligns `self` to the center vertically and horizontally. #[must_use] fn centered(self) -> Align { @@ -726,6 +771,16 @@ pub trait MakeWidget: Sized { fn pad_by(self, padding: impl IntoValue>) -> Container { self.contain().transparent().pad_by(padding) } + + /// Applies `theme` to `self` and its children. + fn themed(self, theme: impl IntoValue) -> Themed { + Themed::new(theme, self) + } + + /// Applies `mode` to `self` and its children. + fn themed_mode(self, mode: impl IntoValue) -> ThemedMode { + ThemedMode::new(mode, self) + } } /// A type that can create a [`WidgetInstance`] with a preallocated @@ -806,6 +861,7 @@ struct WidgetInstanceData { default: bool, cancel: bool, next_focus: Value>, + enabled: Value, widget: Box>, } @@ -823,6 +879,7 @@ impl WidgetInstance { default: false, cancel: false, widget: Box::new(Mutex::new(widget)), + enabled: Value::Constant(true), }), } } @@ -861,6 +918,23 @@ impl WidgetInstance { self } + /// Sets this widget to be enabled/disabled based on `enabled` and returns + /// self. + /// + /// If this widget is disabled, all children widgets will also be disabled. + /// + /// # Panics + /// + /// This function can only be called when one instance of the widget exists. + /// If any clones exist, a panic will occur. + #[must_use] + pub fn with_enabled(mut self, enabled: impl IntoValue) -> WidgetInstance { + let data = Arc::get_mut(&mut self.data) + .expect("with_enabled can only be called on newly created widget instances"); + data.enabled = enabled.into_value(); + self + } + /// Sets this widget as a "default" widget. /// /// Default widgets are automatically activated when the user signals they @@ -908,13 +982,9 @@ impl WidgetInstance { /// Locks the widget for exclusive access. Locking widgets should only be /// done for brief moments of time when you are certain no deadlocks can /// occur due to other widget locks being held. + #[must_use] pub fn lock(&self) -> WidgetGuard<'_> { - WidgetGuard( - self.data - .widget - .lock() - .map_or_else(PoisonError::into_inner, |g| g), - ) + WidgetGuard(self.data.widget.lock().ignore_poison()) } /// Runs this widget instance as an application. @@ -946,6 +1016,13 @@ impl WidgetInstance { pub fn is_escape(&self) -> bool { self.data.cancel } + + pub(crate) fn enabled(&self, context: &WindowHandle) -> bool { + if let Value::Dynamic(dynamic) = &self.data.enabled { + dynamic.redraw_when_changed(context.clone()); + } + self.data.enabled.get() + } } impl AsRef for WidgetInstance { @@ -1041,6 +1118,11 @@ impl ManagedWidget { self.widget.lock() } + /// Invalidates this widget. + pub fn invalidate(&self) { + self.tree.invalidate(self.node_id, false); + } + pub(crate) fn set_layout(&self, rect: Rect) { self.tree.set_layout(self.node_id, rect); } @@ -1062,6 +1144,26 @@ impl ManagedWidget { .and_then(|next_focus| self.tree.widget(next_focus)) } + /// Returns the widget to focus before this widget. + /// + /// There is no direct way to set this value. This relationship is created + /// automatically using [`MakeWidget::with_next_focus()`]. + #[must_use] + pub fn previous_focus(&self) -> Option { + self.tree.previous_focus(self.id()) + } + + /// Returns the next or previous focus target, if one was set using + /// [`MakeWidget::with_next_focus()`]. + #[must_use] + pub fn explicit_focus_target(&self, advance: bool) -> Option { + if advance { + self.next_focus() + } else { + self.previous_focus() + } + } + /// Returns the region that the widget was last rendered at. #[must_use] pub fn last_layout(&self) -> Option> { @@ -1080,6 +1182,10 @@ impl ManagedWidget { self.tree.active_widget() == Some(self.node_id) } + pub(crate) fn enabled(&self, handle: &WindowHandle) -> bool { + self.tree.is_enabled(self.node_id, handle) + } + /// Returns true if this widget is currently the hovered widget. #[must_use] pub fn hovered(&self) -> bool { @@ -1130,8 +1236,12 @@ impl ManagedWidget { self.tree.overriden_theme(self.node_id) } - pub(crate) fn reset_child_layouts(&self) { - self.tree.reset_child_layouts(self.node_id); + pub(crate) fn begin_layout(&self, constraints: Size) -> Option> { + self.tree.begin_layout(self.node_id, constraints) + } + + pub(crate) fn persist_layout(&self, constraints: Size, size: Size) { + self.tree.persist_layout(self.node_id, constraints, size); } pub(crate) fn visually_ordered_children(&self, order: VisualOrder) -> Vec { @@ -1343,7 +1453,7 @@ impl AsRef for WidgetRef { /// /// Each [`WidgetInstance`] is guaranteed to have a unique [`WidgetId`] across /// the lifetime of an application. -#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] +#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Ord, PartialOrd)] pub struct WidgetId(u64); impl WidgetId { diff --git a/src/widgets.rs b/src/widgets.rs index d090af25c..0c0061ec7 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -3,6 +3,7 @@ mod align; pub mod button; mod canvas; +pub mod checkbox; pub mod container; mod expand; mod input; @@ -10,7 +11,7 @@ pub mod label; mod mode_switch; mod resize; pub mod scroll; -mod slider; +pub mod slider; mod space; pub mod stack; mod style; @@ -21,11 +22,12 @@ mod tilemap; pub use align::Align; pub use button::Button; pub use canvas::Canvas; +pub use checkbox::Checkbox; pub use container::Container; pub use expand::Expand; pub use input::Input; pub use label::Label; -pub use mode_switch::ModeSwitch; +pub use mode_switch::ThemedMode; pub use resize::Resize; pub use scroll::Scroll; pub use slider::Slider; diff --git a/src/widgets/align.rs b/src/widgets/align.rs index f7c7106e3..5ee0b7281 100644 --- a/src/widgets/align.rs +++ b/src/widgets/align.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, Point, Rect, ScreenScale, Size}; use crate::context::{AsEventContext, LayoutContext}; use crate::styles::{Edges, FlexibleDimension}; @@ -125,15 +125,11 @@ impl FrameInfo { fn new(scale: Fraction, a: FlexibleDimension, b: FlexibleDimension) -> Self { let a = match a { FlexibleDimension::Auto => None, - FlexibleDimension::Dimension(dimension) => { - Some(dimension.into_px(scale).into_unsigned()) - } + FlexibleDimension::Dimension(dimension) => Some(dimension.into_upx(scale)), }; let b = match b { FlexibleDimension::Auto => None, - FlexibleDimension::Dimension(dimension) => { - Some(dimension.into_px(scale).into_unsigned()) - } + FlexibleDimension::Dimension(dimension) => Some(dimension.into_upx(scale)), }; Self { a, b } } diff --git a/src/widgets/button.rs b/src/widgets/button.rs index c55b5ce41..c8ae9ee5f 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -4,16 +4,19 @@ use std::time::Duration; use kludgine::app::winit::event::{DeviceId, ElementState, KeyEvent, MouseButton}; use kludgine::figures::units::{Lp, Px, UPx}; -use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size}; -use kludgine::shapes::StrokeOptions; +use kludgine::figures::{IntoSigned, Point, Rect, ScreenScale, Size}; +use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; use crate::animation::{AnimationHandle, AnimationTarget, LinearInterpolate, Spawn}; -use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::context::{ + AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetCacheKey, WidgetContext, +}; use crate::styles::components::{ - AutoFocusableControls, Easing, IntrinsicPadding, OpaqueWidgetColor, SurfaceColor, TextColor, + AutoFocusableControls, Easing, HighlightColor, IntrinsicPadding, OpaqueWidgetColor, + OutlineColor, SurfaceColor, TextColor, }; -use crate::styles::Styles; +use crate::styles::{ColorExt, Styles}; use crate::utils::ModifiersExt; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{Callback, EventHandling, MakeWidget, Widget, WidgetRef, HANDLED, IGNORED}; @@ -25,19 +28,100 @@ pub struct Button { pub content: WidgetRef, /// The callback that is invoked when the button is clicked. pub on_click: Option>, - /// The enabled state of the button. - pub enabled: Value, - currently_enabled: bool, + /// The kind of button to draw. + pub kind: Value, buttons_pressed: usize, - active_style: Option>, + cached_state: CacheState, + active_colors: Option>, color_animation: AnimationHandle, } +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +struct CacheState { + key: WidgetCacheKey, + kind: ButtonKind, +} + +/// The type of a [`Button`] or similar clickable widget. +#[derive(Debug, Default, Eq, PartialEq, Clone, Copy)] +pub enum ButtonKind { + /// A solid button. + #[default] + Solid, + /// An outline button, which uses the same colors as [`ButtonKind::Solid`] + /// but swaps the outline and background colors. + Outline, + /// A transparent button, which is transparent until it is hovered. + Transparent, +} + +impl ButtonKind { + /// Returns the [`ButtonColors`] to apply for a + /// [default](MakeWidget::into_default) button. + #[must_use] + pub fn colors_for_default( + self, + visual_state: VisualState, + context: &WidgetContext<'_, '_>, + ) -> ButtonColors { + match self { + ButtonKind::Solid => match visual_state { + VisualState::Normal => ButtonColors { + background: context.theme().primary.color, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonOutline), + }, + VisualState::Hovered => ButtonColors { + background: context.theme().primary.color_bright, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonHoverOutline), + }, + VisualState::Active => ButtonColors { + background: context.theme().primary.color_dim, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonActiveOutline), + }, + VisualState::Disabled => ButtonColors { + background: context.theme().primary.color_dim, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonDisabledOutline), + }, + }, + ButtonKind::Outline | ButtonKind::Transparent => match visual_state { + VisualState::Normal => ButtonColors { + background: context.get(&ButtonOutline), + foreground: context.theme().primary.color, + outline: context.theme().primary.color, + }, + VisualState::Hovered => ButtonColors { + background: context.get(&ButtonHoverOutline), + foreground: context.theme().primary.color, + outline: context.theme().primary.color_bright, + }, + VisualState::Active => ButtonColors { + background: context.get(&ButtonActiveOutline), + foreground: context.theme().primary.color, + outline: context.theme().surface.color, + }, + VisualState::Disabled => ButtonColors { + background: context.get(&ButtonDisabledOutline), + foreground: context.theme().primary.on_color, + outline: context.theme().primary.color_dim, + }, + }, + } + } +} + +/// The coloring to apply to a [`Button`] or button-like widget. #[derive(Debug, PartialEq, Eq, Clone, Copy, LinearInterpolate)] -struct ButtonStyle { - background: Color, - foreground: Color, - outline: Color, +pub struct ButtonColors { + /// The background color of the button. + pub background: Color, + /// The foreground (text) color of the button. + pub foreground: Color, + /// A color to use to surround the button. + pub outline: Color, } impl Button { @@ -46,14 +130,24 @@ impl Button { Self { content: content.widget_ref(), on_click: None, - enabled: Value::Constant(true), - currently_enabled: true, + cached_state: CacheState { + key: WidgetCacheKey::default(), + kind: ButtonKind::default(), + }, buttons_pressed: 0, - active_style: None, + active_colors: None, + kind: Value::Constant(ButtonKind::default()), color_animation: AnimationHandle::default(), } } + /// Sets the button's `kind` and returns self. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } + /// Sets the `on_click` callback and returns self. /// /// This callback will be invoked each time the button is clicked. @@ -66,53 +160,80 @@ impl Button { self } - /// Sets the value to use for the button's enabled status. - #[must_use] - pub fn enabled(mut self, enabled: impl IntoValue) -> Self { - self.enabled = enabled.into_value(); - self.currently_enabled = self.enabled.get(); - self - } - - fn invoke_on_click(&mut self) { - if self.enabled.get() { + fn invoke_on_click(&mut self, context: &WidgetContext<'_, '_>) { + if context.enabled() { if let Some(on_click) = self.on_click.as_mut() { on_click.invoke(()); } } } - fn update_colors(&mut self, context: &WidgetContext<'_, '_>, immediate: bool) { - let new_style = match () { - () if !self.enabled.get() => ButtonStyle { - background: context.get(&ButtonDisabledBackground), - foreground: context.get(&ButtonDisabledForeground), - outline: context.get(&ButtonDisabledOutline), + fn visual_style(context: &WidgetContext<'_, '_>) -> VisualState { + if !context.enabled() { + VisualState::Disabled + } else if context.active() { + VisualState::Active + } else if context.hovered() { + VisualState::Hovered + } else { + VisualState::Normal + } + } + + /// Returns the coloring to apply to a [`ButtonKind::Transparent`] button. + #[must_use] + pub fn colors_for_transparent( + visual_state: VisualState, + context: &WidgetContext<'_, '_>, + ) -> ButtonColors { + match visual_state { + VisualState::Normal => ButtonColors { + background: Color::CLEAR_BLACK, + foreground: context.get(&TextColor), + outline: context.get(&ButtonOutline), }, - // TODO this probably should use actual style. - () if context.is_default() => ButtonStyle { - background: context.theme().primary.color, - foreground: context.theme().primary.on_color, - outline: Color::CLEAR_BLACK, + VisualState::Hovered => ButtonColors { + background: context.get(&OpaqueWidgetColor), + foreground: context.get(&TextColor), + outline: context.get(&ButtonHoverOutline), }, - () if context.active() => ButtonStyle { + VisualState::Active => ButtonColors { background: context.get(&ButtonActiveBackground), foreground: context.get(&ButtonActiveForeground), outline: context.get(&ButtonActiveOutline), }, - () if context.hovered() => ButtonStyle { - background: context.get(&ButtonHoverBackground), - foreground: context.get(&ButtonHoverForeground), - outline: context.get(&ButtonHoverOutline), - }, - () => ButtonStyle { - background: context.get(&ButtonBackground), - foreground: context.get(&ButtonForeground), - outline: context.get(&ButtonOutline), + VisualState::Disabled => ButtonColors { + background: Color::CLEAR_BLACK, + foreground: context.theme().surface.on_color_variant, + outline: context.get(&ButtonDisabledOutline), }, + } + } + + fn determine_stateful_colors(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { + let kind = self.kind.get_tracked(context); + let visual_state = Self::visual_style(context); + + self.cached_state = CacheState { + key: context.cache_key(), + kind, }; - match (immediate, &self.active_style) { + if context.is_default() { + kind.colors_for_default(visual_state, context) + } else { + match kind { + ButtonKind::Transparent => Self::colors_for_transparent(visual_state, context), + ButtonKind::Solid => visual_state.solid_colors(context), + ButtonKind::Outline => visual_state.outline_colors(context), + } + } + } + + fn update_colors(&mut self, context: &mut WidgetContext<'_, '_>, immediate: bool) { + let new_style = self.determine_stateful_colors(context); + + match (immediate, &self.active_colors) { (false, Some(style)) => { self.color_animation = (style.transition_to(new_style)) .over(Duration::from_millis(150)) @@ -126,43 +247,136 @@ impl Button { _ => { let new_style = Dynamic::new(new_style); let foreground = new_style.map_each(|s| s.foreground); - self.active_style = Some(new_style); + self.active_colors = Some(new_style); context.attach_styles(Styles::new().with(&TextColor, foreground)); } } } - fn current_style(&mut self, context: &WidgetContext<'_, '_>) -> ButtonStyle { - if self.active_style.is_none() { + fn current_style(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { + if self.active_colors.is_none() { self.update_colors(context, false); } - let style = self.active_style.as_ref().expect("always initialized"); + let style = self.active_colors.as_ref().expect("always initialized"); context.redraw_when_changed(style); style.get() } } +/// The effective visual state of an element. +/// +/// While an element may be multiple states (e.g., active and hovered), when +/// rendering a widget sometimes a single visual style must take priority. This +/// enum represents the various states a widget may be in for such a decision. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum VisualState { + /// The widget should render in its normal state. + Normal, + /// The widget should render in reaction to the mouse cursor being above the + /// widget. + Hovered, + /// The widget should render in reaction to the widget being clicked on or + /// activated by the user. + Active, + /// The widget should render in a way to convey to the user it is not + /// enabled for interaction. + Disabled, +} + +impl VisualState { + /// Returns the colors to apply to a [`ButtonKind::Solid`] [`Button`] or + /// button-like widget. + #[must_use] + pub fn solid_colors(self, context: &WidgetContext<'_, '_>) -> ButtonColors { + match self { + VisualState::Normal => ButtonColors { + background: context.get(&ButtonBackground), + foreground: context.get(&ButtonForeground), + outline: context.get(&ButtonOutline), + }, + VisualState::Hovered => ButtonColors { + background: context.get(&ButtonHoverBackground), + foreground: context.get(&ButtonHoverForeground), + outline: context.get(&ButtonHoverOutline), + }, + VisualState::Active => ButtonColors { + background: context.get(&ButtonActiveBackground), + foreground: context.get(&ButtonActiveForeground), + outline: context.get(&ButtonActiveOutline), + }, + VisualState::Disabled => ButtonColors { + background: context.get(&ButtonDisabledBackground), + foreground: context.get(&ButtonDisabledForeground), + outline: context.get(&ButtonDisabledOutline), + }, + } + } + + /// Returns the colors to apply to a [`ButtonKind::Outline`] [`Button`] or + /// button-like widget. + #[must_use] + pub fn outline_colors(self, context: &WidgetContext<'_, '_>) -> ButtonColors { + let solid = self.solid_colors(context); + ButtonColors { + background: solid.outline, + foreground: solid.foreground, + outline: solid.background, + } + } +} + impl Widget for Button { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { #![allow(clippy::similar_names)] - let enabled = self.enabled.get(); - // TODO This seems ugly. It needs context, so it can't be moved into the - // dynamic system. - if self.currently_enabled != enabled { + + let current_style = self.kind.get_tracked(context); + if self.cached_state.key != context.cache_key() || self.cached_state.kind != current_style { self.update_colors(context, false); - self.currently_enabled = enabled; } - self.enabled.redraw_when_changed(context); - let style = self.current_style(context); context.gfx.fill(style.background); - context.stroke_outline::(style.outline, StrokeOptions::default()); + let two_lp_stroke = StrokeOptions::lp_wide(Lp::points(2)); + context.stroke_outline(style.outline, two_lp_stroke); if context.focused() { - context.draw_focus_ring(); + if current_style == ButtonKind::Transparent { + let two_lp_stroke = two_lp_stroke.into_px(context.gfx.scale()); + let focus_color = context.get(&HighlightColor); + // Some states of a transparent button have solid background + // colors. most_contrasting from a 0-alpha color is not a + // meaningful measurement, so we only start measuring contrast + // once we reach 50% opacity. If we ever add solid background + // tracking (), + // we should use that color for most_contrasting always. + let color = if style.background.alpha() > 128 { + style + .background + .most_contrasting(&[focus_color, context.get(&TextColor)]) + } else { + focus_color + }; + + let inset = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + + let focus_ring = Shape::stroked_rect( + Rect::new( + Point::new(inset, inset), + context.gfx.region().size - inset * 2, + ), + color, + two_lp_stroke, + ); + context + .gfx + .draw_shape(&focus_ring, Point::default(), None, None); + } else if context.is_default() { + context.stroke_outline(context.get(&OutlineColor), two_lp_stroke); + } else { + context.draw_focus_ring(); + } } let content = self.content.mounted(&mut context.as_event_context()); @@ -174,7 +388,7 @@ impl Widget for Button { } fn accept_focus(&mut self, context: &mut EventContext<'_, '_>) -> bool { - self.enabled.get() && context.get(&AutoFocusableControls).is_all() + context.get(&AutoFocusableControls).is_all() } fn mouse_down( @@ -226,7 +440,7 @@ impl Widget for Button { { context.focus(); - self.invoke_on_click(); + self.invoke_on_click(context); } } } @@ -237,17 +451,27 @@ impl Widget for Button { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); + let double_padding = padding * 2; let mounted = self.content.mounted(&mut context.as_event_context()); + let available_space = Size::new( + available_space.width - double_padding, + available_space.height - double_padding, + ); let size = context.for_other(&mounted).layout(available_space); + let size = Size::new( + available_space + .width + .fit_measured(size.width, context.gfx.scale()), + available_space + .height + .fit_measured(size.height, context.gfx.scale()), + ); context.set_child_layout( &mounted, Rect::new(Point::new(padding, padding), size).into_signed(), ); - size + padding * 2 + size + double_padding } fn keyboard_input( @@ -264,7 +488,7 @@ impl Widget for Button { let changed = context.activate(); if !changed { // The widget was already active. This is now a repeated keypress - self.invoke_on_click(); + self.invoke_on_click(context); } changed } @@ -299,7 +523,7 @@ impl Widget for Button { // If we have no buttons pressed, the event should fire on activate not // on deactivate. if self.buttons_pressed == 0 { - self.invoke_on_click(); + self.invoke_on_click(context); } self.update_colors(context, true); } @@ -317,7 +541,7 @@ define_components! { ButtonActiveBackground(Color, "active_background_color", .surface.color) /// The background color of the button when the mouse cursor is hovering over /// it. - ButtonHoverBackground(Color, "hover_background_color", .surface.bright_color) + ButtonHoverBackground(Color, "hover_background_color", .surface.lowest_container) /// The background color of the button when the mouse cursor is hovering over /// it. ButtonDisabledBackground(Color, "disabled_background_color", .surface.dim_color) @@ -334,12 +558,12 @@ define_components! { /// The outline color of the button. ButtonOutline(Color, "outline_color", Color::CLEAR_BLACK) /// The outline color of the button when it is active (depressed). - ButtonActiveOutline(Color, "active_outline_color", contrasting!(ButtonActiveBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonActiveOutline(Color, "active_outline_color", Color::CLEAR_BLACK) /// The outline color of the button when the mouse cursor is hovering over /// it. - ButtonHoverOutline(Color, "hover_outline_color", contrasting!(ButtonHoverBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonHoverOutline(Color, "hover_outline_color", Color::CLEAR_BLACK) /// The outline color of the button when the mouse cursor is hovering over /// it. - ButtonDisabledOutline(Color, "disabled_outline_color", contrasting!(ButtonDisabledBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonDisabledOutline(Color, "disabled_outline_color", Color::CLEAR_BLACK) } } diff --git a/src/widgets/checkbox.rs b/src/widgets/checkbox.rs new file mode 100644 index 000000000..ffa9144a2 --- /dev/null +++ b/src/widgets/checkbox.rs @@ -0,0 +1,233 @@ +//! A tri-state, labelable checkbox widget. +use std::error::Error; +use std::fmt::Display; +use std::ops::Not; + +use kludgine::figures::units::{Lp, Px}; +use kludgine::figures::{IntoUnsigned, Point, Rect, ScreenScale, Size}; +use kludgine::shapes::{PathBuilder, Shape, StrokeOptions}; + +use crate::context::{GraphicsContext, LayoutContext}; +use crate::styles::components::{ + IntrinsicPadding, LineHeight, OutlineColor, TextColor, WidgetAccentColor, +}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; +use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrappedLayout, WrapperWidget}; +use crate::widgets::button::ButtonKind; +use crate::ConstraintLimit; + +/// A labeled-widget that supports three states: Checked, Unchecked, and +/// Indeterminant +pub struct Checkbox { + /// The state (value) of the checkbox. + pub state: Dynamic, + /// The button kind to use as the basis for this checkbox. Checkboxes + /// default to [`ButtonKind::Transparent`]. + pub kind: Value, + label: WidgetInstance, +} + +impl Checkbox { + /// Returns a new checkbox that updates `state` when clicked. `label` is + /// drawn next to the checkbox and is also clickable to toggle the checkbox. + /// + /// `state` can also be a `Dynamic` if there is no need to represent + /// an indeterminant state. + pub fn new(state: impl IntoDynamic, label: impl MakeWidget) -> Self { + Self { + state: state.into_dynamic(), + kind: Value::Constant(ButtonKind::Transparent), + label: label.make_widget(), + } + } + + /// Updates the button kind to use as the basis for this checkbox, and + /// returns self. + /// + /// Checkboxes default to [`ButtonKind::Transparent`]. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } +} + +impl MakeWidget for Checkbox { + fn make_widget(self) -> WidgetInstance { + CheckboxLabel { + value: self.state.create_reader(), + label: WidgetRef::new(self.label), + } + .into_button() + .on_click(move |()| { + let mut value = self.state.lock(); + *value = !*value; + }) + .kind(self.kind) + .make_widget() + } +} + +/// The state/value of a [`Checkbox`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CheckboxState { + /// The checkbox should display showing that it is neither checked or + /// unchecked. + /// + /// This state is used to represent concepts such as: + /// + /// - States that are neither true/false, or on/off. + /// - States that are partially true or partially on. + Indeterminant, + /// The checkbox should display in an unchecked/off/false state. + Unchecked, + /// The checkbox should display in an checked/on/true state. + Checked, +} + +impl From for CheckboxState { + fn from(value: bool) -> Self { + if value { + Self::Checked + } else { + Self::Unchecked + } + } +} + +impl TryFrom for bool { + type Error = CheckboxToBoolError; + + fn try_from(value: CheckboxState) -> Result { + match value { + CheckboxState::Checked => Ok(true), + CheckboxState::Unchecked => Ok(false), + CheckboxState::Indeterminant => Err(CheckboxToBoolError), + } + } +} + +impl Not for CheckboxState { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::Indeterminant | Self::Unchecked => Self::Checked, + Self::Checked => Self::Unchecked, + } + } +} + +impl IntoDynamic for Dynamic { + fn into_dynamic(self) -> Dynamic { + self.linked( + |bool| CheckboxState::from(*bool), + |tri_state: &CheckboxState| bool::try_from(*tri_state).ok(), + ) + } +} + +/// An [`CheckboxState::Indeterminant`] was encountered when converting to a +/// `bool`. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CheckboxToBoolError; + +impl Display for CheckboxToBoolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CheckboxState was Indeterminant") + } +} + +impl Error for CheckboxToBoolError {} + +#[derive(Debug)] +struct CheckboxLabel { + value: DynamicReader, + label: WidgetRef, +} + +impl WrapperWidget for CheckboxLabel { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.label + } + + fn position_child( + &mut self, + size: Size, + _available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> WrappedLayout { + let checkbox_size = context.get(&LineHeight).into_px(context.gfx.scale()); // TODO create a component? + let padding = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + let label_inset = checkbox_size + padding; + let size_with_checkbox = Size::new(size.width + label_inset, size.height).into_unsigned(); + WrappedLayout { + child: Rect::new(Point::new(label_inset, Px(0)), size), + size: size_with_checkbox, + } + } + + fn redraw_background(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let checkbox_size = context.get(&LineHeight).into_px(context.gfx.scale()); + let padding = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + let checkbox_rect = Rect::new( + Point::new(padding, padding), + Size::new(checkbox_size, checkbox_size), + ); + let stroke_options = StrokeOptions::lp_wide(Lp::points(2)).into_px(context.gfx.scale()); + match self.value.get_tracking_refresh(context) { + state @ (CheckboxState::Checked | CheckboxState::Indeterminant) => { + let color = context.get(&WidgetAccentColor); + context.gfx.draw_shape( + &Shape::filled_rect(checkbox_rect, color), + Point::default(), + None, + None, + ); + let icon_area = checkbox_rect.inset(Lp::points(3).into_px(context.gfx.scale())); + let text_color = context.get(&TextColor); + let center = icon_area.origin + icon_area.size / 2; + if matches!(state, CheckboxState::Checked) { + context.gfx.draw_shape( + &PathBuilder::new(Point::new(icon_area.origin.x, center.y)) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width / 4, + icon_area.origin.y + icon_area.size.height * 3 / 4, + )) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width, + icon_area.origin.y, + )) + .build() + .stroke(text_color, stroke_options), + Point::default(), + None, + None, + ); + } else { + context.gfx.draw_shape( + &PathBuilder::new(Point::new(icon_area.origin.x, center.y)) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width, + center.y, + )) + .build() + .stroke(text_color, stroke_options), + Point::default(), + None, + None, + ); + } + } + CheckboxState::Unchecked => { + let color = context.get(&OutlineColor); + context.gfx.draw_shape( + &Shape::stroked_rect(checkbox_rect, color, stroke_options), + Point::default(), + None, + None, + ); + } + } + } +} diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 34eb3ceff..7b28c4d4e 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -6,7 +6,7 @@ use kludgine::Color; use crate::context::{GraphicsContext, LayoutContext, WidgetContext}; use crate::styles::components::{IntrinsicPadding, SurfaceColor}; -use crate::styles::{Component, ContainerLevel, Dimension, Edges, Styles}; +use crate::styles::{Component, ContainerLevel, Dimension, Edges, RequireInvalidation, Styles}; use crate::value::{IntoValue, Value}; use crate::widget::{MakeWidget, WidgetRef, WrappedLayout, WrapperWidget}; use crate::ConstraintLimit; @@ -193,11 +193,7 @@ impl WrapperWidget for Container { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding_amount = self - .padding(context) - .size() - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding_amount = self.padding(context).size().into_upx(context.gfx.scale()); Size::new( available_space.width - padding_amount.width, available_space.height - padding_amount.height, @@ -250,6 +246,12 @@ impl From for Component { } } +impl RequireInvalidation for EffectiveBackground { + fn requires_invalidation(&self) -> bool { + false + } +} + define_components! { Container { /// The container background behind the current widget. diff --git a/src/widgets/expand.rs b/src/widgets/expand.rs index 050ac2cd7..b654f5258 100644 --- a/src/widgets/expand.rs +++ b/src/widgets/expand.rs @@ -1,4 +1,3 @@ -use kludgine::figures::units::UPx; use kludgine::figures::{IntoSigned, Size}; use crate::context::{AsEventContext, LayoutContext}; @@ -133,6 +132,6 @@ impl WrapperWidget for Expand { ), }; - Size::::new(width, height).into_signed().into() + Size::new(width, height).into_signed().into() } } diff --git a/src/widgets/input.rs b/src/widgets/input.rs index e15ec307e..da35c71ad 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -85,7 +85,7 @@ impl Input { context.get(&LineHeight).into_px(scale).into_float(), ), ); - self.text.map(|text| { + self.text.map_tracking_invalidate(context, |text| { buffer.set_text( kludgine.font_system(), text, @@ -118,6 +118,101 @@ impl Input { editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before))); } } + + fn handle_key( + &mut self, + input: KeyEvent, + context: &mut EventContext<'_, '_>, + ) -> (bool, EventHandling) { + let editor = self.editor_mut(context.kludgine, &context.widget); + + match (input.state, input.logical_key, input.text.as_deref()) { + (ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => { + editor.action( + context.kludgine.font_system(), + match key { + Key::Backspace => Action::Backspace, + Key::Delete => Action::Delete, + _ => unreachable!("previously matched"), + }, + ); + (true, HANDLED) + } + (ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { + let modifiers = context.modifiers(); + match (editor.select_opt(), modifiers.state().shift_key()) { + (None, true) => { + editor.set_select_opt(Some(editor.cursor())); + } + (Some(_), false) => { + editor.set_select_opt(None); + } + _ => {} + }; + + editor.action( + context.kludgine.font_system(), + match key { + Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord, + Key::ArrowLeft => Action::Left, + Key::ArrowDown => Action::Down, + Key::ArrowUp => Action::Up, + Key::ArrowRight if modifiers.word_select() => Action::NextWord, + Key::ArrowRight => Action::Right, + _ => unreachable!("previously matched"), + }, + ); + (false, HANDLED) + } + (state, _, Some("a")) if context.modifiers().primary() => { + if state.is_pressed() { + self.select_all(); + } + (false, HANDLED) + } + (state, _, Some("c")) if context.modifiers().primary() => { + + if state.is_pressed() { + if let Some(mut clipboard) = context.clipboard_guard() { + if let Some(selection) = editor.copy_selection() { + match clipboard.set_text(selection) { + Ok(()) => {}, + Err(err) => tracing::error!("error copying to clipboard: {err}"), + } + } + } + } + (false, HANDLED) + } + (state, _, Some("v")) if context.modifiers().primary() => { + let pasted = state.is_pressed() && + match context.clipboard_guard().map(|mut clipboard| clipboard.get_text()) { + Some(Ok(text)) => { + editor.insert_string(&text, None); + true + }, + None | Some(Err(arboard::Error::ConversionFailure)) => false, + Some(Err(err)) => {tracing::error!("error retrieving clipboard contents: {err}"); false}, + } + + ; + (pasted, HANDLED) + } + (state, _, Some(text)) + if !context.modifiers().primary() + && text != "\t" // tab + && text != "\r" // enter/return + && text != "\u{1b}" // escape + => + { + if state.is_pressed() { + editor.insert_string(text, None); + } + (state.is_pressed(), HANDLED) + } + (_, _, _) => (false, IGNORED), + } + } } impl Default for Input { @@ -351,10 +446,7 @@ impl Widget for Input { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); if self.needs_to_select_all { self.needs_to_select_all = false; self.select_all(); @@ -392,70 +484,11 @@ impl Widget for Input { on_key.invoke(input.clone())?; } - let editor = self.editor_mut(context.kludgine, &context.widget); - // println!( // "Keyboard input: {:?}. {:?}, {:?}", // input.logical_key, input.text, input.physical_key // ); - let (text_changed, handled) = match (input.state, input.logical_key, input.text.as_deref()) { - (ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => { - editor.action( - context.kludgine.font_system(), - match key { - Key::Backspace => Action::Backspace, - Key::Delete => Action::Delete, - _ => unreachable!("previously matched"), - }, - ); - (true, HANDLED) - } - (ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { - let modifiers = context.modifiers(); - match (editor.select_opt(), modifiers.state().shift_key()) { - (None, true) => { - editor.set_select_opt(Some(editor.cursor())); - } - (Some(_), false) => { - editor.set_select_opt(None); - } - _ => {} - }; - - editor.action( - context.kludgine.font_system(), - match key { - Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord, - Key::ArrowLeft => Action::Left, - Key::ArrowDown => Action::Down, - Key::ArrowUp => Action::Up, - Key::ArrowRight if modifiers.word_select() => Action::NextWord, - Key::ArrowRight => Action::Right, - _ => unreachable!("previously matched"), - }, - ); - (false, HANDLED) - } - (state, _, Some("a")) if context.modifiers().primary() => { - if state.is_pressed() { - self.select_all(); - } - (false, HANDLED) - } - (state, _, Some(text)) - if !context.modifiers().primary() - && text != "\t" // tab - && text != "\r" // enter/return - && text != "\u{1b}" // escape - => - { - if state.is_pressed() { - editor.insert_string(text, None); - } - (state.is_pressed(), HANDLED) - } - (_, _, _) => (false, IGNORED), - }; + let (text_changed, handled) = self.handle_key(input, context); if handled.is_break() { context.set_needs_redraw(); diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 266c9cdad..a48890252 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -1,13 +1,13 @@ //! A read-only text widget. use kludgine::figures::units::{Px, UPx}; -use kludgine::figures::{IntoUnsigned, Point, ScreenScale, Size}; +use kludgine::figures::{Point, ScreenScale, Size}; use kludgine::text::{MeasuredText, Text, TextOrigin}; use kludgine::Color; use crate::context::{GraphicsContext, LayoutContext}; use crate::styles::components::{IntrinsicPadding, TextColor}; -use crate::value::{Dynamic, IntoValue, Value}; +use crate::value::{Dynamic, Generation, IntoValue, Value}; use crate::widget::{MakeWidget, Widget, WidgetInstance}; use crate::ConstraintLimit; @@ -16,7 +16,7 @@ use crate::ConstraintLimit; pub struct Label { /// The contents of the label. pub text: Value, - prepared_text: Option<(MeasuredText, Px, Color)>, + prepared_text: Option<(MeasuredText, Option, Px, Color)>, } impl Label { @@ -34,29 +34,34 @@ impl Label { color: Color, width: Px, ) -> &MeasuredText { + let check_generation = self.text.generation(); match &self.prepared_text { - Some((_, prepared_width, prepared_color)) - if *prepared_color == color && *prepared_width == width => {} + Some((prepared, prepared_generation, prepared_width, prepared_color)) + if *prepared_generation == check_generation + && *prepared_color == color + && (*prepared_width == width + || (*prepared_width < width + && prepared.line_height == prepared.size.height)) => {} _ => { let measured = self.text.map(|text| { context .gfx .measure_text(Text::new(text, color).wrap_at(width)) }); - self.prepared_text = Some((measured, width, color)); + self.prepared_text = Some((measured, check_generation, width, color)); } } self.prepared_text .as_ref() - .map(|(prepared, _, _)| prepared) + .map(|(prepared, _, _, _)| prepared) .expect("always initialized") } } impl Widget for Label { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { - self.text.redraw_when_changed(context); + self.text.invalidate_when_changed(context); let size = context.gfx.region().size; let center = Point::from(size) / 2; @@ -74,10 +79,7 @@ impl Widget for Label { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); let color = context.get(&TextColor); let width = available_space.width.max().try_into().unwrap_or(Px::MAX); let prepared = self.prepared_text(context, color, width); diff --git a/src/widgets/mode_switch.rs b/src/widgets/mode_switch.rs index 238b56e98..2f2f61e5b 100644 --- a/src/widgets/mode_switch.rs +++ b/src/widgets/mode_switch.rs @@ -5,12 +5,12 @@ use crate::window::ThemeMode; /// A widget that applies a set of [`ThemeMode`] to all contained widgets. #[derive(Debug)] -pub struct ModeSwitch { +pub struct ThemedMode { mode: Value, child: WidgetRef, } -impl ModeSwitch { +impl ThemedMode { /// Returns a new widget that applies `mode` to all of its children. pub fn new(mode: impl IntoValue, child: impl MakeWidget) -> Self { Self { @@ -20,7 +20,7 @@ impl ModeSwitch { } } -impl WrapperWidget for ModeSwitch { +impl WrapperWidget for ThemedMode { fn child_mut(&mut self) -> &mut WidgetRef { &mut self.child } diff --git a/src/widgets/resize.rs b/src/widgets/resize.rs index 241655af5..eb51cfc2a 100644 --- a/src/widgets/resize.rs +++ b/src/widgets/resize.rs @@ -1,5 +1,4 @@ -use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, ScreenScale, Size}; use crate::context::{AsEventContext, LayoutContext}; use crate::styles::DimensionRange; @@ -48,9 +47,12 @@ impl Resize { /// Resizes `self` to `width`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `width` can be an any of: + /// + /// - [`Dimension`](crate::styles::Dimension) + /// - [`Px`](crate::kludgine::figures::units::Px) + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); @@ -59,9 +61,12 @@ impl Resize { /// Resizes `self` to `height`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `height` can be an any of: + /// + /// - [`Dimension`](crate::styles::Dimension) + /// - [`Px`](crate::kludgine::figures::units::Px) + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); @@ -94,8 +99,8 @@ impl WrapperWidget for Resize { (self.width.exact_dimension(), self.height.exact_dimension()) { Size::new( - width.into_px(context.gfx.scale()).into_unsigned(), - height.into_px(context.gfx.scale()).into_unsigned(), + width.into_upx(context.gfx.scale()), + height.into_upx(context.gfx.scale()), ) } else { let available_space = Size::new( @@ -104,7 +109,7 @@ impl WrapperWidget for Resize { ); context.for_other(&child).layout(available_space) }; - Size::::new( + Size::new( self.width.clamp(size.width, context.gfx.scale()), self.height.clamp(size.height, context.gfx.scale()), ) @@ -121,9 +126,7 @@ fn override_constraint( match constraint { ConstraintLimit::Known(size) => ConstraintLimit::Known(range.clamp(size, scale)), ConstraintLimit::ClippedAfter(clipped_after) => match (range.minimum(), range.maximum()) { - (Some(min), Some(max)) if min == max => { - ConstraintLimit::Known(min.into_px(scale).into_unsigned()) - } + (Some(min), Some(max)) if min == max => ConstraintLimit::Known(min.into_upx(scale)), _ => ConstraintLimit::ClippedAfter(range.clamp(clipped_after, scale)), }, } diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index cd733ff83..91f848ec7 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -172,8 +172,7 @@ impl Widget for Scroll { let (mut scroll, current_max_scroll) = self.constrain_scroll(); let control_size = - Size::::new(available_space.width.max(), available_space.height.max()) - .into_signed(); + Size::new(available_space.width.max(), available_space.height.max()).into_signed(); let max_extents = Size::new( if self.enabled.x { ConstraintLimit::ClippedAfter((control_size.width).into_unsigned()) diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index bbc076d6c..a1fc92da3 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -1,18 +1,19 @@ +//! A widget that allows a user to "slide" between values. use std::fmt::Debug; use std::panic::UnwindSafe; use kludgine::app::winit::event::{DeviceId, MouseButton}; use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ - FloatConversion, FromComponents, IntoComponents, IntoSigned, IntoUnsigned, Point, Ranged, Rect, - ScreenScale, Size, + FloatConversion, FromComponents, IntoComponents, IntoSigned, Point, Ranged, Rect, ScreenScale, + Size, }; use kludgine::shapes::Shape; use kludgine::{Color, Origin}; use crate::animation::{LinearInterpolate, PercentBetween}; use crate::context::{EventContext, GraphicsContext, LayoutContext}; -use crate::styles::components::OpaqueWidgetColor; +use crate::styles::components::{OpaqueWidgetColor, WidgetAccentColor}; use crate::styles::Dimension; use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; use crate::widget::{EventHandling, Widget, HANDLED}; @@ -176,7 +177,7 @@ where let half_knob = knob_size / 2; - let mut value = self.value.get_tracked(context); + let mut value = self.value.get_tracking_refresh(context); let min = self.minimum.get_tracked(context); let mut max = self.maximum.get_tracked(context); @@ -222,14 +223,10 @@ where available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - self.knob_size = context - .get(&KnobSize) - .into_px(context.gfx.scale()) - .into_unsigned(); + self.knob_size = context.get(&KnobSize).into_upx(context.gfx.scale()); let minimum_size = context .get(&MinimumSliderSize) - .into_px(context.gfx.scale()) - .into_unsigned(); + .into_upx(context.gfx.scale()); match (available_space.width, available_space.height) { (ConstraintLimit::Known(width), ConstraintLimit::Known(height)) => { @@ -320,10 +317,51 @@ define_components! { /// The minimum length of the slidable dimension. MinimumSliderSize(Dimension, "minimum_size", |context| context.get(&KnobSize) * 2) /// The color of the draggable portion of the knob. - KnobColor(Color, "knob_color", .primary.color) // TODO make this pull from a component multiple widgets can share + KnobColor(Color, "knob_color", @WidgetAccentColor) /// The color of the track that the knob rests on. TrackColor(Color,"track_color", |context| context.get(&KnobColor)) /// The color of the track that the knob rests on. InactiveTrackColor(Color, "inactive_track_color", |context| context.get(&OpaqueWidgetColor)) } } + +/// A value that can be used in a [`Slider`] widget. +pub trait Slidable: IntoDynamic + Sized +where + T: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ + /// Returns a new slider over the full [range](Ranged) of the type. + fn slider(self) -> Slider + where + T: Ranged, + { + Slider::from_value(self.into_dynamic()) + } + + /// Returns a new slider using the value of `self`. The slider will be + /// limited to values between `min` and `max`. + fn slider_between(self, min: impl IntoValue, max: impl IntoValue) -> Slider { + Slider::new(self.into_dynamic(), min, max) + } +} + +impl Slidable for T +where + T: IntoDynamic, + U: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ +} diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 118cdb439..06b733add 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -296,7 +296,8 @@ struct Layout { total_weights: u32, allocated_space: (UPx, Lp), fractional: Vec<(LotId, u8)>, - measured: Vec, + fit_to_content: Vec, + premeasured: Vec, pub orientation: StackDirection, } @@ -316,7 +317,8 @@ impl Layout { total_weights: 0, allocated_space: (UPx(0), Lp(0)), fractional: Vec::new(), - measured: Vec::new(), + fit_to_content: Vec::new(), + premeasured: Vec::new(), } } @@ -331,20 +333,23 @@ impl Layout { match dimension { StackDimension::FitContent => { - self.measured.retain(|&measured| measured != id); + self.fit_to_content.retain(|&measured| measured != id); } StackDimension::Fractional { weight } => { self.fractional.retain(|(measured, _)| *measured != id); self.total_weights -= u32::from(weight); } - StackDimension::Measured { min, .. } => match min { - Dimension::Px(pixels) => { - self.allocated_space.0 -= pixels.into_unsigned(); - } - Dimension::Lp(lp) => { - self.allocated_space.1 -= lp; + StackDimension::Measured { min, .. } => { + self.premeasured.retain(|&measured| measured != id); + match min { + Dimension::Px(pixels) => { + self.allocated_space.0 -= pixels.into_unsigned(); + } + Dimension::Lp(lp) => { + self.allocated_space.1 -= lp; + } } - }, + } } dimension @@ -364,7 +369,7 @@ impl Layout { let id = self.children.insert(index, child); let layout = match child { StackDimension::FitContent => { - self.measured.push(id); + self.fit_to_content.push(id); UPx(0) } StackDimension::Fractional { weight } => { @@ -373,11 +378,12 @@ impl Layout { UPx(0) } StackDimension::Measured { min, .. } => { + self.premeasured.push(id); match min { Dimension::Px(size) => self.allocated_space.0 += size.into_unsigned(), Dimension::Lp(size) => self.allocated_space.1 += size, } - min.into_px(scale).into_unsigned() + min.into_upx(scale) } }; self.layouts.insert( @@ -397,23 +403,43 @@ impl Layout { ) -> Size { let (space_constraint, other_constraint) = self.orientation.split_size(available); let available_space = space_constraint.max(); - let allocated_space = - self.allocated_space.0 + self.allocated_space.1.into_px(scale).into_unsigned(); + let allocated_space = self.allocated_space.0 + self.allocated_space.1.into_upx(scale); let mut remaining = available_space.saturating_sub(allocated_space); + // If our `other_constraint` is not known, we will need to give child + // widgets an opportunity to lay themselves out in the full area. This + // requires one extra layout call, so we avoid persisting layouts during + // the first loop if this is the case. + let needs_final_layout = !matches!(other_constraint, ConstraintLimit::Known(_)); // Measure the children that fit their content - for &id in &self.measured { + self.other = UPx(0); + for &id in &self.fit_to_content { let index = self.children.index_of_id(id).expect("child not found"); - let (measured, _) = self.orientation.split_size(measure( + let (measured, other) = self.orientation.split_size(measure( index, self.orientation .make_size(ConstraintLimit::ClippedAfter(remaining), other_constraint), - false, + !needs_final_layout, )); self.layouts[index].size = measured; + self.other = self.other.max(other); remaining = remaining.saturating_sub(measured); } + // Measure measure the "other" dimension for children that we know their size already. + for &id in &self.premeasured { + let index = self.children.index_of_id(id).expect("child not found"); + let (_, other) = self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known(self.layouts[index].size), + other_constraint, + ), + !needs_final_layout, + )); + self.other = self.other.max(other); + } + // Measure the weighted children within the remaining space if self.total_weights > 0 { let space_per_weight = remaining / self.total_weights; @@ -435,24 +461,21 @@ impl Layout { self.layouts[index].size = size; } - } - // Now that we know the constrained sizes, we can measure the children - // to get the other measurement using the constrainted measurement. - self.other = UPx(0); - let mut offset = UPx(0); - for index in 0..self.children.len() { - self.layouts[index].offset = offset; - offset += self.layouts[index].size; - let (_, measured) = self.orientation.split_size(measure( - index, - self.orientation.make_size( - ConstraintLimit::Known(self.layouts[index].size.into_px(scale).into_unsigned()), - other_constraint, - ), - false, - )); - self.other = self.other.max(measured); + // Now that we know the constrained sizes, we can measure the children + // to get the other measurement using the constrainted measurement. + for (id, _) in &self.fractional { + let index = self.children.index_of_id(*id).expect("child not found"); + let (_, measured) = self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known(self.layouts[index].size.into_upx(scale)), + other_constraint, + ), + !needs_final_layout, + )); + self.other = self.other.max(measured); + } } self.other = match other_constraint { @@ -460,16 +483,21 @@ impl Layout { ConstraintLimit::ClippedAfter(clip_limit) => self.other.min(clip_limit), }; - // Finally layout the widgets with the final constraints + // Finally, compute the offsets of all of the widgets. + let mut offset = UPx(0); for index in 0..self.children.len() { - self.orientation.split_size(measure( - index, - self.orientation.make_size( - ConstraintLimit::Known(self.layouts[index].size.into_px(scale).into_unsigned()), - ConstraintLimit::Known(self.other), - ), - true, - )); + self.layouts[index].offset = offset; + offset += self.layouts[index].size; + if needs_final_layout { + self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known(self.layouts[index].size.into_upx(scale)), + ConstraintLimit::Known(self.other), + ), + true, + )); + } } self.orientation.make_size(offset, self.other) diff --git a/src/widgets/switcher.rs b/src/widgets/switcher.rs index fd9cd183c..d4b9ee960 100644 --- a/src/widgets/switcher.rs +++ b/src/widgets/switcher.rs @@ -1,58 +1,49 @@ use std::fmt::Debug; -use std::panic::UnwindSafe; use kludgine::figures::Size; use crate::context::{AsEventContext, LayoutContext}; -use crate::value::{Generation, IntoValue, Value}; -use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrapperWidget}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic}; +use crate::widget::{WidgetInstance, WidgetRef, WrapperWidget}; use crate::ConstraintLimit; /// A widget that switches its contents based on a value of `T`. -pub struct Switcher { - value: Value, - value_generation: Option, - factory: Box>, +#[derive(Debug)] +pub struct Switcher { + source: DynamicReader, child: WidgetRef, } -impl Switcher { - /// Returns a new widget that replaces its contents with the result of - /// `widget_factory` each time `value` changes. - #[must_use] - pub fn new(value: impl IntoValue, mut widget_factory: F) -> Self +impl Switcher { + /// Returns a new widget that replaces its contents with the results of + /// calling `map` each time `source` is updated. + /// + /// This function is equivalent to calling + /// `Self::new(source.into_dynamic().map_each(map))`, but this function's + /// signature helps the compiler's type inference work correctly. When using + /// new directly, the compiler often requires annotating the closure's + /// argument type. + pub fn mapping(source: impl IntoDynamic, mut map: F) -> Self where - F: for<'a> FnMut(&'a T) -> W + Send + UnwindSafe + 'static, - W: MakeWidget, + F: FnMut(&T, &Dynamic) -> WidgetInstance + Send + 'static, + T: Send + 'static, { - let value = value.into_value(); - let value_generation = value.generation(); - let child = WidgetRef::new(value.map(|value| widget_factory(value))); - Self { - value, - value_generation, - factory: Box::new(widget_factory), - child, - } + let source = source.into_dynamic(); + + Self::new(source.clone().map_each(move |value| map(value, &source))) } -} -impl Debug for Switcher -where - T: Debug, -{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Switcher") - .field("value", &self.value) - .field("child", &self.child) - .finish_non_exhaustive() + /// Returns a new widget that replaces its contents with the result of + /// `widget_factory` each time `value` changes. + #[must_use] + pub fn new(source: impl IntoDynamic) -> Self { + let mut source = source.into_dynamic().into_reader(); + let child = WidgetRef::new(source.get()); + Self { source, child } } } -impl WrapperWidget for Switcher -where - T: Debug + Send + UnwindSafe + 'static, -{ +impl WrapperWidget for Switcher { fn child_mut(&mut self) -> &mut WidgetRef { &mut self.child } @@ -63,11 +54,8 @@ where available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let current_generation = self.value.generation(); - if self.value_generation != current_generation { - self.value_generation = current_generation; - let new_child = WidgetRef::new(self.value.map(|value| self.factory.invoke(value))); - let removed = std::mem::replace(&mut self.child, new_child); + if self.source.has_updated() { + let removed = std::mem::replace(&mut self.child, WidgetRef::new(self.source.get())); if let WidgetRef::Mounted(removed) = removed { context.remove_child(&removed); } @@ -75,17 +63,3 @@ where available_space } } - -trait SwitchMap: UnwindSafe + Send { - fn invoke(&mut self, value: &T) -> WidgetInstance; -} - -impl SwitchMap for F -where - F: for<'a> FnMut(&'a T) -> W + Send + UnwindSafe, - W: MakeWidget, -{ - fn invoke(&mut self, value: &T) -> WidgetInstance { - self(value).make_widget() - } -} diff --git a/src/window.rs b/src/window.rs index f14c46e99..fdbc0f2d2 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,4 @@ -//! Types for displaying a [`Widget`](crate::widget::Widget) inside of a desktop -//! window. +//! Types for displaying a [`Widget`] inside of a desktop window. use std::cell::RefCell; use std::ffi::OsStr; @@ -7,10 +6,11 @@ use std::ops::{Deref, DerefMut, Not}; use std::panic::{AssertUnwindSafe, UnwindSafe}; use std::path::Path; use std::string::ToString; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use ahash::AHashMap; use alot::LotId; +use arboard::Clipboard; use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize}; use kludgine::app::winit::event::{ DeviceId, ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, @@ -27,14 +27,13 @@ use tracing::Level; use crate::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; use crate::context::{ - AsEventContext, EventContext, Exclusive, GraphicsContext, LayoutContext, RedrawStatus, + AsEventContext, EventContext, Exclusive, GraphicsContext, InvalidationStatus, LayoutContext, WidgetContext, }; use crate::graphics::Graphics; -use crate::styles::components::LayoutOrder; use crate::styles::ThemePair; use crate::tree::Tree; -use crate::utils::ModifiersExt; +use crate::utils::{IgnorePoison, ModifiersExt}; use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; use crate::widget::{ EventHandling, ManagedWidget, Widget, WidgetId, WidgetInstance, HANDLED, IGNORED, @@ -46,6 +45,7 @@ use crate::{initialize_tracing, ConstraintLimit, Run}; /// A currently running Gooey window. pub struct RunningWindow<'window> { window: kludgine::app::Window<'window, WindowCommand>, + clipboard: Option>>, focused: Dynamic, occluded: Dynamic, } @@ -53,11 +53,13 @@ pub struct RunningWindow<'window> { impl<'window> RunningWindow<'window> { pub(crate) fn new( window: kludgine::app::Window<'window, WindowCommand>, + clipboard: &Option>>, focused: &Dynamic, occluded: &Dynamic, ) -> Self { Self { window, + clipboard: clipboard.clone(), focused: focused.clone(), occluded: occluded.clone(), } @@ -76,6 +78,15 @@ impl<'window> RunningWindow<'window> { pub fn occluded(&self) -> &Dynamic { &self.occluded } + + /// Returns a locked mutex guard to the OS's clipboard, if one was able to be + /// initialized when the window opened. + #[must_use] + pub fn clipboard_guard(&mut self) -> Option> { + self.clipboard + .as_ref() + .map(|mutex| mutex.lock().ignore_poison()) + } } impl<'window> Deref for RunningWindow<'window> { @@ -139,7 +150,7 @@ impl Window { /// /// `focused` will be initialized with an initial state /// of `false`. - pub fn with_focused(mut self, focused: impl IntoDynamic) -> Self { + pub fn focused(mut self, focused: impl IntoDynamic) -> Self { let focused = focused.into_dynamic(); focused.update(false); self.focused = Some(focused); @@ -154,7 +165,7 @@ impl Window { /// visible, this value will contain `true`. /// /// `occluded` will be initialized with an initial state of `false`. - pub fn with_occluded(mut self, occluded: impl IntoDynamic) -> Self { + pub fn occluded(mut self, occluded: impl IntoDynamic) -> Self { let occluded = occluded.into_dynamic(); occluded.update(false); self.occluded = Some(occluded); @@ -174,10 +185,16 @@ impl Window { /// Setting the [`Dynamic`]'s value will also update the window with the new /// mode until a mode change is detected, upon which the new mode will be /// stored. - pub fn with_theme_mode(mut self, theme_mode: impl IntoValue) -> Self { + pub fn themed_mode(mut self, theme_mode: impl IntoValue) -> Self { self.theme_mode = Some(theme_mode.into_value()); self } + + /// Applies `theme` to the widgets in this window. + pub fn themed(mut self, theme: impl IntoValue) -> Self { + self.theme = theme.into_value(); + self + } } impl Window @@ -274,28 +291,34 @@ struct GooeyWindow { root: ManagedWidget, contents: Drawing, should_close: bool, - mouse_state: MouseState, - redraw_status: RedrawStatus, + cursor: CursorState, + mouse_buttons: AHashMap>, + redraw_status: InvalidationStatus, initial_frame: bool, occluded: Dynamic, focused: Dynamic, - keyboard_activated: Option, + keyboard_activated: Option, min_inner_size: Option>, max_inner_size: Option>, theme: Option>, current_theme: ThemePair, theme_mode: Value, transparent: bool, + clipboard: Option>>, } impl GooeyWindow where T: WindowBehavior, { - fn request_close(&mut self, window: &mut RunningWindow<'_>) -> bool { - self.should_close |= self.behavior.close_requested(window); + fn request_close( + should_close: &mut bool, + behavior: &mut T, + window: &mut RunningWindow<'_>, + ) -> bool { + *should_close |= behavior.close_requested(window); - self.should_close + *should_close } fn keyboard_activate_widget( @@ -307,7 +330,11 @@ where ) { if is_pressed { if let Some(default) = widget.and_then(|id| self.root.tree.widget_from_node(id)) { - if let Some(previously_active) = self.keyboard_activated.take() { + if let Some(previously_active) = self + .keyboard_activated + .take() + .and_then(|id| self.root.tree.widget(id)) + { EventContext::new( WidgetContext::new( previously_active, @@ -315,6 +342,7 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -327,13 +355,18 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) .activate(); - self.keyboard_activated = Some(default); + self.keyboard_activated = Some(default.id()); } - } else if let Some(keyboard_activated) = self.keyboard_activated.take() { + } else if let Some(keyboard_activated) = self + .keyboard_activated + .take() + .and_then(|id| self.root.tree.widget(id)) + { EventContext::new( WidgetContext::new( keyboard_activated, @@ -341,6 +374,7 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -377,14 +411,14 @@ where .map_or(Px::MAX, |height| height.into_px(graphics.scale())); let new_min_size = (min_width > 0 || min_height > 0) - .then_some(Size::::new(min_width, min_height).into_unsigned()); + .then_some(Size::new(min_width, min_height).into_unsigned()); if new_min_size != self.min_inner_size && resizable { window.set_min_inner_size(new_min_size); self.min_inner_size = new_min_size; } let new_max_size = (max_width > 0 || max_height > 0) - .then_some(Size::::new(max_width, max_height).into_unsigned()); + .then_some(Size::new(max_width, max_height).into_unsigned()); if new_max_size != self.max_inner_size && resizable { window.set_max_inner_size(new_max_size); @@ -437,6 +471,10 @@ where .take() .expect("theme always present"); + let clipboard = Clipboard::new() + .ok() + .map(|clipboard| Arc::new(Mutex::new(clipboard))); + let theme_mode = match context.settings.borrow_mut().theme_mode.take() { Some(Value::Dynamic(dynamic)) => { dynamic.update(window.theme().into()); @@ -447,7 +485,7 @@ where }; let transparent = context.settings.borrow().transparent; let mut behavior = T::initialize( - &mut RunningWindow::new(window, &focused, &occluded), + &mut RunningWindow::new(window, &clipboard, &focused, &occluded), context.user, ); let root = Tree::default().push_boxed(behavior.make_root(), None); @@ -462,12 +500,12 @@ where root, contents: Drawing::default(), should_close: false, - mouse_state: MouseState { + cursor: CursorState { location: None, widget: None, - devices: AHashMap::default(), }, - redraw_status: RedrawStatus::default(), + mouse_buttons: AHashMap::default(), + redraw_status: InvalidationStatus::default(), initial_frame: true, occluded, focused, @@ -478,6 +516,7 @@ where theme, theme_mode, transparent, + clipboard, } } @@ -497,13 +536,15 @@ where self.redraw_status.refresh_received(); graphics.reset_text_attributes(); - self.root.tree.reset_render_order(); + // TODO re-check why we can't add drain without a range to kempt. Or even intoiter. + let invalidations = std::mem::take(&mut *self.redraw_status.invalidations()); + self.root.tree.new_frame(invalidations.iter().copied()); let resizable = window.winit().is_resizable(); let is_expanded = self.constrain_window_resizing(resizable, &window, graphics); let graphics = self.contents.new_frame(graphics); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut context = GraphicsContext { widget: WidgetContext::new( self.root.clone(), @@ -511,6 +552,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), gfx: Exclusive::Owned(Graphics::new(graphics)), }; @@ -614,11 +656,11 @@ where window: kludgine::app::Window<'_, WindowCommand>, _kludgine: &mut Kludgine, ) -> bool { - self.request_close(&mut RunningWindow::new( - window, - &self.focused, - &self.occluded, - )) + Self::request_close( + &mut self.should_close, + &mut self.behavior, + &mut RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded), + ) } // fn power_preference() -> wgpu::PowerPreference { @@ -651,7 +693,13 @@ where // fn scale_factor_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} - // fn resized(&mut self, window: kludgine::app::Window<'_, ()>) {} + fn resized( + &mut self, + _window: kludgine::app::Window<'_, WindowCommand>, + _kludgine: &mut Kludgine, + ) { + self.root.invalidate(); + } // fn theme_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} @@ -672,12 +720,10 @@ where is_synthetic: bool, ) { let target = self.root.tree.focused_widget().unwrap_or(self.root.node_id); - let target = self - .root - .tree - .widget_from_node(target) - .expect("missing widget"); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let Some(target) = self.root.tree.widget_from_node(target) else { + return; + }; + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut target = EventContext::new( WidgetContext::new( target, @@ -685,6 +731,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -698,7 +745,13 @@ where if !handled { match input.logical_key { Key::Character(ch) if ch == "w" && window.modifiers().primary() => { - if input.state.is_pressed() && self.request_close(&mut window) { + if input.state.is_pressed() + && Self::request_close( + &mut self.should_close, + &mut self.behavior, + &mut window, + ) + { window.set_needs_redraw(); } } @@ -719,14 +772,15 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); - let mut visual_order = target.get(&LayoutOrder); if reverse { - visual_order = visual_order.rev(); + target.return_focus(); + } else { + target.advance_focus(); } - target.advance_focus(visual_order); } } Key::Enter => { @@ -778,7 +832,7 @@ where .expect("missing widget") }); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut widget = EventContext::new( WidgetContext::new( widget, @@ -786,6 +840,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -813,7 +868,7 @@ where .widget(self.root.id()) .expect("missing widget") }); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut target = EventContext::new( WidgetContext::new( widget, @@ -821,6 +876,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -837,10 +893,24 @@ where position: PhysicalPosition, ) { let location = Point::::from(position); - self.mouse_state.location = Some(location); + self.cursor.location = Some(location); + + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); + + EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.redraw_status, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ) + .update_hovered_widget(); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); - if let Some(state) = self.mouse_state.devices.get(&device_id) { + if let Some(state) = self.mouse_buttons.get(&device_id) { // Mouse Drag for (button, handler) in state { let Some(handler) = self.root.tree.widget(*handler) else { @@ -853,44 +923,15 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); - let last_rendered_at = context.last_layout().expect("passed hit test"); + let Some(last_rendered_at) = context.last_layout() else { + continue; + }; context.mouse_drag(location - last_rendered_at.origin, device_id, *button); } - } else { - // Hover - let mut context = EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.redraw_status, - &self.current_theme, - &mut window, - self.theme_mode.get(), - ), - kludgine, - ); - self.mouse_state.widget = None; - for widget in self.root.tree.widgets_at_point(location) { - let mut widget_context = context.for_other(&widget); - let relative = location - - widget_context - .last_layout() - .expect("passed hit test") - .origin; - - if widget_context.hit_test(relative) { - widget_context.hover(relative); - drop(widget_context); - self.mouse_state.widget = Some(widget.id()); - break; - } - } - - if self.mouse_state.widget.is_none() { - context.clear_hover(); - } } } @@ -900,8 +941,9 @@ where kludgine: &mut Kludgine, _device_id: DeviceId, ) { - if self.mouse_state.widget.take().is_some() { - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + if self.cursor.widget.take().is_some() { + let mut window = + RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut context = EventContext::new( WidgetContext::new( self.root.clone(), @@ -909,6 +951,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -924,7 +967,7 @@ where state: ElementState, button: MouseButton, ) { - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); match state { ElementState::Pressed => { EventContext::new( @@ -934,6 +977,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -941,10 +985,8 @@ where if let (ElementState::Pressed, Some(location), Some(hovered)) = ( state, - &self.mouse_state.location, - self.mouse_state - .widget - .and_then(|id| self.root.tree.widget(id)), + self.cursor.location, + self.cursor.widget.and_then(|id| self.root.tree.widget(id)), ) { if let Some(handler) = recursively_handle_event( &mut EventContext::new( @@ -954,17 +996,19 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ), |context| { - let relative = - *location - context.last_layout().expect("passed hit test").origin; + let Some(layout) = context.last_layout() else { + return IGNORED; + }; + let relative = location - layout.origin; context.mouse_down(relative, device_id, button) }, ) { - self.mouse_state - .devices + self.mouse_buttons .entry(device_id) .or_default() .insert(button, handler.id()); @@ -972,18 +1016,19 @@ where } } ElementState::Released => { - let Some(device_buttons) = self.mouse_state.devices.get_mut(&device_id) else { + let Some(device_buttons) = self.mouse_buttons.get_mut(&device_id) else { return; }; let Some(handler) = device_buttons.remove(&button) else { return; }; if device_buttons.is_empty() { - self.mouse_state.devices.remove(&device_id); + self.mouse_buttons.remove(&device_id); } let Some(handler) = self.root.tree.widget(handler) else { return; }; + let cursor_location = self.cursor.location; let mut context = EventContext::new( WidgetContext::new( handler, @@ -991,12 +1036,13 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); let relative = if let (Some(last_rendered), Some(location)) = - (context.last_layout(), self.mouse_state.location) + (context.last_layout(), cursor_location) { Some(location - last_rendered.origin) } else { @@ -1045,10 +1091,9 @@ fn recursively_handle_event( } #[derive(Default)] -struct MouseState { - location: Option>, - widget: Option, - devices: AHashMap>, +pub(crate) struct CursorState { + pub(crate) location: Option>, + pub(crate) widget: Option, } pub(crate) mod sealed { @@ -1072,6 +1117,7 @@ pub(crate) mod sealed { pub transparent: bool, } + #[derive(Clone)] pub enum WindowCommand { Redraw, // RequestClose, @@ -1079,7 +1125,7 @@ pub(crate) mod sealed { } /// Controls whether the light or dark theme is applied. -#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, LinearInterpolate)] pub enum ThemeMode { /// Applies the light theme Light, @@ -1130,16 +1176,6 @@ impl From for window::Theme { } } -impl LinearInterpolate for ThemeMode { - fn lerp(&self, target: &Self, percent: f32) -> Self { - if percent >= 0.5 { - *target - } else { - *self - } - } -} - impl PercentBetween for ThemeMode { fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { if *min == *max || *self == *min {