use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex, RwLock};
use blinc_animation::{AnimationScheduler, SchedulerHandle, Spring, SpringConfig, SpringId};
use blinc_core::context_state::MotionAnimationState;
use blinc_core::{Color, Rect, Transform};
use crate::element::{MotionAnimation, MotionKeyframe};
use crate::tree::LayoutNodeId;
pub type SharedMotionStates = Arc<RwLock<HashMap<String, MotionAnimationState>>>;
pub fn create_shared_motion_states() -> SharedMotionStates {
Arc::new(RwLock::new(HashMap::new()))
}
#[allow(clippy::incompatible_msrv)]
static GLOBAL_SCHEDULER: LazyLock<RwLock<Option<SchedulerHandle>>> =
LazyLock::new(|| RwLock::new(None));
pub fn set_global_scheduler(handle: SchedulerHandle) {
let mut storage = GLOBAL_SCHEDULER.write().unwrap();
*storage = Some(handle);
}
pub fn get_global_scheduler() -> Option<SchedulerHandle> {
GLOBAL_SCHEDULER.read().unwrap().clone()
}
pub fn has_global_scheduler() -> bool {
GLOBAL_SCHEDULER.read().unwrap().is_some()
}
#[allow(clippy::incompatible_msrv)]
static PENDING_MOTION_REPLAYS: LazyLock<Mutex<Vec<String>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
pub fn queue_global_motion_replay(key: String) {
let mut queue = PENDING_MOTION_REPLAYS.lock().unwrap();
if !queue.contains(&key) {
queue.push(key);
}
}
pub fn take_global_motion_replays() -> Vec<String> {
std::mem::take(&mut *PENDING_MOTION_REPLAYS.lock().unwrap())
}
#[allow(clippy::incompatible_msrv)]
static PENDING_MOTION_EXIT_CANCELS: LazyLock<Mutex<Vec<String>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
pub fn queue_global_motion_exit_cancel(key: String) {
let mut queue = PENDING_MOTION_EXIT_CANCELS.lock().unwrap();
if !queue.contains(&key) {
queue.push(key);
}
}
pub fn take_global_motion_exit_cancels() -> Vec<String> {
std::mem::take(&mut *PENDING_MOTION_EXIT_CANCELS.lock().unwrap())
}
#[allow(clippy::incompatible_msrv)]
static PENDING_MOTION_EXIT_STARTS: LazyLock<Mutex<Vec<String>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
pub fn queue_global_motion_exit_start(key: String) {
let mut queue = PENDING_MOTION_EXIT_STARTS.lock().unwrap();
if !queue.contains(&key) {
queue.push(key);
}
}
pub fn take_global_motion_exit_starts() -> Vec<String> {
std::mem::take(&mut *PENDING_MOTION_EXIT_STARTS.lock().unwrap())
}
#[allow(clippy::incompatible_msrv)]
static PENDING_MOTION_STARTS: LazyLock<Mutex<Vec<String>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
pub fn queue_global_motion_start(key: String) {
let mut queue = PENDING_MOTION_STARTS.lock().unwrap();
if !queue.contains(&key) {
queue.push(key);
}
}
pub fn take_global_motion_starts() -> Vec<String> {
std::mem::take(&mut *PENDING_MOTION_STARTS.lock().unwrap())
}
const VIEWPORT_BUFFER: f32 = 100.0;
#[derive(Clone, Debug, Default)]
pub enum MotionState {
Suspended,
Waiting { remaining_delay_ms: f32 },
Entering { progress: f32, duration_ms: f32 },
#[default]
Visible,
Exiting { progress: f32, duration_ms: f32 },
Removed,
}
#[derive(Clone, Debug)]
pub struct ActiveMotion {
pub config: MotionAnimation,
pub state: MotionState,
pub current: MotionKeyframe,
}
#[derive(Clone, Debug)]
pub struct ActiveCssAnimation {
pub animation: blinc_animation::MultiKeyframeAnimation,
pub is_playing: bool,
pub current_properties: blinc_animation::KeyframeProperties,
}
impl ActiveCssAnimation {
pub fn new(mut animation: blinc_animation::MultiKeyframeAnimation) -> Self {
animation.start();
let current = animation.current_properties();
Self {
animation,
is_playing: true,
current_properties: current,
}
}
pub fn tick(&mut self, dt_ms: f32) -> bool {
if self.is_playing {
self.animation.tick(dt_ms);
self.current_properties = self.animation.current_properties();
if !self.animation.is_playing() {
self.is_playing = false;
}
}
self.is_playing
}
}
#[derive(Default)]
pub struct CssAnimationStore {
pub animations: HashMap<LayoutNodeId, ActiveCssAnimation>,
pub transitions: HashMap<LayoutNodeId, ActiveCssAnimation>,
}
impl CssAnimationStore {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, dt_ms: f32) -> (bool, bool) {
let mut anim_playing = false;
for anim in self.animations.values_mut() {
if anim.tick(dt_ms) {
anim_playing = true;
}
}
let mut trans_playing = false;
for trans in self.transitions.values_mut() {
if trans.tick(dt_ms) {
trans_playing = true;
}
}
(anim_playing, trans_playing)
}
pub fn remove_completed_transitions(&mut self) {
self.transitions.retain(|_, trans| trans.is_playing);
}
pub fn has_active_animations(&self) -> bool {
self.animations.values().any(|a| a.is_playing)
}
pub fn has_active_transitions(&self) -> bool {
!self.transitions.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct NodeRenderState {
pub opacity: f32,
pub background_color: Option<Color>,
pub border_color: Option<Color>,
pub transform: Option<Transform>,
pub scale: f32,
pub opacity_spring: Option<SpringId>,
pub bg_color_springs: Option<[SpringId; 4]>,
pub transform_springs: Option<[SpringId; 4]>,
pub hovered: bool,
pub focused: bool,
pub pressed: bool,
pub motion: Option<ActiveMotion>,
pub css_animation: Option<ActiveCssAnimation>,
}
impl Default for NodeRenderState {
fn default() -> Self {
Self {
opacity: 1.0,
background_color: None,
border_color: None,
transform: None,
scale: 1.0,
opacity_spring: None,
bg_color_springs: None,
transform_springs: None,
hovered: false,
focused: false,
pressed: false,
motion: None,
css_animation: None,
}
}
}
impl NodeRenderState {
pub fn new() -> Self {
Self::default()
}
pub fn is_animating(&self) -> bool {
self.opacity_spring.is_some()
|| self.bg_color_springs.is_some()
|| self.transform_springs.is_some()
|| self.has_active_motion()
|| self.has_active_css_animation()
}
pub fn has_active_motion(&self) -> bool {
if let Some(ref motion) = self.motion {
!matches!(motion.state, MotionState::Visible | MotionState::Removed)
} else {
false
}
}
pub fn start_css_animation(&mut self, animation: blinc_animation::MultiKeyframeAnimation) {
self.css_animation = Some(ActiveCssAnimation::new(animation));
}
pub fn has_active_css_animation(&self) -> bool {
self.css_animation
.as_ref()
.map(|a| a.is_playing)
.unwrap_or(false)
}
pub fn tick_css_animation(
&mut self,
dt_ms: f32,
) -> Option<&blinc_animation::KeyframeProperties> {
if let Some(ref mut active) = self.css_animation {
active.tick(dt_ms);
if active.is_playing {
return Some(&active.current_properties);
}
}
None
}
pub fn stop_css_animation(&mut self) {
if let Some(ref mut active) = self.css_animation {
active.is_playing = false;
}
}
pub fn css_animation_properties(&self) -> Option<&blinc_animation::KeyframeProperties> {
self.css_animation
.as_ref()
.filter(|a| a.is_playing)
.map(|a| &a.current_properties)
}
}
#[derive(Clone, Debug)]
pub enum Overlay {
Cursor {
position: (f32, f32),
size: (f32, f32),
color: Color,
opacity: f32,
},
Selection {
rects: Vec<(f32, f32, f32, f32)>,
color: Color,
},
FocusRing {
position: (f32, f32),
size: (f32, f32),
radius: f32,
color: Color,
thickness: f32,
},
}
pub struct RenderState {
node_states: HashMap<LayoutNodeId, NodeRenderState>,
stable_motions: HashMap<String, ActiveMotion>,
stable_motions_used: std::collections::HashSet<String>,
pending_motion_replays: Vec<String>,
overlays: Vec<Overlay>,
animations: Arc<Mutex<AnimationScheduler>>,
cursor_visible: bool,
cursor_blink_time: u64,
cursor_blink_interval: u64,
last_tick_time: Option<u64>,
viewport: Rect,
viewport_set: bool,
shared_motion_states: Option<SharedMotionStates>,
}
impl RenderState {
pub fn new(animations: Arc<Mutex<AnimationScheduler>>) -> Self {
let handle = animations.lock().unwrap().handle();
set_global_scheduler(handle);
Self {
node_states: HashMap::new(),
stable_motions: HashMap::new(),
stable_motions_used: std::collections::HashSet::new(),
pending_motion_replays: Vec::new(),
overlays: Vec::new(),
animations,
cursor_visible: true,
cursor_blink_time: 0,
cursor_blink_interval: 400,
last_tick_time: None,
viewport: Rect::new(0.0, 0.0, 0.0, 0.0),
viewport_set: false,
shared_motion_states: None,
}
}
pub fn set_shared_motion_states(&mut self, shared: SharedMotionStates) {
self.shared_motion_states = Some(shared);
}
pub fn sync_shared_motion_states(&self) {
if let Some(ref shared) = self.shared_motion_states {
let mut states = shared.write().unwrap();
states.clear();
for (key, motion) in &self.stable_motions {
let state = match &motion.state {
MotionState::Suspended => MotionAnimationState::Suspended,
MotionState::Waiting { .. } => MotionAnimationState::Waiting,
MotionState::Entering { progress, .. } => MotionAnimationState::Entering {
progress: *progress,
},
MotionState::Visible => MotionAnimationState::Visible,
MotionState::Exiting { progress, .. } => MotionAnimationState::Exiting {
progress: *progress,
},
MotionState::Removed => MotionAnimationState::Removed,
};
states.insert(key.clone(), state);
}
}
}
pub fn animation_handle(&self) -> SchedulerHandle {
self.animations.lock().unwrap().handle()
}
pub fn tick(&mut self, current_time_ms: u64) -> bool {
let dt_ms = if let Some(last_time) = self.last_tick_time {
(current_time_ms.saturating_sub(last_time)) as f32
} else {
16.0 };
self.last_tick_time = Some(current_time_ms);
let animations_active = self.animations.lock().unwrap().tick();
if current_time_ms >= self.cursor_blink_time + self.cursor_blink_interval {
self.cursor_visible = !self.cursor_visible;
self.cursor_blink_time = current_time_ms;
}
let mut motion_active = false;
{
let scheduler = self.animations.lock().unwrap();
for state in self.node_states.values_mut() {
if let Some(spring_id) = state.opacity_spring {
if let Some(value) = scheduler.get_spring_value(spring_id) {
state.opacity = value.clamp(0.0, 1.0);
}
}
if let Some(springs) = state.bg_color_springs {
let r = scheduler.get_spring_value(springs[0]).unwrap_or(0.0);
let g = scheduler.get_spring_value(springs[1]).unwrap_or(0.0);
let b = scheduler.get_spring_value(springs[2]).unwrap_or(0.0);
let a = scheduler.get_spring_value(springs[3]).unwrap_or(1.0);
state.background_color = Some(Color::rgba(r, g, b, a));
}
if let Some(springs) = state.transform_springs {
let tx = scheduler.get_spring_value(springs[0]).unwrap_or(0.0);
let ty = scheduler.get_spring_value(springs[1]).unwrap_or(0.0);
let scale = scheduler.get_spring_value(springs[2]).unwrap_or(1.0);
let _rotate = scheduler.get_spring_value(springs[3]).unwrap_or(0.0);
state.transform = Some(Transform::translate(tx, ty));
state.scale = scale;
}
if let Some(ref mut motion) = state.motion {
if Self::tick_motion(motion, dt_ms) {
motion_active = true;
}
}
}
}
self.tick_stable_motions(dt_ms);
for overlay in &mut self.overlays {
if let Overlay::Cursor { opacity, .. } = overlay {
*opacity = if self.cursor_visible { 1.0 } else { 0.0 };
}
}
animations_active || motion_active || self.has_active_motions() || self.has_overlays()
}
fn tick_motion(motion: &mut ActiveMotion, dt_ms: f32) -> bool {
match &mut motion.state {
MotionState::Waiting { remaining_delay_ms } => {
*remaining_delay_ms -= dt_ms;
if *remaining_delay_ms <= 0.0 {
if motion.config.enter_from.is_some() && motion.config.enter_duration_ms > 0 {
tracing::debug!(
"Motion: Starting enter animation, duration={}ms",
motion.config.enter_duration_ms
);
motion.state = MotionState::Entering {
progress: 0.0,
duration_ms: motion.config.enter_duration_ms as f32,
};
motion.current = motion.config.enter_from.clone().unwrap_or_default();
} else {
motion.state = MotionState::Visible;
motion.current = MotionKeyframe::default(); }
}
true }
MotionState::Entering {
progress,
duration_ms,
} => {
*progress += dt_ms / *duration_ms;
if *progress >= 1.0 {
motion.state = MotionState::Visible;
motion.current = MotionKeyframe::default(); false } else {
let from = motion
.config
.enter_from
.as_ref()
.cloned()
.unwrap_or_default();
let to = MotionKeyframe::default();
let eased = ease_in_out_cubic(*progress);
motion.current = from.lerp(&to, eased);
true }
}
MotionState::Suspended => true, MotionState::Visible => false, MotionState::Exiting {
progress,
duration_ms,
} => {
*progress += dt_ms / *duration_ms;
if *progress >= 1.0 {
motion.state = MotionState::Removed;
motion.current = motion.config.exit_to.clone().unwrap_or_default();
false } else {
let from = MotionKeyframe::default();
let to = motion.config.exit_to.as_ref().cloned().unwrap_or_default();
let eased = ease_in_cubic(*progress);
motion.current = from.lerp(&to, eased);
true }
}
MotionState::Removed => false, }
}
pub fn reset_cursor_blink(&mut self, current_time_ms: u64) {
self.cursor_visible = true;
self.cursor_blink_time = current_time_ms;
}
pub fn set_cursor_blink_interval(&mut self, interval_ms: u64) {
self.cursor_blink_interval = interval_ms;
}
pub fn cursor_visible(&self) -> bool {
self.cursor_visible
}
pub fn get_or_create(&mut self, node_id: LayoutNodeId) -> &mut NodeRenderState {
self.node_states.entry(node_id).or_default()
}
pub fn get(&self, node_id: LayoutNodeId) -> Option<&NodeRenderState> {
self.node_states.get(&node_id)
}
pub fn get_mut(&mut self, node_id: LayoutNodeId) -> Option<&mut NodeRenderState> {
self.node_states.get_mut(&node_id)
}
pub fn remove(&mut self, node_id: LayoutNodeId) {
self.node_states.remove(&node_id);
}
pub fn clear_nodes(&mut self) {
self.node_states.clear();
}
pub fn animate_opacity(&mut self, node_id: LayoutNodeId, target: f32, config: SpringConfig) {
let (current, old_spring) = {
let state = self.node_states.entry(node_id).or_default();
(state.opacity, state.opacity_spring.take())
};
if let Some(old_id) = old_spring {
self.animations.lock().unwrap().remove_spring(old_id);
}
let mut spring = Spring::new(config, current);
spring.set_target(target);
let spring_id = self.animations.lock().unwrap().add_spring(spring);
if let Some(state) = self.node_states.get_mut(&node_id) {
state.opacity_spring = Some(spring_id);
}
}
pub fn animate_background(
&mut self,
node_id: LayoutNodeId,
target: Color,
config: SpringConfig,
) {
let (current, old_springs) = {
let state = self.node_states.entry(node_id).or_default();
let current = state.background_color.unwrap_or(Color::TRANSPARENT);
(current, state.bg_color_springs.take())
};
if let Some(old_ids) = old_springs {
let mut scheduler = self.animations.lock().unwrap();
for id in old_ids {
scheduler.remove_spring(id);
}
}
let springs = {
let mut scheduler = self.animations.lock().unwrap();
[
{
let mut s = Spring::new(config, current.r);
s.set_target(target.r);
scheduler.add_spring(s)
},
{
let mut s = Spring::new(config, current.g);
s.set_target(target.g);
scheduler.add_spring(s)
},
{
let mut s = Spring::new(config, current.b);
s.set_target(target.b);
scheduler.add_spring(s)
},
{
let mut s = Spring::new(config, current.a);
s.set_target(target.a);
scheduler.add_spring(s)
},
]
};
if let Some(state) = self.node_states.get_mut(&node_id) {
state.bg_color_springs = Some(springs);
}
}
pub fn set_background(&mut self, node_id: LayoutNodeId, color: Color) {
let old_springs = {
let state = self.node_states.entry(node_id).or_default();
state.bg_color_springs.take()
};
if let Some(old_ids) = old_springs {
let mut scheduler = self.animations.lock().unwrap();
for id in old_ids {
scheduler.remove_spring(id);
}
}
if let Some(state) = self.node_states.get_mut(&node_id) {
state.background_color = Some(color);
}
}
pub fn set_opacity(&mut self, node_id: LayoutNodeId, opacity: f32) {
let old_spring = {
let state = self.node_states.entry(node_id).or_default();
state.opacity_spring.take()
};
if let Some(old_id) = old_spring {
self.animations.lock().unwrap().remove_spring(old_id);
}
if let Some(state) = self.node_states.get_mut(&node_id) {
state.opacity = opacity;
}
}
pub fn add_cursor(&mut self, x: f32, y: f32, width: f32, height: f32, color: Color) {
self.overlays.push(Overlay::Cursor {
position: (x, y),
size: (width, height),
color,
opacity: if self.cursor_visible { 1.0 } else { 0.0 },
});
}
pub fn add_selection(&mut self, rects: Vec<(f32, f32, f32, f32)>, color: Color) {
self.overlays.push(Overlay::Selection { rects, color });
}
#[allow(clippy::too_many_arguments)]
pub fn add_focus_ring(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
radius: f32,
color: Color,
thickness: f32,
) {
self.overlays.push(Overlay::FocusRing {
position: (x, y),
size: (width, height),
radius,
color,
thickness,
});
}
pub fn clear_overlays(&mut self) {
self.overlays.clear();
}
pub fn overlays(&self) -> &[Overlay] {
&self.overlays
}
pub fn has_overlays(&self) -> bool {
!self.overlays.is_empty()
}
pub fn set_hovered(&mut self, node_id: LayoutNodeId, hovered: bool) {
self.get_or_create(node_id).hovered = hovered;
}
pub fn set_focused(&mut self, node_id: LayoutNodeId, focused: bool) {
self.get_or_create(node_id).focused = focused;
}
pub fn set_pressed(&mut self, node_id: LayoutNodeId, pressed: bool) {
self.get_or_create(node_id).pressed = pressed;
}
pub fn is_hovered(&self, node_id: LayoutNodeId) -> bool {
self.get(node_id).map(|s| s.hovered).unwrap_or(false)
}
pub fn is_focused(&self, node_id: LayoutNodeId) -> bool {
self.get(node_id).map(|s| s.focused).unwrap_or(false)
}
pub fn is_pressed(&self, node_id: LayoutNodeId) -> bool {
self.get(node_id).map(|s| s.pressed).unwrap_or(false)
}
pub fn start_enter_motion(&mut self, node_id: LayoutNodeId, config: MotionAnimation) {
let state = self.get_or_create(node_id);
let initial_state = if config.enter_delay_ms > 0 {
MotionState::Waiting {
remaining_delay_ms: config.enter_delay_ms as f32,
}
} else if config.enter_from.is_some() && config.enter_duration_ms > 0 {
MotionState::Entering {
progress: 0.0,
duration_ms: config.enter_duration_ms as f32,
}
} else {
MotionState::Visible
};
let current = if matches!(initial_state, MotionState::Visible) {
MotionKeyframe::default() } else {
config.enter_from.clone().unwrap_or_default()
};
state.motion = Some(ActiveMotion {
config,
state: initial_state,
current,
});
}
pub fn start_exit_motion(&mut self, node_id: LayoutNodeId) {
if let Some(state) = self.node_states.get_mut(&node_id) {
if let Some(ref mut motion) = state.motion {
if motion.config.exit_to.is_some() && motion.config.exit_duration_ms > 0 {
motion.state = MotionState::Exiting {
progress: 0.0,
duration_ms: motion.config.exit_duration_ms as f32,
};
motion.current = MotionKeyframe::default(); } else {
motion.state = MotionState::Removed;
}
}
}
}
pub fn get_motion_values(&self, node_id: LayoutNodeId) -> Option<&MotionKeyframe> {
self.get(node_id)
.and_then(|s| s.motion.as_ref())
.map(|m| &m.current)
}
pub fn is_motion_removed(&self, node_id: LayoutNodeId) -> bool {
self.get(node_id)
.and_then(|s| s.motion.as_ref())
.map(|m| matches!(m.state, MotionState::Removed))
.unwrap_or(false)
}
pub fn has_active_motions(&self) -> bool {
self.node_states.values().any(|s| s.has_active_motion())
|| self
.stable_motions
.values()
.any(|m| !matches!(m.state, MotionState::Visible | MotionState::Removed))
}
pub fn start_stable_motion(&mut self, key: &str, config: MotionAnimation, replay: bool) {
self.stable_motions_used.insert(key.to_string());
if let Some(existing) = self.stable_motions.get_mut(key) {
match existing.state {
MotionState::Suspended
| MotionState::Waiting { .. }
| MotionState::Entering { .. }
| MotionState::Visible => {
return;
}
MotionState::Exiting { .. } => {
tracing::debug!(
"Motion '{}': Exiting, continuing exit animation (use cancel_exit() to interrupt)",
key
);
return;
}
MotionState::Removed => {
tracing::debug!(
"Motion '{}': Removed state, NOT restarting (wait for cleanup)",
key
);
return;
}
}
}
tracing::debug!(
"Motion '{}': Creating new motion (enter_duration={}ms)",
key,
config.enter_duration_ms
);
let initial_state = if config.enter_delay_ms > 0 {
MotionState::Waiting {
remaining_delay_ms: config.enter_delay_ms as f32,
}
} else if config.enter_from.is_some() && config.enter_duration_ms > 0 {
MotionState::Entering {
progress: 0.0,
duration_ms: config.enter_duration_ms as f32,
}
} else {
MotionState::Visible
};
let current = if matches!(initial_state, MotionState::Visible) {
MotionKeyframe::default() } else {
config.enter_from.clone().unwrap_or_default()
};
self.stable_motions.insert(
key.to_string(),
ActiveMotion {
config,
state: initial_state,
current,
},
);
}
pub fn start_stable_motion_exit(&mut self, key: &str) {
if let Some(motion) = self.stable_motions.get_mut(key) {
if motion.config.exit_to.is_some() && motion.config.exit_duration_ms > 0 {
motion.state = MotionState::Exiting {
progress: 0.0,
duration_ms: motion.config.exit_duration_ms as f32,
};
motion.current = MotionKeyframe::default(); } else {
motion.state = MotionState::Removed;
}
}
}
pub fn cancel_stable_motion_exit(&mut self, key: &str) {
if let Some(motion) = self.stable_motions.get_mut(key) {
if matches!(motion.state, MotionState::Exiting { .. }) {
motion.state = MotionState::Visible;
motion.current = MotionKeyframe::default(); }
}
}
pub fn start_stable_motion_suspended(&mut self, key: &str, config: MotionAnimation) -> bool {
self.stable_motions_used.insert(key.to_string());
if let Some(existing) = self.stable_motions.get_mut(key) {
match existing.state {
MotionState::Suspended
| MotionState::Waiting { .. }
| MotionState::Entering { .. } => {
return false;
}
MotionState::Visible => {
return false;
}
MotionState::Exiting { .. } => {
return false;
}
MotionState::Removed => {
tracing::debug!(
"Motion '{}': Removed state, NOT restarting suspended (wait for cleanup)",
key
);
return false;
}
}
}
tracing::debug!(
"Motion '{}': Creating new SUSPENDED motion (will wait for start())",
key
);
let mut current = config.enter_from.clone().unwrap_or_default();
current.opacity = Some(0.0);
self.stable_motions.insert(
key.to_string(),
ActiveMotion {
config,
state: MotionState::Suspended,
current,
},
);
true
}
pub fn start_suspended_motion(&mut self, key: &str) {
if let Some(motion) = self.stable_motions.get_mut(key) {
if matches!(motion.state, MotionState::Suspended) {
let config = &motion.config;
tracing::debug!(
"Motion '{}': Starting from suspended (enter_duration={}ms)",
key,
config.enter_duration_ms
);
motion.state = if config.enter_delay_ms > 0 {
MotionState::Waiting {
remaining_delay_ms: config.enter_delay_ms as f32,
}
} else if config.enter_from.is_some() && config.enter_duration_ms > 0 {
MotionState::Entering {
progress: 0.0,
duration_ms: config.enter_duration_ms as f32,
}
} else {
MotionState::Visible
};
motion.current = if matches!(motion.state, MotionState::Visible) {
MotionKeyframe::default()
} else {
config.enter_from.clone().unwrap_or_default()
};
}
}
}
pub fn process_global_motion_starts(&mut self) {
let keys = take_global_motion_starts();
for key in keys {
self.start_suspended_motion(&key);
}
}
pub fn queue_motion_replay(&mut self, key: String) {
if !self.pending_motion_replays.contains(&key) {
self.pending_motion_replays.push(key);
}
}
pub fn process_pending_motion_replays(&mut self) {
let keys = std::mem::take(&mut self.pending_motion_replays);
for key in keys {
self.replay_stable_motion(&key);
}
}
pub fn process_global_motion_replays(&mut self) {
let keys = take_global_motion_replays();
for key in keys {
self.replay_stable_motion(&key);
}
}
pub fn process_global_motion_exit_cancels(&mut self) {
let keys = take_global_motion_exit_cancels();
for key in keys {
self.cancel_stable_motion_exit(&key);
}
}
pub fn process_global_motion_exit_starts(&mut self) {
let keys = take_global_motion_exit_starts();
for key in keys {
self.start_stable_motion_exit(&key);
}
}
pub fn replay_stable_motion(&mut self, key: &str) {
if let Some(motion) = self.stable_motions.get_mut(key) {
if matches!(motion.state, MotionState::Visible) {
let config = motion.config.clone();
motion.state = if config.enter_delay_ms > 0 {
MotionState::Waiting {
remaining_delay_ms: config.enter_delay_ms as f32,
}
} else if config.enter_from.is_some() && config.enter_duration_ms > 0 {
MotionState::Entering {
progress: 0.0,
duration_ms: config.enter_duration_ms as f32,
}
} else {
MotionState::Visible
};
motion.current = if matches!(motion.state, MotionState::Visible) {
MotionKeyframe::default()
} else {
config.enter_from.clone().unwrap_or_default()
};
}
}
}
pub fn get_stable_motion_values(&self, key: &str) -> Option<&MotionKeyframe> {
self.stable_motions.get(key).map(|m| &m.current)
}
pub fn get_stable_motion_state(
&self,
key: &str,
) -> blinc_core::context_state::MotionAnimationState {
use blinc_core::context_state::MotionAnimationState;
match self.stable_motions.get(key) {
Some(motion) => match &motion.state {
MotionState::Suspended => MotionAnimationState::Suspended,
MotionState::Waiting { .. } => MotionAnimationState::Waiting,
MotionState::Entering { progress, .. } => MotionAnimationState::Entering {
progress: *progress,
},
MotionState::Visible => MotionAnimationState::Visible,
MotionState::Exiting { progress, .. } => MotionAnimationState::Exiting {
progress: *progress,
},
MotionState::Removed => MotionAnimationState::Removed,
},
None => MotionAnimationState::NotFound,
}
}
pub fn is_stable_motion_removed(&self, key: &str) -> bool {
self.stable_motions
.get(key)
.map(|m| matches!(m.state, MotionState::Removed))
.unwrap_or(false)
}
pub fn reset_stable_motions_for_rebuild(&mut self) {
for motion in self.stable_motions.values_mut() {
if matches!(motion.state, MotionState::Visible) {
let config = &motion.config;
motion.state = if config.enter_delay_ms > 0 {
MotionState::Waiting {
remaining_delay_ms: config.enter_delay_ms as f32,
}
} else if config.enter_from.is_some() && config.enter_duration_ms > 0 {
MotionState::Entering {
progress: 0.0,
duration_ms: config.enter_duration_ms as f32,
}
} else {
MotionState::Visible
};
motion.current = if matches!(motion.state, MotionState::Visible) {
MotionKeyframe::default()
} else {
motion.config.enter_from.clone().unwrap_or_default()
};
}
}
}
pub fn clear_stable_motions(&mut self) {
self.stable_motions.clear();
self.stable_motions_used.clear();
}
pub fn remove_stable_motion(&mut self, key: &str) {
self.stable_motions.remove(key);
}
fn tick_stable_motions(&mut self, dt_ms: f32) {
for motion in self.stable_motions.values_mut() {
Self::tick_single_motion(motion, dt_ms);
}
}
fn tick_single_motion(motion: &mut ActiveMotion, dt_ms: f32) {
match &mut motion.state {
MotionState::Waiting { remaining_delay_ms } => {
*remaining_delay_ms -= dt_ms;
if *remaining_delay_ms <= 0.0 {
if motion.config.enter_from.is_some() && motion.config.enter_duration_ms > 0 {
motion.state = MotionState::Entering {
progress: 0.0,
duration_ms: motion.config.enter_duration_ms as f32,
};
} else {
motion.state = MotionState::Visible;
}
}
}
MotionState::Entering {
progress,
duration_ms,
} => {
*progress += dt_ms / *duration_ms;
if *progress >= 1.0 {
motion.state = MotionState::Visible;
motion.current = MotionKeyframe::default();
} else {
if let Some(ref from) = motion.config.enter_from {
motion.current = from.lerp(&MotionKeyframe::default(), *progress);
}
}
}
MotionState::Exiting {
progress,
duration_ms,
} => {
*progress += dt_ms / *duration_ms;
if *progress >= 1.0 {
motion.state = MotionState::Removed;
if let Some(ref to) = motion.config.exit_to {
motion.current = to.clone();
}
} else {
if let Some(ref to) = motion.config.exit_to {
motion.current = MotionKeyframe::default().lerp(to, *progress);
}
}
}
MotionState::Suspended | MotionState::Visible | MotionState::Removed => {
}
}
}
pub fn begin_stable_motion_frame(&mut self) {
self.stable_motions_used.clear();
}
pub fn end_stable_motion_frame(&mut self) {
let mut to_remove = Vec::new();
for (key, motion) in self.stable_motions.iter_mut() {
if !self.stable_motions_used.contains(key) {
match &motion.state {
MotionState::Removed => {
tracing::debug!("Motion '{}': Removed state, cleaning up", key);
to_remove.push(key.clone());
}
MotionState::Exiting { .. } => {
}
_ => {
if motion.config.exit_to.is_some() && motion.config.exit_duration_ms > 0 {
tracing::debug!(
"Motion '{}': Starting exit animation ({}ms)",
key,
motion.config.exit_duration_ms
);
motion.state = MotionState::Exiting {
progress: 0.0,
duration_ms: motion.config.exit_duration_ms as f32,
};
motion.current = MotionKeyframe::default(); } else {
tracing::debug!(
"Motion '{}': No exit config, removing immediately",
key
);
to_remove.push(key.clone());
}
}
}
}
}
for key in to_remove {
self.stable_motions.remove(&key);
}
}
pub fn set_viewport(&mut self, x: f32, y: f32, width: f32, height: f32) {
self.viewport = Rect::new(x, y, width, height);
self.viewport_set = true;
}
pub fn set_viewport_size(&mut self, width: f32, height: f32) {
self.set_viewport(0.0, 0.0, width, height);
}
pub fn viewport(&self) -> Rect {
self.viewport
}
pub fn viewport_with_buffer(&self) -> Rect {
Rect::new(
self.viewport.x() - VIEWPORT_BUFFER,
self.viewport.y() - VIEWPORT_BUFFER,
self.viewport.width() + 2.0 * VIEWPORT_BUFFER,
self.viewport.height() + 2.0 * VIEWPORT_BUFFER,
)
}
pub fn is_visible(&self, bounds: &Rect) -> bool {
if !self.viewport_set {
return true; }
self.viewport.intersects(bounds)
}
pub fn is_visible_with_buffer(&self, bounds: &Rect) -> bool {
if !self.viewport_set {
return true; }
self.viewport_with_buffer().intersects(bounds)
}
pub fn is_clipped(&self, bounds: &Rect) -> bool {
if !self.viewport_set {
return false; }
!self.viewport.intersects(bounds)
}
pub fn has_viewport(&self) -> bool {
self.viewport_set
}
}
fn ease_in_out_cubic(t: f32) -> f32 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
fn ease_in_cubic(t: f32) -> f32 {
t * t * t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_state_creation() {
let scheduler = Arc::new(Mutex::new(AnimationScheduler::new()));
let state = RenderState::new(scheduler);
assert!(state.cursor_visible());
assert!(!state.has_overlays());
}
#[test]
fn test_node_render_state() {
let scheduler = Arc::new(Mutex::new(AnimationScheduler::new()));
let mut state = RenderState::new(scheduler);
let node_id = LayoutNodeId::default();
state.set_hovered(node_id, true);
assert!(state.is_hovered(node_id));
state.set_opacity(node_id, 0.5);
assert_eq!(state.get(node_id).unwrap().opacity, 0.5);
}
#[test]
fn test_overlays() {
let scheduler = Arc::new(Mutex::new(AnimationScheduler::new()));
let mut state = RenderState::new(scheduler);
state.add_cursor(10.0, 20.0, 2.0, 16.0, Color::WHITE);
assert!(state.has_overlays());
assert_eq!(state.overlays().len(), 1);
state.clear_overlays();
assert!(!state.has_overlays());
}
#[test]
fn test_cursor_blink() {
let scheduler = Arc::new(Mutex::new(AnimationScheduler::new()));
let mut state = RenderState::new(scheduler);
state.set_cursor_blink_interval(100);
assert!(state.cursor_visible());
state.tick(150);
assert!(!state.cursor_visible());
state.tick(300);
assert!(state.cursor_visible());
}
}