Merge branch 'main' into feature/world-coords

This commit is contained in:
Jonathan Johnson 2023-11-15 18:45:23 -08:00
commit a8841e6f1c
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
56 changed files with 3292 additions and 1001 deletions

View file

@ -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);
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 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()
// 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

View file

@ -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

266
Cargo.lock generated
View file

@ -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",

View file

@ -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"]

View file

@ -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);
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 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()
// 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

View file

@ -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(

20
examples/basic-button.rs Normal file
View file

@ -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

View file

@ -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

56
examples/buttons.rs Normal file
View file

@ -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()
}

19
examples/checkbox.rs Normal file
View file

@ -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()
}

View file

@ -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<ThemeMode>) -> 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(),
)

View file

@ -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;
}

84
examples/focus-order.rs Normal file
View file

@ -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()
}

View file

@ -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);

View file

@ -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()
}

View file

@ -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();

18
examples/nested-scroll.rs Normal file
View file

@ -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()
}

View file

@ -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()

67
examples/slider.rs Normal file
View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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<ActiveContent>) -> WidgetInstance {
const INTRO: &str = "This example demonstrates the Switcher<T> 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<ActiveContent>) -> WidgetInstance {
}
fn success(active: Dynamic<ActiveContent>) -> 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()

View file

@ -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<ThemeMode>, impl MakeWidget) {
@ -83,30 +81,18 @@ fn dark_mode_slider() -> (Dynamic<ThemeMode>, impl MakeWidget) {
(
theme_mode.clone(),
Stack::rows(Label::new("Theme Mode").and(Slider::<ThemeMode>::from_value(theme_mode))),
"Theme Mode".and(theme_mode.slider()).into_rows(),
)
}
fn create_paired_string<T>(initial_value: T) -> (Dynamic<T>, Dynamic<String>)
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<ColorSource>, 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::<f32>::new(hue, 0., 360.))
.and(Input::new(hue_text))
label
.and(hue.slider_between(0., 360.))
.and(hue_text.into_input())
.and(Slider::<ZeroToOne>::from_value(saturation))
.and(Input::new(saturation_text)),
.and(saturation_text.into_input()),
),
)
}
@ -128,69 +114,64 @@ fn fixed_themes(
secondary: Dynamic<FixedTheme>,
tertiary: Dynamic<FixedTheme>,
) -> 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<FixedTheme>, 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<Theme>, 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<SurfaceTheme>) -> impl MakeWidget {
fn color_theme(theme: Dynamic<ColorTheme>, 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<ColorTheme>, 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<ColorTheme>, label: &str) -> impl MakeWidget {
}
fn swatch(background: Dynamic<Color>, label: &str, text: Dynamic<Color>) -> impl MakeWidget {
Label::new(label)
label
.with(&TextColor, text)
.with(&WidgetBackground, background)
.fit_horizontally()

204
examples/tic-tac-toe.rs Normal file
View file

@ -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<Player>),
}
#[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<AppState>,
current_player: Player,
cells: Vec<Option<Player>>,
}
impl GameState {
fn new_game(app: &Dynamic<AppState>) -> 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<Player> {
// 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<Player> {
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<Player> {
self.cells[row * 3 + column]
}
}
fn game_end(winner: Option<Player>, app: &Dynamic<AppState>) -> 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<AppState>) -> 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<GameState>) -> 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<GameState>) -> 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<GameState>) -> 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()
}

View file

@ -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"

View file

@ -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<TokenStream> {
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::<Result<Vec<_>>>()?;
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{}
}
}

View file

@ -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))]

View file

@ -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!(),
}
}
}

View file

