diff --git a/Cargo.lock b/Cargo.lock index 8117e90..ab0d3d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "ab_glyph" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1061f3ff92c2f65800df1f12fc7b4ff44ee14783104187dd04dfee6f11b0fd2" +checksum = "80179d7dd5d7e8c285d67c4a1e652972a92de7475beddfb92028c76463b13225" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -457,7 +457,7 @@ dependencies = [ [[package]] name = "figures" version = "0.1.0" -source = "git+https://github.com/khonsulabs/figures#5b00ee6772e8b59067bc69f2f4eeb06cc0cc62b7" +source = "git+https://github.com/khonsulabs/figures#29a89d05f9fc8e81c5071fad49456329203d3bfc" dependencies = [ "bytemuck", "euclid", @@ -636,6 +636,7 @@ version = "0.1.0" dependencies = [ "alot", "interner", + "kempt", "kludgine", ] @@ -755,9 +756,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown", @@ -813,13 +814,19 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.64" +version = "0.3.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kempt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f97ab12df8bfb8a0a286ae251d3c46699c58bf5c2d280533e854e965965412" + [[package]] name = "khronos-egl" version = "6.0.0" @@ -840,6 +847,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" +source = "git+https://github.com/khonsulabs/kludgine#35b83e14e71a02f3cd9a96e865094f5b3ad1407c" dependencies = [ "ahash", "alot", @@ -1241,11 +1249,11 @@ dependencies = [ [[package]] name = "owned_ttf_parser" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +checksum = "d4586edfe4c648c71797a74c84bacb32b52b212eff5dfe2bb9f2c599844023e7" dependencies = [ - "ttf-parser 0.19.2", + "ttf-parser 0.20.0", ] [[package]] @@ -1803,9 +1811,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1813,9 +1821,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" dependencies = [ "bumpalo", "log", @@ -1828,9 +1836,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03" +checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" dependencies = [ "cfg-if", "js-sys", @@ -1840,9 +1848,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1850,9 +1858,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" dependencies = [ "proc-macro2", "quote", @@ -1863,9 +1871,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.87" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" [[package]] name = "wayland-backend" @@ -2322,9 +2330,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c" +checksum = "176b6138793677221d420fd2f0aeeced263f197688b36484660da767bca2fa32" dependencies = [ "memchr", ] @@ -2420,18 +2428,18 @@ checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" [[package]] name = "zerocopy" -version = "0.7.18" +version = "0.7.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7d7c7970ca2215b8c1ccf4d4f354c4733201dfaaba72d44ae5b37472e4901" +checksum = "686b7e407015242119c33dab17b8f61ba6843534de936d94368856528eae4dcc" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.18" +version = "0.7.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b27b1bb92570f989aac0ab7e9cbfbacdd65973f7ee920d9f0e71ebac878fd0b" +checksum = "020f3dfe25dfc38dfea49ce62d5d45ecdd7f0d8a724fa63eb36b6eba4ec76806" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7350bd7..9029fcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,15 @@ kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [ ] } alot = "0.3" interner = "0.2.1" -# appit = { git = "https://github.com/khonsulabs/appit" } +kempt = "0.2.1" -[patch."https://github.com/khonsulabs/kludgine"] -kludgine = { path = "../kludgine2" } +# [patch."https://github.com/khonsulabs/kludgine"] +# kludgine = { path = "../kludgine2" } # [patch."https://github.com/khonsulabs/figures"] # figures = { path = "../figures" } + +# [patch.crates-io] +# kempt = { path = "../objectmap" } + +[profile.dev.package."*"] +opt-level = 2 diff --git a/examples/animation.rs b/examples/animation.rs new file mode 100644 index 0000000..972bf3b --- /dev/null +++ b/examples/animation.rs @@ -0,0 +1,30 @@ +use std::time::Duration; + +use gooey::animation::{Animation, AnimationHandle, Spawn}; +use gooey::value::Dynamic; +use gooey::widgets::{Button, Label, Stack}; +use gooey::{widgets, Run, WithClone}; + +fn main() -> gooey::Result { + let animation = Dynamic::new(AnimationHandle::new()); + let value = Dynamic::new(50); + let label = value.map_each(|value| value.to_string()); + Stack::columns(widgets![ + Button::new("To 0").on_click(animate_to(&animation, &value, 0)), + Label::new(label), + Button::new("To 100").on_click(animate_to(&animation, &value, 100)), + ]) + .run() +} + +fn animate_to( + animation: &Dynamic, + value: &Dynamic, + target: u8, +) -> impl FnMut(()) { + (animation, value).with_clone(|(animation, value)| { + move |_| { + animation.set(Animation::linear(value.clone(), target, Duration::from_secs(1)).spawn()) + } + }) +} diff --git a/examples/counter.rs b/examples/counter.rs index 7d1bf41..629cf8a 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -1,14 +1,13 @@ use std::string::ToString; use gooey::value::Dynamic; -use gooey::widgets::stack::Stack; -use gooey::widgets::{Button, Label}; +use gooey::widgets::{Button, Label, Scroll, Stack}; use gooey::{widgets, Run}; fn main() -> gooey::Result { let counter = Dynamic::new(0i32); let label = counter.map_each(ToString::to_string); - Stack::rows(widgets![ + Scroll::new(Stack::rows(widgets![ Label::new(label), Button::new("+").on_click(counter.with_clone(|counter| { move |_| { @@ -20,6 +19,6 @@ fn main() -> gooey::Result { counter.set(counter.get() - 1); } })), - ]) + ])) .run() } diff --git a/examples/scroll.rs b/examples/scroll.rs new file mode 100644 index 0000000..249c48f --- /dev/null +++ b/examples/scroll.rs @@ -0,0 +1,21 @@ +use gooey::value::Dynamic; +use gooey::widget::Widgets; +use gooey::widgets::{Button, Scroll, Stack}; +use gooey::Run; + +fn main() -> gooey::Result { + Scroll::new(Stack::rows( + (0..30) + .map(|i| { + let count = Dynamic::new(0); + + Button::new(count.map_each(move |count| format!("Row {i}: {count}"))).on_click( + move |_| { + count.map_mut(|count| *count += 1); + }, + ) + }) + .collect::(), + )) + .run() +} diff --git a/src/animation.rs b/src/animation.rs new file mode 100644 index 0000000..0b1c892 --- /dev/null +++ b/src/animation.rs @@ -0,0 +1,509 @@ +//! Types for creating animations. + +use std::fmt::Debug; +use std::marker::PhantomData; +use std::ops::{ControlFlow, Deref}; +use std::sync::{Condvar, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::thread; +use std::time::{Duration, Instant}; + +use alot::{LotId, Lots}; +use kempt::Set; + +use crate::value::Dynamic; + +static ANIMATIONS: Mutex = Mutex::new(Animating::new()); +static NEW_ANIMATIONS: Condvar = Condvar::new(); + +fn thread_state() -> MutexGuard<'static, Animating> { + static THREAD: OnceLock<()> = OnceLock::new(); + THREAD.get_or_init(|| { + thread::spawn(animation_thread); + }); + ANIMATIONS + .lock() + .map_or_else(PoisonError::into_inner, |g| g) +} + +fn animation_thread() { + let mut state = thread_state(); + loop { + if state.running.is_empty() { + state.last_updated = None; + state = NEW_ANIMATIONS + .wait(state) + .map_or_else(PoisonError::into_inner, |g| g); + } else { + let start = Instant::now(); + let last_tick = state.last_updated.unwrap_or(start); + let elapsed = start - last_tick; + state.last_updated = Some(start); + + let mut index = 0; + while index < state.running.len() { + let animation_id = *state.running.member(index).expect("index in bounds"); + if state.animations[animation_id].animate(elapsed).is_break() { + state.running.remove_member(index); + } else { + index += 1; + } + } + + drop(state); + let next_tick = last_tick + Duration::from_millis(16); + std::thread::sleep( + next_tick + .checked_duration_since(Instant::now()) + .unwrap_or(Duration::from_millis(16)), + ); + state = thread_state(); + } + } +} + +struct Animating { + animations: Lots>, + running: Set, + last_updated: Option, +} + +impl Animating { + const fn new() -> Self { + Self { + animations: Lots::new(), + running: Set::new(), + last_updated: None, + } + } + + fn spawn(&mut self, animation: Box) -> AnimationHandle { + let id = self.animations.push(animation); + + if self.running.is_empty() { + NEW_ANIMATIONS.notify_one(); + } + + self.running.insert(id); + + AnimationHandle(Some(id)) + } + + fn remove_animation(&mut self, id: LotId) { + self.animations.remove(id); + self.running.remove(&id); + } +} + +/// A type that can animate. +pub trait Animate: Send + Sync { + /// Update the animation by progressing the timeline by `elapsed`. + /// + /// When the animation is complete, return `ControlFlow::Break` with the + /// remaining time that was not needed to complete the animation. This is + /// used in multi-step animation processes to ensure time is accurately + /// tracked. + fn animate(&mut self, elapsed: Duration) -> ControlFlow; +} + +/// Describes a change to a new value for a [`Dynamic`] over a specified +/// [`Duration`], using the `Easing` generic parameter to control how the value +/// is interpolated. +#[must_use = "animations are not performed until they are spawned"] +pub struct Animation { + value: Dynamic, + end: T, + duration: Duration, + _easing: PhantomData, +} + +impl Animation +where + T: LinearInterpolate + Clone + Send + Sync + 'static, +{ + /// Returns a linearly interpolated animation that transitions `value` to + /// `end_value` over `duration`. + pub fn linear(value: Dynamic, end_value: T, duration: Duration) -> Self { + Self::new(value, end_value, duration) + } +} + +impl Animation +where + T: LinearInterpolate + Clone + Send + Sync + 'static, + Easing: self::Easing, +{ + /// Returns an animation that transitions `value` to `end_value` over + /// `duration` using `Easing` for interpolation. + pub fn new(value: Dynamic, end_value: T, duration: Duration) -> Self { + Self { + value, + end: end_value, + duration, + _easing: PhantomData, + } + } +} + +impl IntoAnimate for Animation +where + T: LinearInterpolate + Clone + Send + Sync + 'static, + Easing: self::Easing, +{ + type Animate = RunningAnimation; + + fn into_animate(self) -> Self::Animate { + RunningAnimation { + start: self.value.get(), + animation: self, + elapsed: Duration::ZERO, + } + } +} + +/// A type that can convert into `Box`. +pub trait BoxAnimate { + /// Returns the boxed animation. + fn boxed(self) -> Box; +} + +/// A type that can be converted into an animation. +pub trait IntoAnimate: Sized + Send + Sync { + /// The running animation type. + type Animate: Animate; + + /// Return this change as a running animation. + fn into_animate(self) -> Self::Animate; + + /// Returns an combined animation that performs `self` and `other` in + /// sequence. + fn chain(self, other: Other) -> Chain { + Chain::new(self, other) + } +} + +impl BoxAnimate for T +where + T: IntoAnimate + 'static, +{ + fn boxed(self) -> Box { + Box::new(self.into_animate()) + } +} + +/// A [`Animate`] implementor that has been boxed as a trait object. +pub struct BoxedAnimation(Box); + +/// An animation that can be spawned. +pub trait Spawn { + /// Spawns the animation, returning a handle that tracks the animation. + /// + /// When the returned handle is dropped, the animation is stopped. + fn spawn(self) -> AnimationHandle; +} + +impl Spawn for T +where + T: BoxAnimate, +{ + fn spawn(self) -> AnimationHandle { + self.boxed().spawn() + } +} + +impl Spawn for Box { + fn spawn(self) -> AnimationHandle { + thread_state().spawn(self) + } +} + +impl Animate for RunningAnimation +where + T: LinearInterpolate + Clone + Send + Sync, + Easing: self::Easing, +{ + fn animate(&mut self, elapsed: Duration) -> ControlFlow { + self.elapsed = self.elapsed.checked_add(elapsed).unwrap_or(Duration::MAX); + + if let Some(remaining_elapsed) = self.elapsed.checked_sub(self.animation.duration) { + self.animation.value.set(self.animation.end.clone()); + ControlFlow::Break(remaining_elapsed) + } else { + let progress = Easing::ease(ZeroToOne::new( + self.elapsed.as_secs_f32() / self.animation.duration.as_secs_f32(), + )); + self.animation + .value + .set(self.start.lerp(&self.animation.end, progress)); + ControlFlow::Continue(()) + } + } +} + +/// A running [`Animation`] that changes a [`Dynamic`] over a specified +/// [`Duration`], using the `Easing` generic parameter to control how the value +/// is interpolated. +/// +/// The initial value for interpolation is recorded at the time this type is +/// created: [`IntoAnimate::into_animate`]. [`Easing`] is used to customize how +/// interpolation is performed. +pub struct RunningAnimation { + animation: Animation, + start: T, + elapsed: Duration, +} + +/// A handle to a spawned animation. When dropped, the associated animation will +/// be stopped. +#[derive(Default, Debug)] +#[must_use] +pub struct AnimationHandle(Option); + +impl AnimationHandle { + /// Returns an empty handle that references no animation. + pub const fn new() -> Self { + Self(None) + } + + /// Cancels the animation immediately. + /// + /// This has the same effect as dropping the handle. + pub fn clear(&mut self) { + if let Some(id) = self.0.take() { + thread_state().remove_animation(id); + } + } +} + +impl Drop for AnimationHandle { + fn drop(&mut self) { + self.clear(); + } +} + +/// An animation combinator that runs animation `A`, then animation `B`. +pub struct Chain(A, B); + +/// A [`Chain`] that is currently animating. +pub struct RunningChain(Option>); + +enum ChainState { + AnimatingFirst(A::Animate, B), + AnimatingSecond(B::Animate), +} + +impl Chain +where + A: IntoAnimate, + B: IntoAnimate, +{ + /// Returns a new instance with `first` and `second`. + pub const fn new(first: A, second: B) -> Self { + Self(first, second) + } +} + +impl IntoAnimate for Chain +where + A: IntoAnimate, + B: IntoAnimate, +{ + type Animate = RunningChain; + + fn into_animate(self) -> Self::Animate { + let a = self.0.into_animate(); + RunningChain(Some(ChainState::AnimatingFirst(a, self.1))) + } +} + +impl Animate for RunningChain +where + A: IntoAnimate, + B: IntoAnimate, +{ + fn animate(&mut self, elapsed: Duration) -> ControlFlow { + match self.0.as_mut().expect("invalid state") { + ChainState::AnimatingFirst(a, _) => match a.animate(elapsed) { + ControlFlow::Continue(()) => ControlFlow::Continue(()), + ControlFlow::Break(remaining) => { + let Some(ChainState::AnimatingFirst(_, b)) = self.0.take() else { + unreachable!("invalid state") + }; + self.0 = Some(ChainState::AnimatingSecond(b.into_animate())); + self.animate(remaining) + } + }, + ChainState::AnimatingSecond(b) => b.animate(elapsed), + } + } +} + +/// Performs a linear interpolation between two values. +pub trait LinearInterpolate { + /// Interpolate linearly between `self` and `target` using `percent`. + #[must_use] + fn lerp(&self, target: &Self, percent: f32) -> Self; +} + +macro_rules! impl_lerp_for_int { + ($type:ident, $unsigned:ident, $float:ident) => { + impl LinearInterpolate for $type { + fn lerp(&self, target: &Self, percent: f32) -> Self { + let percent = $float::from(percent); + let delta = target.abs_diff(*self); + let delta = (delta as $float * percent).round() as $unsigned; + if target > self { + self.checked_add_unsigned(delta).expect("direction checked") + } else { + self.checked_sub_unsigned(delta).expect("direction checked") + } + } + } + }; +} + +macro_rules! impl_lerp_for_uint { + ($type:ident, $float:ident) => { + impl LinearInterpolate for $type { + 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 + } else { + *self - ((*self - *target) as $float * percent).round() as $type + } + } + } + }; +} + +impl_lerp_for_uint!(u8, f32); +impl_lerp_for_uint!(u16, f32); +impl_lerp_for_uint!(u32, f32); +impl_lerp_for_uint!(u64, f32); +impl_lerp_for_uint!(u128, f64); +impl_lerp_for_uint!(usize, f64); +impl_lerp_for_int!(i8, u8, f32); +impl_lerp_for_int!(i16, u16, f32); +impl_lerp_for_int!(i32, u32, f32); +impl_lerp_for_int!(i64, u64, f32); +impl_lerp_for_int!(i128, u128, f64); +impl_lerp_for_int!(isize, usize, f64); + +#[test] +fn integer_lerps() { + #[track_caller] + fn test_lerps(a: &T, b: &T, mid: &T) { + assert_eq!(&a.lerp(b, 1.), b); + assert_eq!(&a.lerp(b, 0.), a); + assert_eq!(&a.lerp(b, 0.5), mid); + } + + test_lerps(&u8::MIN, &u8::MAX, &128); + test_lerps(&u16::MIN, &u16::MAX, &32_768); + test_lerps(&u32::MIN, &u32::MAX, &2_147_483_648); + test_lerps(&i8::MIN, &i8::MAX, &0); + test_lerps(&i16::MIN, &i16::MAX, &0); + test_lerps(&i32::MIN, &i32::MAX, &0); + test_lerps(&i64::MIN, &i64::MAX, &0); + test_lerps(&i128::MIN, &i128::MAX, &0); + test_lerps(&isize::MIN, &isize::MAX, &0); +} + +/// 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`. +#[derive(Clone, Copy, Debug)] +pub struct ZeroToOne(f32); + +impl ZeroToOne { + /// The maximum value this type can contain. + pub const ONE: Self = Self(1.); + /// The minimum type this type can contain. + pub const ZERO: Self = Self(0.); + + /// Returns a new instance after clamping `value` between +0.0 and 1.0. + /// + /// # Panics + /// + /// This function panics if `value` is not a number. + #[must_use] + pub fn new(value: f32) -> Self { + assert!(!value.is_nan()); + + Self(value.clamp(0., 1.)) + } + + /// Returns the contained floating point value. + #[must_use] + pub fn into_f32(self) -> f32 { + self.0 + } +} + +impl Default for ZeroToOne { + fn default() -> Self { + Self::ZERO + } +} + +impl Deref for ZeroToOne { + type Target = f32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Eq for ZeroToOne {} + +impl PartialEq for ZeroToOne { + fn eq(&self, other: &Self) -> bool { + *self == other.0 + } +} + +impl PartialEq for ZeroToOne { + fn eq(&self, other: &f32) -> bool { + (self.0 - *other).abs() < f32::EPSILON + } +} + +impl Ord for ZeroToOne { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.total_cmp(&other.0) + } +} + +impl PartialOrd for ZeroToOne { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialOrd for ZeroToOne { + fn partial_cmp(&self, other: &f32) -> Option { + Some(self.0.total_cmp(other)) + } +} + +impl LinearInterpolate for ZeroToOne { + fn lerp(&self, target: &Self, percent: f32) -> Self { + let delta = **target - **self; + ZeroToOne::new(**self + delta * percent) + } +} + +/// Performs easing for value interpolation. +pub trait Easing: Send + Sync + 'static { + /// Returns a ratio between 0.0 and 1.0 of + fn ease(progress: ZeroToOne) -> f32; +} + +/// An [`Easing`] function that produces a steady, linear transition. +pub enum Linear {} + +impl Easing for Linear { + fn ease(progress: ZeroToOne) -> f32 { + *progress + } +} diff --git a/src/context.rs b/src/context.rs index 6143150..6957c79 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,7 +5,7 @@ use kludgine::app::winit::event::{ DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, }; use kludgine::figures::units::{Px, UPx}; -use kludgine::figures::{IntoSigned, Point, Rect, Size}; +use kludgine::figures::{Point, Rect, Size}; use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Kludgine; @@ -13,7 +13,7 @@ use crate::graphics::Graphics; use crate::styles::components::HighlightColor; use crate::styles::{ComponentDefaultvalue, Styles}; use crate::value::Dynamic; -use crate::widget::{BoxedWidget, EventHandling, ManagedWidget}; +use crate::widget::{EventHandling, ManagedWidget, WidgetInstance}; use crate::window::RunningWindow; use crate::ConstraintLimit; @@ -126,25 +126,26 @@ impl<'context, 'window> EventContext<'context, 'window> { } pub(crate) fn hover(&mut self, location: Point) { - let newly_hovered = match self.current_node.tree.hover(Some(self.current_node)) { - Ok(old_hover) => { - if let Some(old_hover) = old_hover { - let mut old_hover_context = self.for_other(&old_hover); - old_hover.lock().unhover(&mut old_hover_context); - } - true + if let Ok(changes) = self.current_node.tree.hover(Some(self.current_node)) { + for unhovered in changes.unhovered { + let mut context = self.for_other(&unhovered); + unhovered.lock().unhover(&mut context); + } + for hover in changes.hovered { + let mut context = self.for_other(&hover); + hover.lock().hover(location, &mut context); } - Err(_) => false, - }; - if newly_hovered { - self.current_node.lock().hover(location, self); } } pub(crate) fn clear_hover(&mut self) { - if let Ok(Some(old_hover)) = self.current_node.tree.hover(None) { - let mut old_hover_context = self.for_other(&old_hover); - old_hover.lock().unhover(&mut old_hover_context); + if let Ok(changes) = self.current_node.tree.hover(None) { + assert!(changes.hovered.is_empty()); + + for old_hover in changes.unhovered { + let mut old_hover_context = self.for_other(&old_hover); + old_hover.lock().unhover(&mut old_hover_context); + } } } @@ -254,8 +255,24 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' } } + /// Returns a new `GraphicsContext` that allows invoking graphics functions + /// for `widget` and restricts the drawing area to `region`. + /// + /// This is equivalent to calling + /// `self.for_other(widget).clipped_to(region)`. + pub fn for_child<'child>( + &'child mut self, + widget: &'child ManagedWidget, + region: Rect, + ) -> GraphicsContext<'child, 'window, 'child, 'gfx, 'pass> { + GraphicsContext { + widget: self.widget.for_other(widget), + graphics: Exclusive::Owned(self.graphics.clipped_to(region)), + } + } + /// Returns a new graphics context that renders to the `clip` rectangle. - pub fn clipped_to(&mut self, clip: Rect) -> GraphicsContext<'_, 'window, '_, 'gfx, 'pass> { + pub fn clipped_to(&mut self, clip: Rect) -> GraphicsContext<'_, 'window, '_, 'gfx, 'pass> { GraphicsContext { widget: self.widget.borrowed(), graphics: Exclusive::Owned(self.graphics.clipped_to(clip)), @@ -267,7 +284,7 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' /// To ensure the correct color is used, include [`HighlightColor`] in the /// styles request. pub fn draw_focus_ring_using(&mut self, styles: &Styles) { - let visible_rect = Rect::from(self.graphics.size() - (UPx(1), UPx(1))); + let visible_rect = Rect::from(self.graphics.region().size - (Px(1), Px(1))); let focus_ring = Shape::stroked_rect( visible_rect, styles.get_or_default(&HighlightColor), @@ -291,11 +308,7 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' /// Invokes [`Widget::redraw()`](crate::widget::Widget::redraw) on this /// context's widget. pub fn redraw(&mut self) { - // TODO this should not use clip_rect, because it forces UPx, and once - // we have scrolling, we can have negative offsets of rectangles where - // it's clipped partially. - self.current_node - .note_rendered_rect(self.graphics.clip_rect().into_signed()); + self.current_node.note_rendered_rect(self.graphics.region()); self.current_node.lock().redraw(self); } } @@ -326,7 +339,7 @@ pub trait AsEventContext<'window> { /// Pushes a new child widget into the widget hierarchy beneathq the /// context's widget. #[must_use] - fn push_child(&mut self, child: BoxedWidget) -> ManagedWidget { + fn push_child(&mut self, child: WidgetInstance) -> ManagedWidget { let mut context = self.as_event_context(); let pushed_widget = context .current_node @@ -502,12 +515,19 @@ impl<'context, 'window> WidgetContext<'context, 'window> { self.pending_state.active.as_ref() == Some(self.current_node) } - /// Returns true if this widget is currently hovered. + /// Returns true if this widget is currently hovered, even if the cursor is + /// over a child widget. #[must_use] pub fn hovered(&self) -> bool { self.current_node.hovered() } + /// Returns true if this widget that is directly beneath the cursor. + #[must_use] + pub fn primary_hover(&self) -> bool { + self.current_node.primary_hover() + } + /// Returns true if this widget is currently focused for user input. #[must_use] pub fn focused(&self) -> bool { diff --git a/src/graphics.rs b/src/graphics.rs index f993212..8238442 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -1,13 +1,21 @@ use std::ops::{Deref, DerefMut}; -use kludgine::figures::units::UPx; -use kludgine::figures::Rect; +use kludgine::figures::units::{Px, UPx}; +use kludgine::figures::{ + self, Angle, Fraction, IntoSigned, IntoUnsigned, IsZero, Point, Rect, ScreenScale, ScreenUnit, + Size, +}; use kludgine::render::Renderer; -use kludgine::ClipGuard; +use kludgine::shapes::Shape; +use kludgine::text::{MeasuredText, Text, TextOrigin}; +use kludgine::{ + cosmic_text, ClipGuard, Color, Kludgine, ShaderScalable, ShapeSource, TextureSource, +}; /// A 2d graphics context pub struct Graphics<'clip, 'gfx, 'pass> { renderer: RenderContext<'clip, 'gfx, 'pass>, + region: Rect, } enum RenderContext<'clip, 'gfx, 'pass> { @@ -20,10 +28,48 @@ impl<'clip, 'gfx, 'pass> Graphics<'clip, 'gfx, 'pass> { #[must_use] pub fn new(renderer: Renderer<'gfx, 'pass>) -> Self { Self { + region: renderer.clip_rect().into_signed(), renderer: RenderContext::Renderer(renderer), } } + /// Returns the offset relative to the clipping rect that the graphics + /// context renders at. + /// + /// This is used when rendering controls that are partially offscreen to the + /// left or top of the window's origin. + /// + /// In general, this is handled automatically. This function should only be + /// needed when using [`inner_graphics()`](Self::inner_graphics). + #[must_use] + pub fn translation(&self) -> Point { + let clip_origin = self.renderer.clip_rect().origin.into_signed(); + -Point::new( + if clip_origin.x <= self.region.origin.x { + Px(0) + } else { + clip_origin.x - self.region.origin.x + }, + if clip_origin.y <= self.region.origin.y { + Px(0) + } else { + clip_origin.y - self.region.origin.y + }, + ) + } + + /// Returns the underlying renderer. + /// + /// Note: Kludgine graphics contexts only support clipping. This type adds + /// [`self.translation()`](Self::translation) to the offset of each drawing + /// call. When using the underlying renderer, any drawing calls will need + /// this offset as well, otherwise the widget that is being rendered will + /// not render correctly when placed in a [`Scroll`](crate::widgets::Scroll) + /// widget. + pub fn inner_graphics(&mut self) -> &mut Renderer<'gfx, 'pass> { + &mut self.renderer + } + /// Returns a context that has been clipped to `clip`. /// /// The new clipping rectangle is interpreted relative to the current @@ -32,27 +78,217 @@ impl<'clip, 'gfx, 'pass> Graphics<'clip, 'gfx, 'pass> { /// /// The returned context will report the clipped size, and all drawing /// operations will be relative to the origin of `clip`. - pub fn clipped_to(&mut self, clip: Rect) -> Graphics<'_, 'gfx, 'pass> { + pub fn clipped_to(&mut self, clip: Rect) -> Graphics<'_, 'gfx, 'pass> { + let region = clip + self.region.origin; + let new_clip = self + .renderer + .clip_rect() + .intersection(®ion.into_unsigned()) + .map(|intersection| intersection - self.renderer.clip_rect().origin) + .unwrap_or_default(); + Graphics { - renderer: RenderContext::Clipped(self.deref_mut().clipped_to(clip)), + renderer: RenderContext::Clipped(self.renderer.clipped_to(new_clip)), + region, } } + + /// Returns the current clipping rectangle. + /// + /// The clipping rectangle is represented in unsigned pixels in the window's + /// coordinate system. + #[must_use] + pub fn clip_rect(&self) -> Rect { + self.renderer.clip_rect() + } + + /// Returns the current region being rendered to. + /// + /// The rendering region utilizes signed pixels, which allows it to + /// represent regions that are out of bounds of the window's visible region. + #[must_use] + pub fn region(&self) -> Rect { + self.region + } + + /// Returns the visible region of the graphics context. + /// + /// This is the intersection of [`Self::region()`] and + /// [`Self::clip_rect()`]. + #[must_use] + pub fn visible_rect(&self) -> Option> { + self.clip_rect().intersection(&self.region.into_unsigned()) + } + + /// Returns the size of the current region. + /// + /// This is `self.region().size` converted to unsigned pixels. + #[must_use] + pub fn size(&self) -> Size { + self.region.size.into_unsigned() + } + + /// Returns the current DPI scaling factor applied to the window this + /// context is attached to. + #[must_use] + pub fn scale(&self) -> Fraction { + self.renderer.scale() + } + + /// Draws a shape at the origin, rotating and scaling as needed. + pub fn draw_shape( + &mut self, + shape: &Shape, + origin: Point, + rotation_rads: Option, + scale: Option, + ) where + Unit: IsZero + ShaderScalable + figures::ScreenUnit + Copy, + { + let translate = origin + Point::::from_px(self.translation(), self.scale()); + self.renderer + .draw_shape(shape, translate, rotation_rads, scale); + } + + /// Draws `texture` at `destination`, scaling as necessary. + pub fn draw_texture(&mut self, texture: &impl TextureSource, destination: Rect) + where + Unit: figures::ScreenUnit + ShaderScalable, + i32: From<::Signed>, + { + let translate = Point::::from_px(self.translation(), self.scale()); + self.renderer.draw_texture(texture, destination + translate); + } + + /// Draws a shape that was created with texture coordinates, applying the + /// provided texture. + pub fn draw_textured_shape( + &mut self, + shape: &impl ShapeSource, + texture: &impl TextureSource, + origin: Point, + rotation: Option, + scale: Option, + ) where + Unit: IsZero + ShaderScalable + figures::ScreenUnit + Copy, + i32: From<::Signed>, + { + let translate = origin + Point::::from_px(self.translation(), self.scale()); + self.renderer + .draw_textured_shape(shape, texture, translate, rotation, scale); + } + + /// Measures `text` using the current text settings. + /// + /// `default_color` does not affect the + pub fn measure_text<'a, Unit>(&mut self, text: impl Into>) -> MeasuredText + where + Unit: figures::ScreenUnit, + { + self.renderer.measure_text(text) + } + + /// Draws `text` using the current text settings. + pub fn draw_text<'a, Unit>( + &mut self, + text: impl Into>, + translate: Point, + rotation: Option, + scale: Option, + ) where + Unit: ScreenUnit, + { + let translate = translate + Point::::from_px(self.translation(), self.scale()); + self.renderer.draw_text(text, translate, rotation, scale); + } + + /// Prepares the text layout contained in `buffer` to be rendered. + /// + /// When the text in `buffer` has no color defined, `default_color` will be + /// used. + /// + /// `origin` allows controlling how the text will be drawn relative to the + /// coordinate provided in [`render()`](crate::PreparedGraphic::render). + pub fn draw_text_buffer( + &mut self, + buffer: &cosmic_text::Buffer, + default_color: Color, + origin: TextOrigin, + translate: Point, + rotation: Option, + scale: Option, + ) where + Unit: ScreenUnit, + { + let translate = translate + Point::::from_px(self.translation(), self.scale()); + self.renderer + .draw_text_buffer(buffer, default_color, origin, translate, rotation, scale); + } + + /// Measures `buffer` and caches the results using `default_color` when + /// the buffer has no color associated with text. + pub fn measure_text_buffer( + &mut self, + buffer: &cosmic_text::Buffer, + default_color: Color, + ) -> MeasuredText + where + Unit: figures::ScreenUnit, + { + self.renderer.measure_text_buffer(buffer, default_color) + } + + /// Prepares the text layout contained in `buffer` to be rendered. + /// + /// When the text in `buffer` has no color defined, `default_color` will be + /// used. + /// + /// `origin` allows controlling how the text will be drawn relative to the + /// coordinate provided in [`render()`](crate::PreparedGraphic::render). + pub fn draw_measured_text( + &mut self, + text: &MeasuredText, + origin: TextOrigin, + translate: Point, + rotation: Option, + scale: Option, + ) where + Unit: ScreenUnit, + { + let translate = translate + Point::::from_px(self.translation(), self.scale()); + self.renderer + .draw_measured_text(text, origin, translate, rotation, scale); + } } impl<'gfx, 'pass> Deref for Graphics<'_, 'gfx, 'pass> { + type Target = Kludgine; + + fn deref(&self) -> &Self::Target { + &self.renderer + } +} + +impl<'gfx, 'pass> DerefMut for Graphics<'_, 'gfx, 'pass> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.renderer + } +} + +impl<'gfx, 'pass> Deref for RenderContext<'_, 'gfx, 'pass> { type Target = Renderer<'gfx, 'pass>; fn deref(&self) -> &Self::Target { - match &self.renderer { + match self { RenderContext::Renderer(renderer) => renderer, RenderContext::Clipped(clipped) => clipped, } } } -impl<'gfx, 'pass> DerefMut for Graphics<'_, 'gfx, 'pass> { +impl<'gfx, 'pass> DerefMut for RenderContext<'_, 'gfx, 'pass> { fn deref_mut(&mut self) -> &mut Self::Target { - match &mut self.renderer { + match self { RenderContext::Renderer(renderer) => renderer, RenderContext::Clipped(clipped) => &mut *clipped, } diff --git a/src/lib.rs b/src/lib.rs index f572076..bad1944 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ #![warn(clippy::pedantic, missing_docs)] #![allow(clippy::module_name_repetitions, clippy::missing_errors_doc)] +pub mod animation; pub mod context; mod graphics; mod names; @@ -13,6 +14,8 @@ pub mod value; pub mod widget; pub mod widgets; pub mod window; +pub use with_clone::WithClone; +mod with_clone; pub use kludgine; use kludgine::app::winit::error::EventLoopError; @@ -23,7 +26,7 @@ pub use self::graphics::Graphics; pub use self::tick::{InputState, Tick}; /// A limit used when measuring a widget. -#[derive(Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ConstraintLimit { /// The widget is expected to occupy a known size. Known(UPx), diff --git a/src/tree.rs b/src/tree.rs index d83eae4..660e730 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -7,7 +7,7 @@ use kludgine::figures::units::Px; use kludgine::figures::{Point, Rect}; use crate::styles::{ComponentDefaultvalue, Styles}; -use crate::widget::{BoxedWidget, ManagedWidget}; +use crate::widget::{ManagedWidget, WidgetInstance}; #[derive(Clone, Default)] pub struct Tree { @@ -15,7 +15,11 @@ pub struct Tree { } impl Tree { - pub fn push_boxed(&self, widget: BoxedWidget, parent: Option<&ManagedWidget>) -> ManagedWidget { + pub fn push_boxed( + &self, + widget: WidgetInstance, + parent: Option<&ManagedWidget>, + ) -> ManagedWidget { let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); let id = WidgetId(data.nodes.push(Node { widget: widget.clone(), @@ -42,6 +46,7 @@ impl Tree { pub(crate) fn note_rendered_rect(&self, widget: WidgetId, rect: Rect) { let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + rect.extents(); data.nodes[widget.0].last_rendered_location = Some(rect); data.render_order.push(widget); } @@ -56,9 +61,31 @@ impl Tree { data.render_order.clear(); } - pub fn hover(&self, new_hover: Option<&ManagedWidget>) -> Result, ()> { + pub(crate) fn hover(&self, new_hover: Option<&ManagedWidget>) -> Result { let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - data.update_tracked_widget(new_hover, self, |data| &mut data.hover) + let mut hovered = new_hover + .map(|new_hover| data.widget_hierarchy(new_hover.id, self)) + .unwrap_or_default(); + match data.update_tracked_widget(new_hover, self, |data| &mut data.hover)? { + Some(old_hover) => { + let mut old_hovered = data.widget_hierarchy(old_hover.id, self); + // For any widgets that were shared, remove them, as they don't + // need to have their events fired again. + while !old_hovered.is_empty() && old_hovered.get(0) == hovered.get(0) { + old_hovered.remove(0); + hovered.remove(0); + } + + Ok(HoverResults { + unhovered: old_hovered, + hovered, + }) + } + None => Ok(HoverResults { + unhovered: Vec::new(), + hovered, + }), + } } pub fn focus(&self, new_focus: Option<&ManagedWidget>) -> Result, ()> { @@ -76,11 +103,7 @@ impl Tree { pub fn widget(&self, id: WidgetId) -> ManagedWidget { let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - ManagedWidget { - id, - widget: data.nodes[id.0].widget.clone(), - tree: self.clone(), - } + data.widget(id, self) } pub fn active_widget(&self) -> Option { @@ -97,6 +120,19 @@ impl Tree { .hover } + pub fn is_hovered(&self, id: WidgetId) -> bool { + let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut search = data.hover; + while let Some(hovered) = search { + if hovered == id { + return true; + } + search = data.nodes[hovered.0].parent; + } + + false + } + pub fn focused_widget(&self) -> Option { self.data .lock() @@ -143,6 +179,11 @@ impl Tree { } } +pub(crate) struct HoverResults { + pub unhovered: Vec, + pub hovered: Vec, +} + #[derive(Default)] struct TreeData { nodes: Lots, @@ -153,6 +194,14 @@ struct TreeData { } impl TreeData { + fn widget(&self, id: WidgetId, tree: &Tree) -> ManagedWidget { + ManagedWidget { + id, + widget: self.nodes[id.0].widget.clone(), + tree: tree.clone(), + } + } + fn remove_child(&mut self, child: WidgetId, parent: WidgetId) { let removed_node = self.nodes.remove(child.0).expect("widget already removed"); let parent = &mut self.nodes[parent.0]; @@ -171,6 +220,21 @@ impl TreeData { } } + pub(crate) fn widget_hierarchy(&self, mut widget: WidgetId, tree: &Tree) -> Vec { + let mut hierarchy = Vec::new(); + loop { + hierarchy.push(self.widget(widget, tree)); + let Some(parent) = self.nodes[widget.0].parent else { + break; + }; + widget = parent; + } + + hierarchy.reverse(); + + hierarchy + } + fn update_tracked_widget( &mut self, new_widget: Option<&ManagedWidget>, @@ -218,7 +282,7 @@ impl TreeData { } pub struct Node { - pub widget: BoxedWidget, + pub widget: WidgetInstance, pub children: Vec, pub parent: Option, pub last_rendered_location: Option>, diff --git a/src/widget.rs b/src/widget.rs index 1858f36..fac6945 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -166,14 +166,14 @@ where T: Widget, { fn run(self) -> crate::Result { - BoxedWidget::new(self).run() + WidgetInstance::new(self).run() } } /// A type that can create a widget. pub trait MakeWidget: Sized { /// Returns a new widget. - fn make_widget(self) -> BoxedWidget; + fn make_widget(self) -> WidgetInstance; /// Runs the widget this type creates as an application. fn run(self) -> crate::Result { @@ -185,8 +185,8 @@ impl MakeWidget for T where T: Widget, { - fn make_widget(self) -> BoxedWidget { - BoxedWidget::new(self) + fn make_widget(self) -> WidgetInstance { + WidgetInstance::new(self) } } @@ -209,9 +209,9 @@ pub const IGNORED: EventHandling = EventHandling::Continue(EventIgnored); /// An instance of a [`Widget`]. #[derive(Clone, Debug)] -pub struct BoxedWidget(Arc>); +pub struct WidgetInstance(Arc>); -impl BoxedWidget { +impl WidgetInstance { /// Returns a new instance containing `widget`. pub fn new(widget: W) -> Self where @@ -225,28 +225,28 @@ impl BoxedWidget { } } -impl Run for BoxedWidget { +impl Run for WidgetInstance { fn run(self) -> crate::Result { - Window::::new(self).run() + Window::::new(self).run() } } -impl Eq for BoxedWidget {} +impl Eq for WidgetInstance {} -impl PartialEq for BoxedWidget { +impl PartialEq for WidgetInstance { fn eq(&self, other: &Self) -> bool { Arc::ptr_eq(&self.0, &other.0) } } -impl WindowBehavior for BoxedWidget { +impl WindowBehavior for WidgetInstance { type Context = Self; fn initialize(_window: &mut RunningWindow<'_>, context: Self::Context) -> Self { context } - fn make_root(&mut self) -> BoxedWidget { + fn make_root(&mut self) -> WidgetInstance { self.clone() } } @@ -297,7 +297,7 @@ where #[derive(Clone)] pub struct ManagedWidget { pub(crate) id: WidgetId, - pub(crate) widget: BoxedWidget, + pub(crate) widget: WidgetInstance, pub(crate) tree: Tree, } @@ -334,6 +334,12 @@ impl ManagedWidget { /// Returns true if this widget is currently the hovered widget. #[must_use] pub fn hovered(&self) -> bool { + self.tree.is_hovered(self.id) + } + + /// Returns true if this widget that is directly beneath the cursor. + #[must_use] + pub fn primary_hover(&self) -> bool { self.tree.hovered_widget() == Some(self.id) } @@ -360,8 +366,8 @@ impl PartialEq for ManagedWidget { } } -impl PartialEq for ManagedWidget { - fn eq(&self, other: &BoxedWidget) -> bool { +impl PartialEq for ManagedWidget { + fn eq(&self, other: &WidgetInstance) -> bool { &self.widget == other } } @@ -370,7 +376,7 @@ impl PartialEq for ManagedWidget { #[derive(Debug, Default)] #[must_use] pub struct Widgets { - ordered: Vec, + ordered: Vec, } impl Widgets { @@ -431,7 +437,7 @@ where } impl Deref for Widgets { - type Target = [BoxedWidget]; + type Target = [WidgetInstance]; fn deref(&self) -> &Self::Target { &self.ordered diff --git a/src/widgets.rs b/src/widgets.rs index 10df657..48ff140 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -4,6 +4,7 @@ mod button; mod canvas; mod input; mod label; +mod scroll; pub mod stack; mod style; mod tilemap; @@ -12,5 +13,7 @@ pub use button::Button; pub use canvas::Canvas; pub use input::Input; pub use label::Label; +pub use scroll::Scroll; +pub use stack::Stack; pub use style::Style; pub use tilemap::TileMap; diff --git a/src/widgets/button.rs b/src/widgets/button.rs index fa81053..6ae8ee2 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -57,7 +57,8 @@ impl Button { impl Widget for Button { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { - let center = Point::from(context.graphics.size()) / 2; + let size = context.graphics.region().size; + let center = Point::from(size) / 2; self.label.redraw_when_changed(context); let styles = context.query_style(&[ @@ -68,7 +69,7 @@ impl Widget for Button { &ButtonHoverBackground, ]); - let visible_rect = Rect::from(context.graphics.size() - (UPx(1), UPx(1))); + let visible_rect = Rect::from(size - (Px(1), Px(1))); let background = if context.active() { styles.get_or_default(&ButtonActiveBackground) @@ -86,12 +87,11 @@ impl Widget for Button { context.draw_focus_ring_using(&styles); } - let width = context.graphics.size().width; self.label.map(|label| { context.graphics.draw_text( Text::new(label, styles.get_or_default(&TextColor)) .origin(kludgine::text::TextOrigin::Center) - .wrap_at(width), + .wrap_at(size.width), center, None, None, diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 186d70a..59786c1 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -27,14 +27,15 @@ impl Widget for Label { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { self.text.redraw_when_changed(context); - let center = Point::from(context.graphics.size()) / 2; + let size = context.graphics.region().size; + let center = Point::from(size) / 2; let styles = context.query_style(&[&TextColor]); - let width = context.graphics.size().width; + self.text.map(|contents| { context.graphics.draw_text( Text::new(contents, styles.get_or_default(&TextColor)) .origin(TextOrigin::Center) - .wrap_at(width), + .wrap_at(size.width), center, None, None, diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs new file mode 100644 index 0000000..2fe6d17 --- /dev/null +++ b/src/widgets/scroll.rs @@ -0,0 +1,242 @@ +use std::borrow::Cow; +use std::time::Duration; + +use kludgine::app::winit::event::{DeviceId, MouseScrollDelta, TouchPhase}; +use kludgine::figures::units::{Lp, Px, UPx}; +use kludgine::figures::utils::lossy_f64_to_f32; +use kludgine::figures::{ + FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, +}; +use kludgine::shapes::Shape; +use kludgine::Color; + +use crate::animation::{Animation, AnimationHandle, Spawn, ZeroToOne}; +use crate::context::{AsEventContext, EventContext}; +use crate::styles::{ + ComponentDefinition, ComponentGroup, ComponentName, Dimension, NamedComponent, +}; +use crate::value::Dynamic; +use crate::widget::{EventHandling, MakeWidget, ManagedWidget, Widget, WidgetInstance, HANDLED}; +use crate::{ConstraintLimit, Name}; + +#[derive(Debug)] +enum ChildWidget { + Instance(WidgetInstance), + Managed(ManagedWidget), + Mounting, +} + +impl ChildWidget { + pub fn managed(&mut self, context: &mut EventContext<'_, '_>) -> ManagedWidget { + if matches!(self, ChildWidget::Instance(_)) { + let ChildWidget::Instance(instance) = std::mem::replace(self, ChildWidget::Mounting) + else { + unreachable!("just matched") + }; + *self = ChildWidget::Managed(context.push_child(instance)); + } + let ChildWidget::Managed(managed) = self else { + unreachable!("always converted") + }; + managed.clone() + } +} + +/// A widget that supports scrolling its contents. +#[derive(Debug)] +pub struct Scroll { + contents: ChildWidget, + content_size: Size, + scroll: Point, + max_scroll: Point, + scrollbar_opacity: Dynamic, + scrollbar_opacity_animation: AnimationHandle, +} + +impl Scroll { + /// Returns a new scroll widget containing `contents`. + pub fn new(contents: impl MakeWidget) -> Self { + Self { + contents: ChildWidget::Instance(contents.make_widget()), + content_size: Size::default(), + scroll: Point::default(), + max_scroll: Point::default(), + scrollbar_opacity: Dynamic::default(), + scrollbar_opacity_animation: AnimationHandle::new(), + } + } + + fn constrain_scroll(&mut self) { + self.scroll = self.scroll.max(self.max_scroll).min(Point::default()); + } +} + +impl Widget for Scroll { + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { + true + } + + fn hover(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) { + self.scrollbar_opacity_animation = Animation::linear( + self.scrollbar_opacity.clone(), + ZeroToOne::ONE, + Duration::from_millis(300), + ) + .spawn(); + } + + fn unhover(&mut self, _context: &mut EventContext<'_, '_>) { + self.scrollbar_opacity_animation = Animation::linear( + self.scrollbar_opacity.clone(), + ZeroToOne::ZERO, + Duration::from_millis(300), + ) + .spawn(); + } + + fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { + context.redraw_when_changed(&self.scrollbar_opacity); + self.constrain_scroll(); + let Some(visible_rect) = context.graphics.visible_rect() else { + return; + }; + let visible_bottom_right = visible_rect.into_signed().extent(); + let styles = context.query_style(&[&ScrollBarThickness]); + let bar_width = styles + .get_or_default(&ScrollBarThickness) + .into_px(context.graphics.scale()); + + let max_extents = Size::new( + ConstraintLimit::ClippedAfter(UPx::MAX - self.scroll.x.into_unsigned()), + ConstraintLimit::ClippedAfter(UPx::MAX - self.scroll.y.into_unsigned()), + ); + let managed = self.contents.managed(&mut context.as_event_context()); + self.content_size = context + .for_other(&managed) + .measure(max_extents) + .into_signed(); + let control_size = context.graphics.region().size; + + let region = Rect::new( + self.scroll, + self.content_size + .min(Size::new(Px::MAX, Px::MAX) - self.scroll.max(Point::default())), + ); + context.for_child(&managed, region).redraw(); + + let horizontal_bar = + scrollbar_region(self.scroll.x, self.content_size.width, control_size.width); + self.max_scroll.x = -horizontal_bar.amount_hidden; + + if horizontal_bar.size > 0 { + context.graphics.draw_shape( + &Shape::filled_rect( + Rect::new( + Point::new(horizontal_bar.offset, control_size.height - bar_width), + Size::new(horizontal_bar.size, bar_width), + ), + Color::new_f32(1.0, 1.0, 1.0, *self.scrollbar_opacity.get()), + ), + Point::default(), + None, + None, + ); + } + + let vertical_bar = + scrollbar_region(self.scroll.y, self.content_size.height, control_size.height); + self.max_scroll.y = -vertical_bar.amount_hidden; + + if vertical_bar.size > 0 { + context.graphics.draw_shape( + &Shape::filled_rect( + Rect::new( + Point::new(visible_bottom_right.x - bar_width, vertical_bar.offset), + Size::new(bar_width, vertical_bar.size), + ), + Color::new_f32(1.0, 1.0, 1.0, *self.scrollbar_opacity.get()), + ), + Point::default(), + None, + None, + ); + } + } + + fn measure( + &mut self, + available_space: Size, + _context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>, + ) -> Size { + Size::new(available_space.width.max(), available_space.height.max()) + } + + fn mouse_wheel( + &mut self, + _device_id: DeviceId, + delta: MouseScrollDelta, + _phase: TouchPhase, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + let amount = match delta { + /* TODO query line height */ + MouseScrollDelta::LineDelta(x, y) => Point::new(x, y) * 16.0, + MouseScrollDelta::PixelDelta(px) => { + Point::new(lossy_f64_to_f32(px.x), lossy_f64_to_f32(px.y)) + } + }; + + self.scroll += amount.cast(); + context.set_needs_redraw(); + + // TODO make this only returned handled if we actually scrolled. + HANDLED + } +} + +#[derive(Default)] +struct ScrollbarInfo { + offset: Px, + amount_hidden: Px, + size: Px, +} + +fn scrollbar_region(scroll: Px, content_size: Px, control_size: Px) -> ScrollbarInfo { + if content_size > control_size { + let amount_hidden = content_size - control_size; + let ratio_visible = control_size.into_float() / content_size.into_float(); + let bar_size = control_size * ratio_visible; + let remaining_area = control_size - bar_size; + let amount_scrolled = -scroll.into_float() / amount_hidden.into_float(); + let bar_offset = remaining_area * amount_scrolled; + ScrollbarInfo { + offset: bar_offset, + amount_hidden, + size: bar_size, + } + } else { + ScrollbarInfo::default() + } +} + +pub struct ScrollBarThickness; + +impl ComponentDefinition for ScrollBarThickness { + type ComponentType = Dimension; + + fn default_value(&self) -> Self::ComponentType { + Dimension::Lp(Lp::points(9)) + } +} + +impl NamedComponent for ScrollBarThickness { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::named::("text_size")) + } +} + +impl ComponentGroup for Scroll { + fn name() -> Name { + Name::new("Scroll") + } +} diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 1794537..fb2f53f 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -4,7 +4,7 @@ use std::ops::Deref; use alot::{LotId, OrderedLots}; use kludgine::figures::units::UPx; -use kludgine::figures::{Point, Rect, Size}; +use kludgine::figures::{IntoSigned, Point, Rect, Size}; use crate::context::{AsEventContext, EventContext, GraphicsContext}; use crate::value::{Generation, IntoValue, Value}; @@ -117,13 +117,21 @@ impl Widget for Stack { for (index, layout) in self.layout.iter().enumerate() { let child = &self.synced_children[index]; if layout.size > 0 { - let mut clipped = context.clipped_to(Rect::new( - self.layout.orientation.make_point(layout.offset, UPx(0)), - self.layout - .orientation - .make_size(layout.size, self.layout.other), - )); - clipped.for_other(child).redraw(); + context + .for_child( + child, + Rect::new( + self.layout + .orientation + .make_point(layout.offset, UPx(0)) + .into_signed(), + self.layout + .orientation + .make_size(layout.size, self.layout.other) + .into_signed(), + ), + ) + .redraw(); } } } @@ -402,7 +410,7 @@ impl Layout { ConstraintLimit::ClippedAfter(clip_limit) => self.other.min(clip_limit), }; - self.orientation.make_size(available_space, self.other) + self.orientation.make_size(offset, self.other) } } diff --git a/src/widgets/style.rs b/src/widgets/style.rs index 47ce2da..075a3cc 100644 --- a/src/widgets/style.rs +++ b/src/widgets/style.rs @@ -3,14 +3,14 @@ use kludgine::figures::Size; use crate::context::{AsEventContext, EventContext, GraphicsContext}; use crate::styles::Styles; -use crate::widget::{BoxedWidget, ManagedWidget, Widget}; +use crate::widget::{ManagedWidget, Widget, WidgetInstance}; use crate::ConstraintLimit; /// A widget that applies a set of [`Styles`] to all contained widgets. #[derive(Debug)] pub struct Style { styles: Styles, - child: BoxedWidget, + child: WidgetInstance, mounted_child: Option, } @@ -20,7 +20,7 @@ impl Style { pub fn new(styles: impl Into, child: impl Widget) -> Self { Self { styles: styles.into(), - child: BoxedWidget::new(child), + child: WidgetInstance::new(child), mounted_child: None, } } diff --git a/src/widgets/tilemap.rs b/src/widgets/tilemap.rs index 18192a2..bee86e0 100644 --- a/src/widgets/tilemap.rs +++ b/src/widgets/tilemap.rs @@ -65,8 +65,10 @@ where { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { let focus = self.focus.get(); - self.layers - .map(|layers| tilemap::draw(layers, focus, self.zoom, &mut context.graphics)); + // TODO this needs to be updated to support being placed in side of a scroll view. + self.layers.map(|layers| { + tilemap::draw(layers, focus, self.zoom, context.graphics.inner_graphics()); + }); if let Some(tick) = &self.tick { tick.rendered(context); diff --git a/src/window.rs b/src/window.rs index 9a383cd..5deca9a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,7 +20,7 @@ use crate::context::{EventContext, Exclusive, GraphicsContext, WidgetContext}; use crate::graphics::Graphics; use crate::tree::Tree; use crate::utils::ModifiersExt; -use crate::widget::{BoxedWidget, EventHandling, ManagedWidget, Widget, HANDLED, IGNORED}; +use crate::widget::{EventHandling, ManagedWidget, Widget, WidgetInstance, HANDLED, IGNORED}; use crate::window::sealed::WindowCommand; use crate::Run; @@ -52,13 +52,13 @@ where } } -impl Window { +impl Window { /// Returns a new instance using `widget` as its contents. pub fn for_widget(widget: W) -> Self where W: Widget, { - Self::new(BoxedWidget::new(widget)) + Self::new(WidgetInstance::new(widget)) } } @@ -102,7 +102,7 @@ pub trait WindowBehavior: Sized + 'static { fn initialize(window: &mut RunningWindow<'_>, context: Self::Context) -> Self; /// Create the window's root widget. This function is only invoked once. - fn make_root(&mut self) -> BoxedWidget; + fn make_root(&mut self) -> WidgetInstance; /// The window has been requested to close. If this function returns true, /// the window will be closed. Returning false prevents the window from @@ -422,6 +422,8 @@ where ) { match event { WindowCommand::Redraw => { + // TODO we should attempt to batch redraw events so that we're + // not constantly sending them from animations. window.set_needs_redraw(); } } diff --git a/src/with_clone.rs b/src/with_clone.rs new file mode 100644 index 0000000..6c758a4 --- /dev/null +++ b/src/with_clone.rs @@ -0,0 +1,39 @@ +/// Invokes a function with a clone of `self`. +pub trait WithClone: Sized { + /// The type that results from cloning. + type Cloned; + + /// Maps `with` with the results of cloning `self`. + fn with_clone(&self, with: impl FnOnce(Self::Cloned) -> R) -> R; +} + +macro_rules! impl_with_clone { + ($($name:ident $field:tt),+) => { + impl<'a, $($name: Clone,)+> WithClone for ($(&'a $name,)+) + { + type Cloned = ($($name,)+); + + fn with_clone(&self, with: impl FnOnce(Self::Cloned) -> R) -> R { + with(($(self.$field.clone(),)+)) + } + } + }; +} + +impl<'a, T> WithClone for &'a T +where + T: Clone, +{ + type Cloned = T; + + fn with_clone(&self, with: impl FnOnce(Self::Cloned) -> R) -> R { + with((*self).clone()) + } +} + +impl_with_clone!(T1 0); +impl_with_clone!(T1 0, T2 1); +impl_with_clone!(T1 0, T2 1, T3 2); +impl_with_clone!(T1 0, T2 1, T3 2, T4 3); +impl_with_clone!(T1 0, T2 1, T3 2, T4 3, T5 4); +impl_with_clone!(T1 0, T2 1, T3 2, T4 3, T5 4, T6 5);