diff --git a/Cargo.lock b/Cargo.lock index fc811f7..6190484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,14 @@ dependencies = [ "libc", ] +[[package]] +name = "animations" +version = "0.1.0" +dependencies = [ + "craft_gui", + "util", +] + [[package]] name = "arrayref" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 6233d8f..e2db576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "examples/text", "examples/tour", "examples/events", + "examples/animations", "examples/overlay", "examples/custom_event_loop", "website", diff --git a/crates/craft_core/src/animations/animation.rs b/crates/craft_core/src/animations/animation.rs new file mode 100644 index 0000000..fcfa287 --- /dev/null +++ b/crates/craft_core/src/animations/animation.rs @@ -0,0 +1,348 @@ +use crate::elements::ElementState; +use crate::style::{Style, StyleProperty, Unit}; +use craft_primitives::geometry::TrblRectangle; +use kurbo::{CubicBez, ParamCurve, Point}; +use smallvec::SmallVec; +use std::collections::HashMap; +use std::iter::zip; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct KeyFrame { + /// The action / styles interpolated at `offset_percentage`. + /// Range [0.0, 100.0] + offset_percentage: f32, + + /// The list of styles interpolated to an element at this keyframe. + properties: SmallVec<[StyleProperty; 3]>, +} + +impl KeyFrame { + pub fn new(offset_percentage: f32) -> Self { + KeyFrame { + offset_percentage, + properties: SmallVec::new(), + } + } + + pub fn push(mut self, property: StyleProperty) -> Self { + self.properties.push(property); + self + } +} + +#[derive(Copy, Clone, Debug)] +#[derive(PartialEq)] +pub enum AnimationStatus { + Paused, + Playing, + Scheduled, +} + +/// A cubic bézier curve where P0 and P3 are stuck at (0,0) and (1,1). +#[derive(Clone, Copy, Debug)] +pub struct FixedCubicBezier { + cubic_bez: CubicBez, +} + +impl FixedCubicBezier { + /// Sets P1 and P2 of a fixed cubic bézier curve. + pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { + Self { + cubic_bez: CubicBez::new( + Point::new(0.0, 0.0), + Point::new(x1 as f64, y1 as f64), + Point::new(x2 as f64, y2 as f64), + Point::new(1.0, 1.0), + ) + } + } +} + + +/// The motion of an animation modeled with a mathematical function. +#[derive(Default, Copy, Clone, Debug)] +pub enum TimingFunction { + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#linear + #[default] + Linear, + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease + Ease, + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-in + EaseIn, + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-out + EaseOut, + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-in-out + EaseInOut, + /// https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#cubic-beziernumber_01_number_number_01_number + BezierCurve(FixedCubicBezier), +} + +#[derive(Clone, Debug)] +pub struct Animation { + pub name: String, + pub key_frames: SmallVec<[KeyFrame; 2]>, + pub duration: Duration, + pub timing_function: TimingFunction, + pub loop_amount: LoopAmount, +} + +#[derive(Copy, Clone, Debug)] +pub enum LoopAmount { + Infinite, + Fixed(u32) +} + +impl Animation { + pub fn new(name: &str, duration: Duration, timing_function: TimingFunction) -> Self { + Self { + name: name.to_string(), + key_frames: SmallVec::new(), + duration, + timing_function, + loop_amount: LoopAmount::Fixed(1), + } + } + + pub fn push(mut self, key_frame: KeyFrame) -> Self { + self.key_frames.push(key_frame); + self + } + + pub fn loop_amount(mut self, loop_amount: LoopAmount) -> Self { + self.loop_amount = loop_amount; + self + } +} + +#[derive(Copy, Clone, Debug)] +pub struct ActiveAnimation { + /// How far into an animation we are. + pub(crate) current: Duration, + /// Tracks the status of an animation, if it is playing, scheduled, or paused. + pub(crate) status: AnimationStatus, + pub(crate) loop_amount: LoopAmount, +} + +/// For damage tracking across recursive calls to `on_animation_frame`. +#[derive(Clone, Copy, Debug, Default)] +pub struct AnimationFlags { + needs_relayout: bool, + has_active_animation: bool, +} + +impl AnimationFlags { + /// OR'd with the provided boolean and the previously stored boolean, to track if an animatable property effects layout. + /// This is used after `on_animation_frame` to optionally recompute the layout. + pub fn set_needs_relayout(&mut self, needs_relayout: bool) { + self.needs_relayout |= needs_relayout; + } + + /// Returns whether we need to perform a relayout or not. + pub fn needs_relayout(&self) -> bool { + self.needs_relayout + } + + /// OR'd with the provided boolean and the previously stored boolean, to track if any animation is active. + pub fn set_has_active_animation(&mut self, has_active_animation: bool) { + self.has_active_animation |= has_active_animation; + } + + /// Returns true if any animation is in the Playing state. + pub fn has_active_animation(&self) -> bool { + self.has_active_animation + } +} + +impl ActiveAnimation { + + /// Advances an active animation, and it is also responsible for tracking the status and element_state. + pub fn tick(&mut self, animation_flags: &mut AnimationFlags, animation: &Animation, _state: ElementState, delta: Duration) { + if self.status == AnimationStatus::Playing { + self.current += delta; + + let is_completed = self.current >= animation.duration; + + match &mut self.loop_amount { + LoopAmount::Infinite => { + if is_completed { + self.current = Duration::ZERO; + } + } + LoopAmount::Fixed(amount) => { + if is_completed { + *amount -= 1; + + if *amount == 0 { + self.current = Duration::ZERO; + self.status = AnimationStatus::Paused; + animation_flags.set_needs_relayout(true); + } else { + self.current = Duration::ZERO; + } + } + } + } + + } + } + + /// Called after `tick`, and is responsible for using the current animation time and + /// computing an interpolated style from a provided `Animation`. + pub fn compute_style(&mut self, element_style: &Style, animation: &Animation, _state: ElementState, animation_flags: &mut AnimationFlags) -> Style { + if self.status != AnimationStatus::Playing { + return element_style.clone(); + } + + let pos = Duration::div_duration_f32(self.current, animation.duration); + fn find_keyframe_pair(pos: f32, animation: &Animation) -> (&KeyFrame, &KeyFrame) { + let mut sorted = animation.key_frames.iter().collect::>(); + sorted.sort_by(|a, b| a.offset_percentage.total_cmp(&b.offset_percentage)); + for window in sorted.windows(2) { + let [start, end] = window else { continue }; + if pos >= (start.offset_percentage / 100.0) && pos <= (end.offset_percentage / 100.0) { + return (start, end); + } + } + + panic!("No keyframes available."); + } + + let (keyframe_start, keyframe_end) = find_keyframe_pair(pos, animation); + + let mut style = Style::default(); + let mut start_map = HashMap::new(); + let mut end_map = HashMap::new(); + + for prop in &keyframe_start.properties { + start_map.insert(std::mem::discriminant(prop), prop); + } + + for prop in &keyframe_end.properties { + end_map.insert(std::mem::discriminant(prop), prop); + } + + for key in start_map.keys().chain(end_map.keys()).collect::>() { + let start_prop = start_map.get(key); + let end_prop = end_map.get(key); + + let start_percentage = keyframe_start.offset_percentage / 100.0; + let end_percentage = keyframe_end.offset_percentage / 100.0; + let local_t = (pos - start_percentage) / (end_percentage - start_percentage); + + let t = match &animation.timing_function { + TimingFunction::Linear => { + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#linear + let linear = FixedCubicBezier::new(0.0, 0.0, 1.0, 1.0); + linear.cubic_bez.eval(local_t as f64).y + } + TimingFunction::Ease => { + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease + let ease = FixedCubicBezier::new(0.25, 0.1, 0.25, 1.0); + ease.cubic_bez.eval(local_t as f64).y + } + TimingFunction::EaseIn => { + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-in + let ease_in = FixedCubicBezier::new(0.42, 0.0, 1.0, 1.0); + ease_in.cubic_bez.eval(local_t as f64).y + } + TimingFunction::EaseOut => { + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-out + let ease_out = FixedCubicBezier::new(0.0, 0.0, 0.58, 1.0); + ease_out.cubic_bez.eval(local_t as f64).y + } + TimingFunction::EaseInOut => { + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#ease-in-out + let ease_in_out = FixedCubicBezier::new(0.42, 0.0, 0.58, 1.0); + ease_in_out.cubic_bez.eval(local_t as f64).y + } + // https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function#cubic-beziernumber_01_number_number_01_number + TimingFunction::BezierCurve(cubic_bezier) => { + cubic_bezier.cubic_bez.eval(local_t as f64).y + } + }; + + fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t + } + + #[inline(always)] + fn resolve_unit(start: Unit, end: Unit, t: f64, set_prop: &mut dyn FnMut(Unit)) { + let resolved_start = match start { + Unit::Px(px) => px, + Unit::Percentage(percent) => percent, + Unit::Auto => panic!("Unit must not be auto.") + }; + + let resolved_end = match end { + Unit::Px(px) => px, + Unit::Percentage(percent) => percent, + Unit::Auto => panic!("Unit must not be auto.") + }; + let new = lerp(resolved_start, resolved_end, t as f32); + + // Naively asserts that start and end must be the same Unit type. + let new = match start { + Unit::Px(_) => Unit::Px(new), + Unit::Percentage(_) => Unit::Percentage(new), + _ => unreachable!() + }; + + set_prop(new); + } + + match (start_prop, end_prop) { + (Some(StyleProperty::Background(start)), Some(StyleProperty::Background(end))) => { + let new_color = start.lerp_rect(*end, t as f32); + style.set_background(new_color); + } + (Some(StyleProperty::Color(start)), Some(StyleProperty::Color(end))) => { + let new_color = start.lerp_rect(*end, t as f32); + style.set_color(new_color); + animation_flags.set_needs_relayout(true); + } + (Some(StyleProperty::FontSize(start)), Some(StyleProperty::FontSize(end))) => { + let new = lerp(*start, *end, t as f32); + style.set_font_size(new); + animation_flags.set_needs_relayout(true); + } + (Some(StyleProperty::Width(start)), Some(StyleProperty::Width(end))) + => { + resolve_unit(*start, *end, t, &mut |new| { + style.set_width(new); + }); + animation_flags.set_needs_relayout(true); + } + (Some(StyleProperty::Height(start)), Some(StyleProperty::Height(end))) => { + resolve_unit(*start, *end, t, &mut |new| { + style.set_height(new); + }); + animation_flags.set_needs_relayout(true); + } + + (Some(StyleProperty::Inset(start)), Some(StyleProperty::Inset(end))) => { + let trlb = zip(start.to_array(), end.to_array()).map(|(start, end)| { + let mut inset_unit = Unit::Auto; + resolve_unit(start, end, t, &mut |new| { + inset_unit = new; + }); + + inset_unit + }).collect::>(); + + let inset = TrblRectangle::new(trlb[0], trlb[1], trlb[2], trlb[3]); + + style.set_inset(inset); + animation_flags.set_needs_relayout(true); + } + + _ => {} + } + + } + + + + style + } +} \ No newline at end of file diff --git a/crates/craft_core/src/animations/mod.rs b/crates/craft_core/src/animations/mod.rs new file mode 100644 index 0000000..8e10d76 --- /dev/null +++ b/crates/craft_core/src/animations/mod.rs @@ -0,0 +1,6 @@ +pub mod animation; + +pub use animation::Animation; +pub use animation::KeyFrame; +pub use animation::LoopAmount; +pub use animation::TimingFunction; \ No newline at end of file diff --git a/crates/craft_core/src/app.rs b/crates/craft_core/src/app.rs index a8b1fe3..85d5491 100644 --- a/crates/craft_core/src/app.rs +++ b/crates/craft_core/src/app.rs @@ -33,8 +33,16 @@ use cfg_if::cfg_if; use craft_logging::{info, span, Level}; use kurbo::{Affine, Point}; use peniko::Color; -use std::collections::HashMap; +use std::collections::{HashMap}; use std::sync::Arc; + +#[cfg(not(target_arch = "wasm32"))] +use std::time; +#[cfg(target_arch = "wasm32")] +use web_time as time; + +use std::time::{Duration}; + use taffy::{AvailableSpace, NodeId, TaffyTree}; use craft_runtime::Sender; use ui_events::keyboard::{KeyState, KeyboardEvent, Modifiers, NamedKey}; @@ -43,11 +51,12 @@ use ui_events::ScrollDelta; use ui_events::ScrollDelta::PixelDelta; use winit::dpi::{LogicalSize, PhysicalSize}; use winit::event::Ime; -use winit::event_loop::ActiveEventLoop; +use winit::event_loop::{ActiveEventLoop}; use winit::window::Window; use craft_renderer::RenderList; use craft_resource_manager::resource_event::ResourceEvent; use craft_resource_manager::resource_type::ResourceType; +use crate::animations::animation::{AnimationFlags}; use crate::events::update_queue_entry::UpdateQueueEntry; macro_rules! get_tree { @@ -103,6 +112,25 @@ pub struct App { pub(crate) accesskit_adapter: Option, pub(crate) runtime: CraftRuntimeHandle, pub(crate) modifiers: Modifiers, + pub(crate) last_frame_time: time::Instant, + pub redraw_flags: RedrawFlags, +} + +#[derive(Debug)] +pub struct RedrawFlags { + rebuild_layout: bool, +} + +impl RedrawFlags { + pub fn new(rebuild_layout: bool) -> Self { + Self { + rebuild_layout, + } + } + + pub fn should_rebuild_layout(&self) -> bool { + self.rebuild_layout + } } impl App { @@ -283,11 +311,14 @@ impl App { if self.window.is_none() { return; } + + let now = time::Instant::now(); + let delta_time = now - self.last_frame_time; + self.last_frame_time = now; let surface_size = self.window_context.window_size(); self.setup_text_context(); - self.update_view(); cfg_if! { @@ -310,15 +341,21 @@ impl App { } } + let layout_origin = Point::new(0.0, 0.0); + { - self.layout_tree( - false, - root_size, - Point::new(0.0, 0.0), - self.window_context.effective_scale_factor(), - self.window_context.mouse_position, - ); - + if self.redraw_flags.should_rebuild_layout() { + self.layout_tree( + false, + root_size, + layout_origin, + self.window_context.effective_scale_factor(), + self.window_context.mouse_position, + ); + } + + self.animate_tree(false, &delta_time, layout_origin, root_size); + if self.renderer.is_some() { self.draw_reactive_tree(false, self.window_context.mouse_position, self.window.clone()); } @@ -326,6 +363,9 @@ impl App { #[cfg(feature = "dev_tools")] { + let viewport_size = LogicalSize::new(surface_size.width - root_size.width, root_size.height); + let dev_tools_layout_origin = Point::new(root_size.width as f64, 0.0); + if self.is_dev_tools_open { update_reactive_tree( dev_tools_view(self.user_tree.element_tree.clone().unwrap()), @@ -339,12 +379,14 @@ impl App { self.layout_tree( true, - LogicalSize::new(surface_size.width - root_size.width, root_size.height), + viewport_size, Point::new(root_size.width as f64, 0.0), self.window_context.effective_scale_factor(), self.window_context.mouse_position, ); + self.animate_tree(true, &delta_time, dev_tools_layout_origin, viewport_size); + if self.renderer.is_some() { self.draw_reactive_tree(true, self.window_context.mouse_position, self.window.clone()); } @@ -386,7 +428,7 @@ impl App { } else { self.window_context.zoom_in(); } - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); return; } @@ -394,7 +436,7 @@ impl App { let message = Message::CraftMessage(event); self.dispatch_event(&message, EventDispatchType::Bubbling, false); - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } pub fn on_pointer_button( @@ -424,7 +466,7 @@ impl App { self.dispatch_event(&message, EventDispatchType::Bubbling, true); } - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } pub fn on_pointer_moved(&mut self, mouse_moved: PointerUpdate) { @@ -439,7 +481,7 @@ impl App { self.dispatch_event(&message, EventDispatchType::Bubbling, true); - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } pub fn on_ime(&mut self, ime: Ime) { @@ -448,7 +490,7 @@ impl App { self.dispatch_event(&message, EventDispatchType::Bubbling, false); - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } /// Dispatch messages to the reactive tree. @@ -487,11 +529,11 @@ impl App { if keyboard_input.modifiers.ctrl() { if keyboard_input.key == ui_events::keyboard::Key::Character("=".to_string()) { self.window_context.zoom_in(); - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); return; } else if keyboard_input.key == ui_events::keyboard::Key::Character("-".to_string()) { self.window_context.zoom_out(); - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); return; } } @@ -511,7 +553,7 @@ impl App { } } - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } /// Processes async messages sent from the user. @@ -547,7 +589,7 @@ impl App { )); } - self.request_redraw(); + self.request_redraw(RedrawFlags::new(true)); } pub fn on_resource_event(&mut self, resource_event: ResourceEvent) { @@ -575,12 +617,49 @@ impl App { ); } - fn request_redraw(&self) { + fn request_redraw(&mut self, redraw_flags: RedrawFlags) { + self.redraw_flags = redraw_flags; if let Some(window) = &self.window { window.request_redraw(); } } + /// "Animates" a tree by calling `on_animation_frame` and changing an element's styles. + fn animate_tree(&mut self, is_dev_tree: bool, delta_time: &Duration, layout_origin: Point, viewport_size: LogicalSize) { + + let span = span!(Level::INFO, "animate_tree"); + let _enter = span.enter(); + + let reactive_tree = get_tree!(self, is_dev_tree); + let old_has_active_animation = reactive_tree.previous_animation_flags.has_active_animation(); + let root_element = reactive_tree.element_tree.as_mut().unwrap(); + + // Damage track across recursive calls to `on_animation_frame`. + let mut animation_flags = AnimationFlags::default(); + root_element.on_animation_frame(&mut animation_flags, &mut reactive_tree.element_state, *delta_time); + reactive_tree.previous_animation_flags = animation_flags; + + // Perform a relayout if an animation used any layout effecting style property. + if animation_flags.needs_relayout() || old_has_active_animation { + root_element.reset_layout_item(); + + self.layout_tree( + is_dev_tree, + viewport_size, + layout_origin, + self.window_context.effective_scale_factor(), + self.window_context.mouse_position, + ); + } + + // Request a redraw if there is at least one animation playing. + // ControlFlow::Poll is set in `about_to_wait`. + if animation_flags.has_active_animation() || old_has_active_animation { + // Winit does not guarantee when a redraw event will happen, but that should be fine, at worst we redraw an extra time. + self.request_redraw(RedrawFlags::new(old_has_active_animation)); + } + } + #[allow(clippy::too_many_arguments)] fn layout_tree( &mut self, diff --git a/crates/craft_core/src/craft_winit_state.rs b/crates/craft_core/src/craft_winit_state.rs index debcaac..605ac24 100644 --- a/crates/craft_core/src/craft_winit_state.rs +++ b/crates/craft_core/src/craft_winit_state.rs @@ -251,9 +251,23 @@ impl ApplicationHandler for CraftWinitState { return; } - if !craft_state.wait_cancelled { + // Switch to Poll mode if we are running animations. + + + + let mut has_active_animation = craft_state.craft_app.user_tree.previous_animation_flags.has_active_animation(); + + #[cfg(feature = "dev_tools")] + { + has_active_animation = has_active_animation || craft_state.craft_app.dev_tree.previous_animation_flags.has_active_animation(); + } + + if has_active_animation { + event_loop.set_control_flow(ControlFlow::Poll); + } else { event_loop.set_control_flow(ControlFlow::WaitUntil(time::Instant::now() + WAIT_TIME)); } + } } diff --git a/crates/craft_core/src/elements/base_element_state.rs b/crates/craft_core/src/elements/base_element_state.rs index 27ce1dd..05cebf8 100644 --- a/crates/craft_core/src/elements/base_element_state.rs +++ b/crates/craft_core/src/elements/base_element_state.rs @@ -2,6 +2,8 @@ use crate::elements::element_data::ElementData; use crate::elements::element_states::ElementState; use crate::style::Style; use std::collections::HashMap; +use rustc_hash::FxHashMap; +use crate::animations::animation::ActiveAnimation; #[derive(Debug, Default, Clone)] pub struct BaseElementState { @@ -13,6 +15,7 @@ pub struct BaseElementState { /// Useful for scroll thumbs. pub(crate) pointer_capture: HashMap, pub(crate) focused: bool, + pub(crate) animations: Option>, } impl<'a> BaseElementState { @@ -43,6 +46,21 @@ impl<'a> BaseElementState { } &mut element_data.style } + pub fn current_style_mut_no_fallback(&self, element_data: &'a mut ElementData) -> Option<&'a mut Style> { + if self.active { + if let Some(pressed_style) = &mut element_data.pressed_style { + return Some(pressed_style); + } + } + if self.hovered { + if let Some(hover_style) = &mut element_data.hover_style { + return Some(hover_style); + } + } + + None + } + } // HACK: Remove this and all usages when pointer capture per device works. diff --git a/crates/craft_core/src/elements/element.rs b/crates/craft_core/src/elements/element.rs index b107d93..288baab 100644 --- a/crates/craft_core/src/elements/element.rs +++ b/crates/craft_core/src/elements/element.rs @@ -1,24 +1,27 @@ +use crate::animations::animation::{ActiveAnimation, AnimationFlags, AnimationStatus}; use crate::components::component::{ComponentOrElement, ComponentSpecification}; use crate::components::{ComponentId, Event, FocusAction}; use crate::elements::element_data::ElementData; use crate::elements::element_states::ElementState; use crate::elements::scroll_state::ScrollState; use crate::events::CraftMessage; -use craft_primitives::geometry::borders::{BorderSpec, ComputedBorderSpec}; -use craft_primitives::geometry::{ElementBox, Point, Rectangle, TrblRectangle}; use crate::layout::layout_context::LayoutContext; use crate::layout::layout_item::{draw_borders_generic, LayoutItem}; use crate::reactive::element_state_store::{ElementStateStore, ElementStateStoreItem}; -use craft_renderer::renderer::RenderList; use crate::style::Style; use crate::text::text_context::TextContext; #[cfg(feature = "accesskit")] use accesskit::{Action, Role}; +use craft_primitives::geometry::borders::{BorderSpec, ComputedBorderSpec}; +use craft_primitives::geometry::{ElementBox, Point, Rectangle, TrblRectangle}; +use craft_renderer::renderer::RenderList; use kurbo::Affine; use peniko::Color; use std::any::Any; use std::mem; use std::sync::Arc; +use std::time::Duration; +use rustc_hash::FxHashMap; use taffy::{NodeId, Overflow, TaffyTree}; use winit::window::Window; @@ -198,6 +201,15 @@ pub trait Element: Any + StandardElementClone + Send + Sync { position, ); } + + /// A bit of a hack to reset the layout item of an element recursively. + fn reset_layout_item(&mut self) { + *self.layout_item_mut() = LayoutItem::default(); + + for child in self.element_data_mut().children.iter_mut() { + child.internal.reset_layout_item(); + } + } fn draw_children( &mut self, @@ -313,6 +325,77 @@ pub trait Element: Any + StandardElementClone + Send + Sync { fn get_base_state_mut<'a>(&self, element_state: &'a mut ElementStateStore) -> &'a mut ElementStateStoreItem { element_state.storage.get_mut(&self.element_data().component_id).unwrap() } + + /// Called after layout, and is responsible for updating the animation state of an element. + fn on_animation_frame(&mut self, animation_flags: &mut AnimationFlags, element_state: &mut ElementStateStore, delta_time: Duration) { + let base_state = self.get_base_state_mut(element_state); + let current_state: ElementState = { + if base_state.base.hovered { + ElementState::Hovered + } else if base_state.base.focused { + ElementState::Focused + } else { + ElementState::Normal + } + }; + + // If we don't have an animation in the current style then try to fall back to the normal style. + let current_style = + if let Some(current_style) = base_state.base.current_style_mut_no_fallback(self.element_data_mut()) && current_style.animations.is_some() { + current_style + } else { + &mut self.element_data_mut().style + }; + + // This is pretty hacky, but we can avoid allocating a hashmap for every element. + let active_animations = if current_style.animations.is_some() { + if base_state.base.animations.is_none() { + base_state.base.animations = Some(FxHashMap::default()); + } + + base_state.base.animations.as_mut().unwrap() + } else { + for child in self.children_mut() { + child.internal.on_animation_frame(animation_flags, element_state, delta_time); + } + return; + }; + + if let Some(current_style_animations) = &mut current_style.animations { + for ani in &mut *current_style_animations { + if !active_animations.contains_key(&ani.name) { + active_animations.insert(ani.name.clone(), ActiveAnimation { + current: Duration::ZERO, + status: AnimationStatus::Playing, + loop_amount: ani.loop_amount, + }); + } + } + + active_animations.retain(|key, _| { + current_style_animations.iter().any(|ani| &ani.name == key) + }); + } + + active_animations.retain(|anim_name, active_animation| { + if active_animation.status == AnimationStatus::Playing { + animation_flags.set_has_active_animation(true); + } + + if let Some(animation) = current_style.animation(anim_name) { + active_animation.tick(animation_flags, animation, current_state, delta_time); + let new_style = active_animation.compute_style(current_style, animation, current_state, animation_flags); + *current_style = Style::merge(current_style, &new_style); + true + } else { + false + } + }); + + for child in self.children_mut() { + child.internal.on_animation_frame(animation_flags, element_state, delta_time); + } + } #[cfg(feature = "accesskit")] fn compute_accessibility_tree( @@ -590,6 +673,14 @@ macro_rules! generate_component_methods { self } + #[allow(dead_code)] + pub fn extend_children_in_place(&mut self, children: Vec) + where + T: Into, + { + self.element_data.child_specs.extend(children.into_iter().map(|x| x.into())); + } + #[allow(dead_code)] pub fn push_in_place(&mut self, component_specification: ComponentSpecification) { self.element_data.child_specs.push(component_specification); diff --git a/crates/craft_core/src/elements/element_states.rs b/crates/craft_core/src/elements/element_states.rs index cc92369..8e9e4cb 100644 --- a/crates/craft_core/src/elements/element_states.rs +++ b/crates/craft_core/src/elements/element_states.rs @@ -1,4 +1,4 @@ -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ElementState { #[default] Normal, diff --git a/crates/craft_core/src/elements/element_styles.rs b/crates/craft_core/src/elements/element_styles.rs index 087dd7d..2833c5a 100644 --- a/crates/craft_core/src/elements/element_styles.rs +++ b/crates/craft_core/src/elements/element_styles.rs @@ -3,6 +3,7 @@ use craft_primitives::geometry::TrblRectangle; use craft_primitives::Color; use crate::style::{AlignItems, Display, FlexDirection, FontStyle, JustifyContent, Overflow, Style, Underline, Unit, Weight, Wrap}; use taffy::Position; +use crate::animations::animation::Animation; pub trait ElementStyles where @@ -262,6 +263,11 @@ where self.styles_mut().set_visible(visible); self } + + fn push_animation(mut self, animation: Animation) -> Self { + self.styles_mut().set_animation(animation); + self + } } impl From<&str> for Unit { diff --git a/crates/craft_core/src/lib.rs b/crates/craft_core/src/lib.rs index f492df4..95fecd8 100644 --- a/crates/craft_core/src/lib.rs +++ b/crates/craft_core/src/lib.rs @@ -24,6 +24,7 @@ pub mod markdown; mod utils; #[cfg(target_arch = "wasm32")] pub mod wasm_queue; +pub mod animations; pub use options::CraftOptions; pub use craft_primitives::palette; @@ -52,7 +53,10 @@ use std::collections::VecDeque; use std::future::Future; use std::pin::Pin; use std::sync::Arc; - +#[cfg(not(target_arch = "wasm32"))] +use std::time; +#[cfg(target_arch = "wasm32")] +use web_time as time; use crate::reactive::reactive_tree::ReactiveTree; use crate::reactive::state_store::{StateStore, StateStoreItem}; #[cfg(target_arch = "wasm32")] @@ -66,6 +70,7 @@ use craft_logging::info; use {winit::event_loop::EventLoopBuilder, winit::platform::android::EventLoopBuilderExtAndroid}; use app::App; +use crate::app::RedrawFlags; use crate::craft_winit_state::CraftWinitState; use crate::utils::cloneable_any::CloneableAny; @@ -242,6 +247,7 @@ pub fn setup_craft( user_state, element_state: Default::default(), focus: None, + previous_animation_flags: Default::default(), }, #[cfg(feature = "dev_tools")] @@ -258,9 +264,12 @@ pub fn setup_craft( component_ids: Default::default(), pointer_captures: Default::default(), focus: None, + previous_animation_flags: Default::default(), }, runtime: runtime_copy, modifiers: Default::default(), + last_frame_time: time::Instant::now(), + redraw_flags: RedrawFlags::new(true), }); CraftState::new(runtime, winit_receiver, app_sender, craft_options, craft_app) diff --git a/crates/craft_core/src/reactive/reactive_tree.rs b/crates/craft_core/src/reactive/reactive_tree.rs index 7bed41c..a8c8a70 100644 --- a/crates/craft_core/src/reactive/reactive_tree.rs +++ b/crates/craft_core/src/reactive/reactive_tree.rs @@ -5,6 +5,7 @@ use crate::reactive::element_state_store::ElementStateStore; use crate::reactive::state_store::StateStore; use crate::reactive::tree::ComponentTreeNode; use std::collections::{HashMap, HashSet, VecDeque}; +use crate::animations::animation::AnimationFlags; #[derive(Default)] pub struct ReactiveTree { @@ -18,6 +19,7 @@ pub struct ReactiveTree { pub(crate) user_state: StateStore, pub(crate) element_state: ElementStateStore, pub(crate) focus: Option, + pub(crate) previous_animation_flags: AnimationFlags, } impl ReactiveTree { diff --git a/crates/craft_core/src/style/styles.rs b/crates/craft_core/src/style/styles.rs index c3c48f9..a4beeed 100644 --- a/crates/craft_core/src/style/styles.rs +++ b/crates/craft_core/src/style/styles.rs @@ -6,11 +6,12 @@ pub use taffy::BoxSizing; pub use taffy::Overflow; pub use taffy::Position; +use crate::animations::animation::Animation; use craft_primitives::geometry::TrblRectangle; use craft_primitives::ColorBrush; +use smallvec::SmallVec; use std::fmt; use std::fmt::Debug; -use smallvec::SmallVec; #[derive(Clone, Copy, Debug)] pub enum Unit { @@ -177,24 +178,19 @@ impl TextStyleProperty { pub(crate) fn to_parley_style_property(&self) -> Option> { match self { TextStyleProperty::FontFamily(font_family) => { - let font_stack_cow_list = - Cow::Owned(vec![ - parley::FontFamily::Named(Cow::Owned(font_family.to_string())), - parley::FontFamily::Generic(parley::GenericFamily::SystemUi), - ]); + let font_stack_cow_list = Cow::Owned(vec![ + parley::FontFamily::Named(Cow::Owned(font_family.to_string())), + parley::FontFamily::Generic(parley::GenericFamily::SystemUi), + ]); let font_stack = parley::FontStack::List(font_stack_cow_list); Some(parley::StyleProperty::FontStack(font_stack)) } - TextStyleProperty::FontSize(font_size) => { - Some(parley::StyleProperty::FontSize(*font_size)) - } + TextStyleProperty::FontSize(font_size) => Some(parley::StyleProperty::FontSize(*font_size)), TextStyleProperty::Color(color) => { - let brush = ColorBrush { - color: *color, - }; + let brush = ColorBrush { color: *color }; Some(parley::StyleProperty::Brush(brush)) } @@ -213,25 +209,17 @@ impl TextStyleProperty { TextStyleProperty::FontWeight(font_weight) => { Some(parley::StyleProperty::FontWeight(parley::FontWeight::new(font_weight.0 as f32))) } - TextStyleProperty::Underline(underline) => { - Some(parley::StyleProperty::Underline(*underline)) - } - TextStyleProperty::UnderlineOffset(offset) => { - Some(parley::StyleProperty::UnderlineOffset(Some(*offset))) - } + TextStyleProperty::Underline(underline) => Some(parley::StyleProperty::Underline(*underline)), + TextStyleProperty::UnderlineOffset(offset) => Some(parley::StyleProperty::UnderlineOffset(Some(*offset))), - TextStyleProperty::UnderlineSize(size) => { - Some(parley::StyleProperty::UnderlineSize(Some(*size))) - } + TextStyleProperty::UnderlineSize(size) => Some(parley::StyleProperty::UnderlineSize(Some(*size))), TextStyleProperty::UnderlineBrush(color) => { - let brush = ColorBrush { - color: *color, - }; + let brush = ColorBrush { color: *color }; Some(parley::StyleProperty::UnderlineBrush(Some(brush))) } - TextStyleProperty::Link(_) | TextStyleProperty::BackgroundColor(_) => { None } + TextStyleProperty::Link(_) | TextStyleProperty::BackgroundColor(_) => None, } } } @@ -260,7 +248,7 @@ pub enum StyleProperty { FlexGrow(f32), FlexShrink(f32), FlexBasis(Unit), - + Color(Color), Background(Color), /// Defaults to the text color, if it is None. @@ -286,8 +274,7 @@ pub enum StyleProperty { Visible(bool), } -#[derive(Clone, Debug, Copy)] -#[derive(PartialEq)] +#[derive(Clone, Debug, Copy, PartialEq)] pub struct FontFamily { font_family_length: u8, font_family_name: [u8; 64], @@ -299,7 +286,7 @@ impl FontFamily { font_family_length: 0, font_family_name: [0; 64], }; - + let chars = font_family.chars().collect::>(); font_family_res.font_family_length = chars.len() as u8; font_family_res.font_family_name[..font_family.len()].copy_from_slice(font_family.as_bytes()); @@ -333,6 +320,7 @@ impl Default for FontFamily { pub struct Style { properties: SmallVec<[StyleProperty; 5]>, pub dirty_flags: StyleFlags, + pub animations: Option>, } impl Default for Style { @@ -340,6 +328,7 @@ impl Default for Style { Style { properties: SmallVec::new(), dirty_flags: StyleFlags::empty(), + animations: None, } } } @@ -350,15 +339,12 @@ macro_rules! style_property { ) => { impl Style { pub fn $get(&self) -> $inner { - self.properties.iter().find_map(|p| { - if let StyleProperty::$variant(val) = p { - Some(*val) - } else { - None - } - }).unwrap_or($default) + self.properties + .iter() + .find_map(|p| if let StyleProperty::$variant(val) = p { Some(*val) } else { None }) + .unwrap_or($default) } - + pub fn $set(&mut self, val: $inner) { if self.dirty_flags.contains(StyleFlags::$flag) { self.remove_property(|p| matches!(p, StyleProperty::$variant(_))); @@ -406,21 +392,91 @@ style_property!(font_style, set_font_style, FontStyle, FontStyle, FONT_STYLE, Fo style_property!(underline, set_underline, Underline, Option, UNDERLINE, None); style_property!(overflow, set_overflow, Overflow, [Overflow; 2], OVERFLOW, [Overflow::default(); 2]); -style_property!(border_color, set_border_color, BorderColor, TrblRectangle, BORDER_COLOR, TrblRectangle::new_all(Color::BLACK)); -style_property!(border_width, set_border_width, BorderWidth, TrblRectangle, BORDER_WIDTH, TrblRectangle::new_all(Unit::Px(0.0))); +style_property!( + border_color, + set_border_color, + BorderColor, + TrblRectangle, + BORDER_COLOR, + TrblRectangle::new_all(Color::BLACK) +); +style_property!( + border_width, + set_border_width, + BorderWidth, + TrblRectangle, + BORDER_WIDTH, + TrblRectangle::new_all(Unit::Px(0.0)) +); style_property!(border_radius, set_border_radius, BorderRadius, [(f32, f32); 4], BORDER_RADIUS, [(0.0, 0.0); 4]); -style_property!(scrollbar_color, set_scrollbar_color, ScrollbarColor, ScrollbarColor, SCROLLBAR_COLOR, ScrollbarColor {thumb_color: Color::from_rgb8(150, 150, 152), track_color: Color::TRANSPARENT}); -const SCROLLBAR_THUMB_MARGIN: TrblRectangle = if cfg!(any(target_os = "android", target_os = "ios")) { TrblRectangle::new_all(0.0) } else { TrblRectangle::new(1.0, 2.0, 1.0, 2.0) }; -style_property!(scrollbar_thumb_margin, set_scrollbar_thumb_margin, ScrollbarThumbMargin, TrblRectangle, SCROLLBAR_THUMB_MARGIN, SCROLLBAR_THUMB_MARGIN); -style_property!(scrollbar_thumb_radius, set_scrollbar_thumb_radius, ScrollbarRadius, [(f32, f32); 4], SCROLLBAR_RADIUS, [(10.0, 10.0); 4]); -style_property!(scrollbar_width, set_scrollbar_width, ScrollbarWidth, f32, SCROLLBAR_WIDTH, if cfg!(any(target_os = "android", target_os = "ios")) { 0.0 } else { 10.0 }); +style_property!( + scrollbar_color, + set_scrollbar_color, + ScrollbarColor, + ScrollbarColor, + SCROLLBAR_COLOR, + ScrollbarColor { + thumb_color: Color::from_rgb8(150, 150, 152), + track_color: Color::TRANSPARENT + } +); +const SCROLLBAR_THUMB_MARGIN: TrblRectangle = if cfg!(any(target_os = "android", target_os = "ios")) { + TrblRectangle::new_all(0.0) +} else { + TrblRectangle::new(1.0, 2.0, 1.0, 2.0) +}; +style_property!( + scrollbar_thumb_margin, + set_scrollbar_thumb_margin, + ScrollbarThumbMargin, + TrblRectangle, + SCROLLBAR_THUMB_MARGIN, + SCROLLBAR_THUMB_MARGIN +); +style_property!( + scrollbar_thumb_radius, + set_scrollbar_thumb_radius, + ScrollbarRadius, + [(f32, f32); 4], + SCROLLBAR_RADIUS, + [(10.0, 10.0); 4] +); +style_property!( + scrollbar_width, + set_scrollbar_width, + ScrollbarWidth, + f32, + SCROLLBAR_WIDTH, + if cfg!(any(target_os = "android", target_os = "ios")) { 0.0 } else { 10.0 } +); style_property!(visible, set_visible, Visible, bool, VISIBLE, true); -style_property!(selection_color, set_selection_color, SelectionColor, Color, SELECTION_COLOR, Color::from_rgb8(0, 120, 215)); +style_property!( + selection_color, + set_selection_color, + SelectionColor, + Color, + SELECTION_COLOR, + Color::from_rgb8(0, 120, 215) +); style_property!(cursor_color, set_cursor_color, CursorColor, Option, CURSOR_COLOR, None); impl Style { + pub fn animation(&self, animation: &str) -> Option<&Animation> { + self.animations.as_ref().map(|ani| ani.iter().find(|ani| ani.name == animation)).unwrap_or_default() + } + + pub fn set_animation(&mut self, animation: Animation) { + if let Some(animations) = &mut self.animations { + animations.push(animation); + } else { + let mut ani_vec = SmallVec::new(); + ani_vec.push(animation); + self.animations = Some(ani_vec); + } + } + fn remove_property(&mut self, f: impl Fn(&StyleProperty) -> bool) { if let Some(pos) = self.properties.iter().position(f) { self.properties.remove(pos); @@ -445,6 +501,7 @@ impl Style { } let mut merged = old.clone(); + merged.animations = None; for prop in &new.properties { let flag = match prop { @@ -621,5 +678,4 @@ impl Style { style_set.insert(parley::StyleProperty::UnderlineOffset(underline_offset)); style_set.insert(parley::StyleProperty::UnderlineSize(underline_size)); } - } diff --git a/examples/animations/Cargo.toml b/examples/animations/Cargo.toml new file mode 100644 index 0000000..8d9a85e --- /dev/null +++ b/examples/animations/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "animations" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "animations" +path = "main.rs" + +[dependencies] +util = { path = "../util" } + +[dependencies.craft] +path = "../../crates/craft" +default-features = false +features = ["vello_renderer", "devtools", "accesskit"] +package = "craft_gui" diff --git a/examples/animations/main.rs b/examples/animations/main.rs new file mode 100644 index 0000000..b40b240 --- /dev/null +++ b/examples/animations/main.rs @@ -0,0 +1,112 @@ +use craft::animations::{Animation, KeyFrame, LoopAmount, TimingFunction}; +use craft::components::Context; +use craft::elements::Text; +use craft::geometry::TrblRectangle; +use craft::style::{Position, StyleProperty, Unit}; +use craft::{ + components::{Component, ComponentSpecification}, + elements::{Container, ElementStyles}, + palette, + style::{Display, FlexDirection}, +}; +use std::time::Duration; + +#[derive(Default)] +pub struct AnimationsExample {} + +impl Component for AnimationsExample { + type GlobalState = (); + type Props = (); + type Message = (); + + fn view(_context: &mut Context) -> ComponentSpecification { + let growing_animation = Animation::new("growing_animation", Duration::from_secs(5), TimingFunction::EaseOut) + .push( + KeyFrame::new(0.0) + .push(StyleProperty::Background(palette::css::GREEN)) + .push(StyleProperty::Width(Unit::Percentage(10.0))) + .push(StyleProperty::Height(Unit::Px(40.0))), + ) + .push( + KeyFrame::new(100.0) + .push(StyleProperty::Background(palette::css::RED)) + .push(StyleProperty::Width(Unit::Percentage(80.0))) + .push(StyleProperty::Height(Unit::Px(100.0))), + ) + .loop_amount(LoopAmount::Fixed(3)); + + let moving_animation = Animation::new("moving_animation", Duration::from_secs(5), TimingFunction::Ease) + .push(KeyFrame::new(0.0).push(StyleProperty::Background(palette::css::BLUE)).push(StyleProperty::Inset( + TrblRectangle::new(Unit::Px(100.0), Unit::Px(0.0), Unit::Px(0.0), Unit::Px(0.0)), + ))) + .push(KeyFrame::new(50.0).push(StyleProperty::Background(palette::css::MAGENTA)).push( + StyleProperty::Inset(TrblRectangle::new( + Unit::Px(150.0), + Unit::Px(0.0), + Unit::Px(0.0), + Unit::Px(250.0), + )), + )) + .push( + KeyFrame::new(100.0) + .push(StyleProperty::Background(palette::css::YELLOW)) + .push(StyleProperty::Inset(TrblRectangle::new(100.into(), 0.into(), 0.into(), 0.into()))), + ) + .loop_amount(LoopAmount::Infinite); + + let text_animation = Animation::new("text_animation", Duration::from_secs(5), TimingFunction::Ease) + .push( + KeyFrame::new(0.0) + .push(StyleProperty::Background(palette::css::RED)) + .push(StyleProperty::Color(palette::css::BLUE)) + .push(StyleProperty::FontSize(20.0)), + ) + .push( + KeyFrame::new(100.0) + .push(StyleProperty::Background(palette::css::YELLOW)) + .push(StyleProperty::Color(palette::css::BLUE_VIOLET)) + .push(StyleProperty::FontSize(40.0)), + ) + .loop_amount(LoopAmount::Infinite); + + let animation_examples: Vec = vec![ + Container::new() + .background(palette::css::GRAY) + .width("100px") + .height("40px") + .push_animation(growing_animation) + .component(), + Container::new() + .push( + Container::new() + .inset(Unit::Px(100.0), Unit::Px(0.0), Unit::Px(0.0), Unit::Px(0.0)) + .background(palette::css::BLUE) + .position(Position::Absolute) + .width("40px") + .height("40px") + .push_animation(moving_animation), + ) + .component(), + Text::new("Why, Hello!").push_animation(text_animation).component(), + ]; + + let mut container = Container::new() + .display(Display::Flex) + .flex_direction(FlexDirection::Column) + .width("100%") + .height("100%") + .gap(20); + + container.extend_children_in_place(animation_examples); + + container.component() + } +} + +#[allow(unused)] +#[cfg(not(target_os = "android"))] +fn main() { + use craft::CraftOptions; + util::setup_logging(); + craft::craft_main(AnimationsExample::component(), (), CraftOptions::basic("Animations")); +} diff --git a/website/src/examples.rs b/website/src/examples.rs index c2fda09..5090eed 100644 --- a/website/src/examples.rs +++ b/website/src/examples.rs @@ -10,10 +10,14 @@ mod request; #[path = "../../examples/tour/main.rs"] mod tour; +#[path = "../../examples/animations/main.rs"] +mod animations; + use crate::examples::counter::Counter; use crate::examples::request::AniList; use crate::examples::text::TextState; use crate::examples::tour::Tour; +use crate::examples::animations::AnimationsExample; use crate::navbar::NAVBAR_HEIGHT; use crate::theme::{wrapper, ACTIVE_LINK_COLOR, DEFAULT_LINK_COLOR, MOBILE_MEDIA_QUERY_WIDTH, WRAPPER_PADDING_LEFT, WRAPPER_PADDING_RIGHT}; use crate::WebsiteGlobalState; @@ -30,6 +34,7 @@ const COUNTER_EXAMPLE_LINK: &str = "/examples/counter"; const TOUR_EXAMPLE_LINK: &str = "/examples/tour"; const REQUEST_EXAMPLE_LINK: &str = "/examples/request"; const TEXT_EXAMPLE_LINK: &str = "/examples/text"; +const ANIMATIONS_EXAMPLE_LINK: &str = "/examples/animations"; #[derive(Default)] pub(crate) struct Examples { @@ -60,7 +65,8 @@ fn examples_sidebar(example_to_show: &String, window: &WindowContext) -> Compone create_examples_link("Counter", COUNTER_EXAMPLE_LINK, example_to_show), create_examples_link("Tour", TOUR_EXAMPLE_LINK, example_to_show), create_examples_link("Request", REQUEST_EXAMPLE_LINK, example_to_show), - create_examples_link("Text", TEXT_EXAMPLE_LINK, example_to_show) + create_examples_link("Text", TEXT_EXAMPLE_LINK, example_to_show), + create_examples_link("Animations", ANIMATIONS_EXAMPLE_LINK, example_to_show) ]; if window.window_width() <= MOBILE_MEDIA_QUERY_WIDTH { @@ -128,6 +134,7 @@ impl Component for Examples { TEXT_EXAMPLE_LINK => TextState::component().key(TEXT_EXAMPLE_LINK), TOUR_EXAMPLE_LINK => Tour::component().key(TOUR_EXAMPLE_LINK).props(Props::new(example_props)), REQUEST_EXAMPLE_LINK => AniList::component().key(REQUEST_EXAMPLE_LINK).props(Props::new(example_props)), + ANIMATIONS_EXAMPLE_LINK => AnimationsExample::component().key(ANIMATIONS_EXAMPLE_LINK).props(Props::new(example_props)), _ => Counter::component().key(COUNTER_EXAMPLE_LINK), };