@ -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,
}
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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<Animating> = 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<T: LinearInterpolate + Debug + Eq>(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>(T);
impl<T> LinearInterpolate for BinaryLerp<T>
@ -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>(T);
impl<T> LinearInterpolate for ImmediateLerp<T>
@ -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<ZeroToOne> {
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<f32> 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<std::cmp::Ordering> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialOrd<f32> for ZeroToOne {
fn partial_cmp(&self, other: &f32) -> Option<std::cmp::Ordering> {
fn partial_cmp(&self, other: &f32) -> Option<Ordering> {
Some(self.0.total_cmp(other))
}
}
@ -1012,6 +1099,12 @@ impl TryFrom<Component> 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

View file

@ -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<ManagedWidget> {
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<WidgetId> {
// 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<ManagedWidget> {
self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, order)
advance: bool,
) -> Option<WidgetId> {
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<WidgetId>,
stop_at: WidgetId,
order: VisualOrder,
) -> Option<ManagedWidget> {
advance: bool,
) -> Option<WidgetId> {
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<Unit>(&mut self, color: Color, options: StrokeOptions<Unit>)
where
Unit: ScreenScale<Px = Px, Lp = Lp>,
Unit: ScreenScale<Px = Px, Lp = Lp, UPx = UPx>,
{
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<ConstraintLimit>) -> Size<UPx> {
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<ManagedWidget> {
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<T>(&self, value: &Dynamic<T>) {
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<Rect<Px>> {
@ -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<WindowCommand>,
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<H: std::hash::Hasher>(&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<ManagedWidget>,
active: Option<ManagedWidget>,
focus_is_advancing: bool,
focus: Option<WidgetId>,
active: Option<WidgetId>,
}
impl PendingState<'_> {
@ -1036,11 +1169,12 @@ impl DerefMut for PendingState<'_> {
}
#[derive(Default, Clone)]
pub(crate) struct RedrawStatus {
pub(crate) struct InvalidationStatus {
refresh_sent: Arc<AtomicBool>,
invalidated: Arc<Mutex<Set<WidgetId>>>,
}
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<WidgetId>> {
self.invalidated.lock().ignore_poison()
}
}
/// A type chat can convert to a [`ManagedWidget`] through a [`WidgetContext`].
@ -1113,3 +1256,22 @@ impl<T> MapManagedWidget<T> 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,
}
}
}

View file

@ -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,

View file

@ -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);
<Named::ComponentType>::try_from_component(component.get()).ok()
match <Named::ComponentType>::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<Component> for Color {
}
}
impl RequireInvalidation for Color {
fn requires_invalidation(&self) -> bool {
false
}
}
impl From<Dimension> for Component {
fn from(value: Dimension) -> Self {
Self::Dimension(value)
@ -236,6 +251,12 @@ impl TryFrom<Component> for Dimension {
}
}
impl RequireInvalidation for Dimension {
fn requires_invalidation(&self) -> bool {
true
}
}
impl From<Px> for Component {
fn from(value: Px) -> Self {
Self::from(Dimension::from(value))
@ -253,6 +274,12 @@ impl TryFrom<Component> for Px {
}
}
impl RequireInvalidation for Px {
fn requires_invalidation(&self) -> bool {
true
}
}
impl From<Lp> for Component {
fn from(value: Lp) -> Self {
Self::from(Dimension::from(value))
@ -270,6 +297,12 @@ impl TryFrom<Component> 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<Lp> 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<i32> 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<Component> for DimensionRange {
}
}
impl RequireInvalidation for DimensionRange {
fn requires_invalidation(&self) -> bool {
true
}
}
/// A custom component value.
#[derive(Debug, Clone)]
pub struct CustomComponent(Arc<dyn AnyComponent>);
@ -571,7 +622,7 @@ impl CustomComponent {
/// Wraps an arbitrary value so that it can be used as a [`Component`].
pub fn new<T>(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<T> 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<T> ComponentType for T
where
T: Into<Component> + TryFrom<Component, Error = Component>,
T: RequireInvalidation + Into<Component> + TryFrom<Component, Error = Component>,
{
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<Component> 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<Component> 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<Component> 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<ColorSource>,
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<OklabHue>) -> 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<OklabHue>) -> 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<ZeroToOne>;
/// Returns a color source built from this prototype color
#[must_use]
fn into_source(self, saturation_if_not_provided: impl Into<ZeroToOne>) -> 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<ZeroToOne> {
None
}
}
impl ProtoColor for OklabHue {
fn hue(&self) -> OklabHue {
*self
}
fn saturation(&self) -> Option<ZeroToOne> {
None
}
}
impl ProtoColor for ColorSource {
fn hue(&self) -> OklabHue {
self.hue
}
fn saturation(&self) -> Option<ZeroToOne> {
Some(self.saturation)
}
}
impl<Hue, Saturation> ProtoColor for (Hue, Saturation)
where
Hue: Into<OklabHue> + Copy,
Saturation: Into<ZeroToOne> + Copy,
{
fn hue(&self) -> OklabHue {
self.0.into()
}
fn saturation(&self) -> Option<ZeroToOne> {
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<OklabHue>) -> Self {
ColorSchemeBuilder::from_hue(hue).build()
}
}
impl Default for ColorScheme {
fn default() -> Self {
Self::from_primary_hue(138.5)
Self::from_primary(138.5)
}
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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<Px>) {
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<Rect<Px>> {
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<Item = WidgetId>) {
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();
for child in children {
data.nodes.get_mut(child).expect("missing widget").layout = None;
pub(crate) fn begin_layout(
&self,
parent: LotId,
constraints: Size<ConstraintLimit>,
) -> Option<Size<UPx>> {
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.invalidate(child, false);
}
None
}
pub(crate) fn persist_layout(
&self,
id: LotId,
constraints: Size<ConstraintLimit>,
size: Size<UPx>,
) {
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<ManagedWidget> {
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::<ManagedWidget>::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<Option<ManagedWidget>, ()> {
let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g);
pub fn focus(&self, new_focus: Option<WidgetId>) -> Result<Option<ManagedWidget>, ()> {
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<ManagedWidget> {
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<Option<ManagedWidget>, ()> {
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<ManagedWidget> {
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<ManagedWidget> {
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<LotId> {
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<LotId> {
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<LotId> {
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<LotId> {
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<LotId> {
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<Px>) -> Vec<ManagedWidget> {
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<Px>) -> Vec<ManagedWidget> {
let data = self.data.lock().ignore_poison();
data.render_info.widgets_under_point(point, &data, self)
}
pub(crate) fn parent(&self, id: LotId) -> Option<LotId> {
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<Styles>) {
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<ThemePair>) {
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<ThemeMode>) {
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<Value<ThemePair>>, Option<Value<ThemeMode>>) {
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<LotId>,
defaults: Vec<LotId>,
escapes: Vec<LotId>,
render_order: Vec<LotId>,
previous_focuses: AHashMap<LotId, LotId>,
render_info: RenderInfo,
previous_focuses: AHashMap<WidgetId, WidgetId>,
}
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<WidgetId>,
tree: &Tree,
property: impl FnOnce(&mut Self) -> &mut Option<LotId>,
) -> Result<Option<ManagedWidget>, ()> {
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];
}
}
}
pub struct Node {
pub widget: WidgetInstance,
pub children: Vec<LotId>,
pub parent: Option<LotId>,
pub layout: Option<Rect<Px>>,
pub associated_styles: Option<Value<Styles>>,
pub effective_styles: Styles,
pub theme: Option<Value<ThemePair>>,
pub theme_mode: Option<Value<ThemeMode>>,
#[derive(Default)]
struct RenderInfo {
order: Vec<RenderArea>,
}
impl RenderInfo {
pub fn push(&mut self, node: LotId, region: Rect<Px>) {
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<Px>,
tree_data: &TreeData,
tree: &Tree,
) -> Vec<ManagedWidget> {
// 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<Px>,
max: Point<Px>,
}
impl RenderArea {
fn new(node: LotId, area: Rect<Px>) -> Self {
let (min, max) = area.extents();
Self { node, min, max }
}
}
struct Node {
widget: WidgetInstance,
children: Vec<LotId>,
parent: Option<LotId>,
layout: Option<Rect<Px>>,
last_layout_query: Option<CachedLayoutQuery>,
associated_styles: Option<Value<Styles>>,
effective_styles: Styles,
theme: Option<Value<ThemePair>>,
theme_mode: Option<Value<ThemeMode>>,
}
impl Node {
@ -475,3 +594,8 @@ impl Node {
effective_styles
}
}
struct CachedLayoutQuery {
constraints: Size<ConstraintLimit>,
size: Size<UPx>,
}

View file

@ -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<T> Deref for Lazy<T> {
self.once.get_or_init(self.init)
}
}
pub trait IgnorePoison {
type Unwrapped;
fn ignore_poison(self) -> Self::Unwrapped;
}
impl<T> IgnorePoison for Result<T, PoisonError<T>> {
type Unwrapped = T;
fn ignore_poison(self) -> Self::Unwrapped {
self.map_or_else(PoisonError::into_inner, |g| g)
}
}

View file

@ -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<T> Dynamic<T> {
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<T>`. 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<R>`. 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<R, TIntoR, TIntoRResult, RIntoT, RIntoTResult>(
&self,
mut t_into_r: TIntoR,
mut r_into_t: RIntoT,
) -> Dynamic<R>
where
T: PartialEq + Send + 'static,
R: PartialEq + Send + 'static,
TIntoRResult: Into<Option<R>> + Send + 'static,
RIntoTResult: Into<Option<T>> + 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<String>
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<T> Dynamic<T> {
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<T> Dynamic<T> {
/// 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<T> Dynamic<T> {
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<T> Dynamic<T> {
}
}
impl Dynamic<WidgetInstance> {
/// Returns a new [`Switcher`] widget whose contents is the value of this
/// dynamic.
#[must_use]
pub fn switcher(self) -> Switcher {
Switcher::new(self)
}
}
impl<T> Default for Dynamic<T>
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<T> {
impl<T> DynamicData<T> {
fn state(&self) -> Result<DynamicMutexGuard<'_, T>, 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<T> DynamicData<T> {
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<T> DynamicData<T> {
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<GenerationalValue<T>, DeadlockError>
@ -579,7 +677,8 @@ impl Display for DeadlockError {
struct State<T> {
wrapped: GenerationalValue<T>,
callbacks: Vec<Box<dyn ValueCallback<T>>>,
windows: Vec<WindowHandle>,
windows: AHashSet<WindowHandle>,
widgets: AHashSet<(WindowHandle, WidgetId)>,
wakers: Vec<Waker>,
readers: usize,
}
@ -591,7 +690,10 @@ impl<T> State<T> {
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<T> DynamicReader<T> {
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<T> DynamicReader<T> {
/// 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<T> DynamicReader<T> {
"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<T> DynamicReader<T> {
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<T>: IntoDynamic<T> + Sized {
/// Returns a new [`Switcher`] whose contents is the result of invoking
/// `map` each time `self` is updated.
fn switcher<F>(self, map: F) -> Switcher
where
F: FnMut(&T, &Dynamic<T>) -> WidgetInstance + Send + 'static,
T: Send + 'static,
{
Switcher::mapping(self, map)
}
}
impl<T, W> Switchable<T> for W where W: IntoDynamic<T> {}
/// A value that may be either constant or dynamic.
#[derive(Debug)]
pub enum Value<T> {
@ -936,7 +1080,11 @@ impl<T> Value<T> {
///
/// If `self` is a dynamic, `context` will be invalidated when the value is
/// updated.
pub fn map_tracked<R>(&self, context: &WidgetContext<'_, '_>, map: impl FnOnce(&T) -> R) -> R {
pub fn map_tracking_redraw<R>(
&self,
context: &WidgetContext<'_, '_>,
map: impl FnOnce(&T) -> R,
) -> R {
match self {
Value::Constant(value) => map(value),
Value::Dynamic(dynamic) => {
@ -946,6 +1094,24 @@ impl<T> Value<T> {
}
}
/// 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<R>(
&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<R>(&mut self, map: impl FnOnce(&mut T) -> R) -> R {
match self {
@ -984,7 +1150,7 @@ impl<T> Value<T> {
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<T> Value<T> {
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<T> Clone for Value<T>
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<String>`].
pub trait StringValue: IntoValue<String> + Sized {
/// Returns this string as a text input widget.
fn into_input(self) -> Input {
Input::new(self.into_value())
}
}
impl<T> StringValue for T where T: IntoValue<String> {}

View file

@ -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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> WrappedLayout {
Size::<UPx>::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<bool>) -> 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<DimensionRange>) -> 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<DimensionRange>) -> 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<Edges<Dimension>>) -> Container {
self.contain().transparent().pad_by(padding)
}
/// Applies `theme` to `self` and its children.
fn themed(self, theme: impl IntoValue<ThemePair>) -> Themed {
Themed::new(theme, self)
}
/// Applies `mode` to `self` and its children.
fn themed_mode(self, mode: impl IntoValue<ThemeMode>) -> 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<Option<WidgetId>>,
enabled: Value<bool>,
widget: Box<Mutex<dyn AnyWidget>>,
}
@ -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<bool>) -> 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<WidgetId> 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<Px>) {
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<ManagedWidget> {
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<ManagedWidget> {
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<Rect<Px>> {
@ -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<ConstraintLimit>) -> Option<Size<UPx>> {
self.tree.begin_layout(self.node_id, constraints)
}
pub(crate) fn persist_layout(&self, constraints: Size<ConstraintLimit>, size: Size<UPx>) {
self.tree.persist_layout(self.node_id, constraints, size);
}
pub(crate) fn visually_ordered_children(&self, order: VisualOrder) -> Vec<ManagedWidget> {
@ -1343,7 +1453,7 @@ impl AsRef<WidgetId> 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 {

View file

@ -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;

View file

@ -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 }
}

View file

@ -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::styles::components::{
AutoFocusableControls, Easing, IntrinsicPadding, OpaqueWidgetColor, SurfaceColor, TextColor,
use crate::context::{
AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetCacheKey, WidgetContext,
};
use crate::styles::Styles;
use crate::styles::components::{
AutoFocusableControls, Easing, HighlightColor, IntrinsicPadding, OpaqueWidgetColor,
OutlineColor, SurfaceColor, TextColor,
};
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<Callback<()>>,
/// The enabled state of the button.
pub enabled: Value<bool>,
currently_enabled: bool,
/// The kind of button to draw.
pub kind: Value<ButtonKind>,
buttons_pressed: usize,
active_style: Option<Dynamic<ButtonStyle>>,
cached_state: CacheState,
active_colors: Option<Dynamic<ButtonColors>>,
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<ButtonKind>) -> 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<bool>) -> 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 {
self.update_colors(context, false);
self.currently_enabled = enabled;
}
self.enabled.redraw_when_changed(context);
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);
}
let style = self.current_style(context);
context.gfx.fill(style.background);
context.stroke_outline::<Lp>(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 (<https://github.com/khonsulabs/gooey/issues/73>),
// 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<crate::ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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)
}
}

233
src/widgets/checkbox.rs Normal file
View file

@ -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<CheckboxState>,
/// The button kind to use as the basis for this checkbox. Checkboxes
/// default to [`ButtonKind::Transparent`].
pub kind: Value<ButtonKind>,
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<bool>` if there is no need to represent
/// an indeterminant state.
pub fn new(state: impl IntoDynamic<CheckboxState>, 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<ButtonKind>) -> 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<bool> for CheckboxState {
fn from(value: bool) -> Self {
if value {
Self::Checked
} else {
Self::Unchecked
}
}
}
impl TryFrom<CheckboxState> for bool {
type Error = CheckboxToBoolError;
fn try_from(value: CheckboxState) -> Result<Self, Self::Error> {
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<CheckboxState> for Dynamic<bool> {
fn into_dynamic(self) -> Dynamic<CheckboxState> {
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<CheckboxState>,
label: WidgetRef,
}
impl WrapperWidget for CheckboxLabel {
fn child_mut(&mut self) -> &mut WidgetRef {
&mut self.label
}
fn position_child(
&mut self,
size: Size<Px>,
_available_space: Size<ConstraintLimit>,
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,
);
}
}
}
}

View file

@ -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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<ConstraintLimit> {
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<EffectiveBackground> for Component {
}
}
impl RequireInvalidation for EffectiveBackground {
fn requires_invalidation(&self) -> bool {
false
}
}
define_components! {
Container {
/// The container background behind the current widget.

View file

@ -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::<UPx>::new(width, height).into_signed().into()
Size::new(width, height).into_signed().into()
}
}

View file

@ -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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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();

View file

@ -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<String>,
prepared_text: Option<(MeasuredText<Px>, Px, Color)>,
prepared_text: Option<(MeasuredText<Px>, Option<Generation>, Px, Color)>,
}
impl Label {
@ -34,29 +34,34 @@ impl Label {
color: Color,
width: Px,
) -> &MeasuredText<Px> {
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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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);

View file

@ -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<ThemeMode>,
child: WidgetRef,
}
impl ModeSwitch {
impl ThemedMode {
/// Returns a new widget that applies `mode` to all of its children.
pub fn new(mode: impl IntoValue<ThemeMode>, 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
}

View file

@ -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<DimensionRange>) -> 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<DimensionRange>) -> 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::<UPx>::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)),
},
}

View file

@ -172,8 +172,7 @@ impl Widget for Scroll {
let (mut scroll, current_max_scroll) = self.constrain_scroll();
let control_size =
Size::<UPx>::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())

View file

@ -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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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<T>: IntoDynamic<T> + 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<T>
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<T>, max: impl IntoValue<T>) -> Slider<T> {
Slider::new(self.into_dynamic(), min, max)
}
}
impl<U, T> Slidable<U> for T
where
T: IntoDynamic<U>,
U: Clone
+ Debug
+ PartialOrd
+ LinearInterpolate
+ PercentBetween
+ UnwindSafe
+ Send
+ 'static,
{
}

View file

@ -296,7 +296,8 @@ struct Layout {
total_weights: u32,
allocated_space: (UPx, Lp),
fractional: Vec<(LotId, u8)>,
measured: Vec<LotId>,
fit_to_content: Vec<LotId>,
premeasured: Vec<LotId>,
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();
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::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<UPx> {
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)

View file

@ -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<T> {
value: Value<T>,
value_generation: Option<Generation>,
factory: Box<dyn SwitchMap<T>>,
#[derive(Debug)]
pub struct Switcher {
source: DynamicReader<WidgetInstance>,
child: WidgetRef,
}
impl<T> Switcher<T> {
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<T, F>(source: impl IntoDynamic<T>, mut map: F) -> Self
where
F: FnMut(&T, &Dynamic<T>) -> WidgetInstance + Send + 'static,
T: Send + 'static,
{
let source = source.into_dynamic();
Self::new(source.clone().map_each(move |value| map(value, &source)))
}
/// Returns a new widget that replaces its contents with the result of
/// `widget_factory` each time `value` changes.
#[must_use]
pub fn new<W, F>(value: impl IntoValue<T>, mut widget_factory: F) -> Self
where
F: for<'a> FnMut(&'a T) -> W + Send + UnwindSafe + 'static,
W: MakeWidget,
{
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,
}
pub fn new(source: impl IntoDynamic<WidgetInstance>) -> Self {
let mut source = source.into_dynamic().into_reader();
let child = WidgetRef::new(source.get());
Self { source, child }
}
}
impl<T> Debug for Switcher<T>
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()
}
}
impl<T> WrapperWidget for Switcher<T>
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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<ConstraintLimit> {
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<T>: UnwindSafe + Send {
fn invoke(&mut self, value: &T) -> WidgetInstance;
}
impl<W, T, F> SwitchMap<T> 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()
}
}

View file

@ -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<Arc<Mutex<Clipboard>>>,
focused: Dynamic<bool>,
occluded: Dynamic<bool>,
}
@ -53,11 +53,13 @@ pub struct RunningWindow<'window> {
impl<'window> RunningWindow<'window> {
pub(crate) fn new(
window: kludgine::app::Window<'window, WindowCommand>,
clipboard: &Option<Arc<Mutex<Clipboard>>>,
focused: &Dynamic<bool>,
occluded: &Dynamic<bool>,
) -> 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<bool> {
&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<MutexGuard<'_, Clipboard>> {
self.clipboard
.as_ref()
.map(|mutex| mutex.lock().ignore_poison())
}
}
impl<'window> Deref for RunningWindow<'window> {
@ -139,7 +150,7 @@ impl Window<WidgetInstance> {
///
/// `focused` will be initialized with an initial state
/// of `false`.
pub fn with_focused(mut self, focused: impl IntoDynamic<bool>) -> Self {
pub fn focused(mut self, focused: impl IntoDynamic<bool>) -> Self {
let focused = focused.into_dynamic();
focused.update(false);
self.focused = Some(focused);
@ -154,7 +165,7 @@ impl Window<WidgetInstance> {
/// 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<bool>) -> Self {
pub fn occluded(mut self, occluded: impl IntoDynamic<bool>) -> Self {
let occluded = occluded.into_dynamic();
occluded.update(false);
self.occluded = Some(occluded);
@ -174,10 +185,16 @@ impl Window<WidgetInstance> {
/// 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<ThemeMode>) -> Self {
pub fn themed_mode(mut self, theme_mode: impl IntoValue<ThemeMode>) -> 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<ThemePair>) -> Self {
self.theme = theme.into_value();
self
}
}
impl<Behavior> Window<Behavior>
@ -274,28 +291,34 @@ struct GooeyWindow<T> {
root: ManagedWidget,
contents: Drawing,
should_close: bool,
mouse_state: MouseState,
redraw_status: RedrawStatus,
cursor: CursorState,
mouse_buttons: AHashMap<DeviceId, AHashMap<MouseButton, WidgetId>>,
redraw_status: InvalidationStatus,
initial_frame: bool,
occluded: Dynamic<bool>,
focused: Dynamic<bool>,
keyboard_activated: Option<ManagedWidget>,
keyboard_activated: Option<WidgetId>,
min_inner_size: Option<Size<UPx>>,
max_inner_size: Option<Size<UPx>>,
theme: Option<DynamicReader<ThemePair>>,
current_theme: ThemePair,
theme_mode: Value<ThemeMode>,
transparent: bool,
clipboard: Option<Arc<Mutex<Clipboard>>>,
}
impl<T> GooeyWindow<T>
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::<Px>::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::<Px>::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<f64>,
) {
let location = Point::<Px>::from(position);
self.mouse_state.location = Some(location);
self.cursor.location = Some(location);
let mut window = RunningWindow::new(window, &self.focused, &self.occluded);
if let Some(state) = self.mouse_state.devices.get(&device_id) {
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();
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<Point<Px>>,
widget: Option<WidgetId>,
devices: AHashMap<DeviceId, AHashMap<MouseButton, WidgetId>>,
pub(crate) struct CursorState {
pub(crate) location: Option<Point<Px>>,
pub(crate) widget: Option<WidgetId>,
}
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<ThemeMode> 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 {