blinc_layout 0.4.0

Blinc layout engine - Flexbox layout powered by Taffy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
//! Visual Animation System (FLIP-style)
//!
//! This module implements a FLIP-style animation system for layout changes:
//! - **F**irst: Record old visual bounds before layout change
//! - **L**ast: Let layout compute new (final) bounds
//! - **I**nvert: Calculate visual transform to show old position
//! - **P**lay: Animate transform from inverted to identity (zero offset)
//!
//! Key principle: **Taffy owns layout truth** - animations never modify the layout tree.
//! Instead, we track visual offsets that get animated back to zero.

use blinc_animation::{AnimatedValue, SchedulerHandle, SpringConfig};
use blinc_core::Rect;

use crate::element::ElementBounds;

// ============================================================================
// Animation Direction
// ============================================================================

/// Direction of the animation (affects clipping strategy)
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AnimationDirection {
    /// Growing from smaller to larger
    Expanding,
    /// Shrinking from larger to smaller
    Collapsing,
    /// Different directions for different properties
    Mixed,
}

// ============================================================================
// Animated Offset Components
// ============================================================================

/// Animated offset from layout position to visual position
/// Values animate from initial offset back to 0 (identity)
pub struct AnimatedOffset {
    /// Horizontal offset (dx from layout position)
    pub x: Option<AnimatedValue>,
    /// Vertical offset (dy from layout position)
    pub y: Option<AnimatedValue>,
}

impl AnimatedOffset {
    /// Create with no offset animation
    pub fn none() -> Self {
        Self { x: None, y: None }
    }

    /// Get current x offset (0 if not animating)
    pub fn get_x(&self) -> f32 {
        self.x.as_ref().map(|v| v.get()).unwrap_or(0.0)
    }

    /// Get current y offset (0 if not animating)
    pub fn get_y(&self) -> f32 {
        self.y.as_ref().map(|v| v.get()).unwrap_or(0.0)
    }

    /// Check if any offset is animating
    pub fn is_animating(&self) -> bool {
        self.x.as_ref().map(|v| v.is_animating()).unwrap_or(false)
            || self.y.as_ref().map(|v| v.is_animating()).unwrap_or(false)
    }
}

/// Animated size delta from layout size
/// Values animate from initial delta back to 0 (identity)
pub struct AnimatedSizeDelta {
    /// Width delta (dw from layout width)
    pub width: Option<AnimatedValue>,
    /// Height delta (dh from layout height)
    pub height: Option<AnimatedValue>,
}

impl AnimatedSizeDelta {
    /// Create with no size animation
    pub fn none() -> Self {
        Self {
            width: None,
            height: None,
        }
    }

    /// Get current width delta (0 if not animating)
    pub fn get_width(&self) -> f32 {
        self.width.as_ref().map(|v| v.get()).unwrap_or(0.0)
    }

    /// Get current height delta (0 if not animating)
    pub fn get_height(&self) -> f32 {
        self.height.as_ref().map(|v| v.get()).unwrap_or(0.0)
    }

    /// Check if any size is animating
    pub fn is_animating(&self) -> bool {
        self.width
            .as_ref()
            .map(|v| v.is_animating())
            .unwrap_or(false)
            || self
                .height
                .as_ref()
                .map(|v| v.is_animating())
                .unwrap_or(false)
    }
}

// ============================================================================
// Visual Animation State
// ============================================================================

/// Visual animation state - purely tracks visual offsets, never touches layout
///
/// This is the FLIP technique:
/// - from_bounds: The visual bounds we're animating FROM (snapshot at animation start)
/// - to_bounds: The layout bounds we're animating TO (updated each frame from taffy)
/// - offset/size_delta: Animated values that start at (from - to) and animate to 0
pub struct VisualAnimation {
    /// Stable key for tracking across rebuilds
    pub key: String,

    /// The layout bounds we're animating FROM (snapshot at animation start)
    pub from_bounds: ElementBounds,

    /// The layout bounds we're animating TO (updated each frame from taffy)
    pub to_bounds: ElementBounds,

    /// Animated offset values (visual-only, don't affect layout)
    /// These represent the DELTA from current layout to visual position
    pub offset: AnimatedOffset,

    /// Animated size delta (visual-only)
    pub size_delta: AnimatedSizeDelta,

    /// Whether this is expanding or collapsing (affects clipping strategy)
    pub direction: AnimationDirection,

    /// Spring configuration
    pub spring: SpringConfig,
}

impl VisualAnimation {
    /// Create a new visual animation from a bounds change
    ///
    /// The FLIP calculation:
    /// - offset = from_bounds - to_bounds (inverted position)
    /// - Animated values start at offset, target 0 (play back to layout)
    pub fn from_bounds_change(
        key: String,
        from_bounds: ElementBounds,
        to_bounds: ElementBounds,
        config: &VisualAnimationConfig,
        scheduler: SchedulerHandle,
    ) -> Option<Self> {
        // Calculate deltas (FLIP "Invert" step)
        let dx = from_bounds.x - to_bounds.x;
        let dy = from_bounds.y - to_bounds.y;
        let dw = from_bounds.width - to_bounds.width;
        let dh = from_bounds.height - to_bounds.height;

        // Check if any property has significant change
        let has_position_change =
            config.animate.position && (dx.abs() > config.threshold || dy.abs() > config.threshold);
        let has_size_change =
            config.animate.size && (dw.abs() > config.threshold || dh.abs() > config.threshold);

        if !has_position_change && !has_size_change {
            return None;
        }

        // Determine animation direction based on size change
        let direction = if dh > config.threshold || dw > config.threshold {
            AnimationDirection::Collapsing
        } else if dh < -config.threshold || dw < -config.threshold {
            AnimationDirection::Expanding
        } else {
            AnimationDirection::Mixed
        };

        // Create animated offsets - start at delta, animate to 0
        let offset = AnimatedOffset {
            x: if config.animate.position && dx.abs() > config.threshold {
                let mut anim = AnimatedValue::new(scheduler.clone(), dx, config.spring);
                anim.set_target(0.0);
                Some(anim)
            } else {
                None
            },
            y: if config.animate.position && dy.abs() > config.threshold {
                let mut anim = AnimatedValue::new(scheduler.clone(), dy, config.spring);
                anim.set_target(0.0);
                Some(anim)
            } else {
                None
            },
        };

        let size_delta = AnimatedSizeDelta {
            width: if config.animate.size && dw.abs() > config.threshold {
                let mut anim = AnimatedValue::new(scheduler.clone(), dw, config.spring);
                anim.set_target(0.0);
                Some(anim)
            } else {
                None
            },
            height: if config.animate.size && dh.abs() > config.threshold {
                let mut anim = AnimatedValue::new(scheduler.clone(), dh, config.spring);
                anim.set_target(0.0);
                Some(anim)
            } else {
                None
            },
        };

        Some(Self {
            key,
            from_bounds,
            to_bounds,
            offset,
            size_delta,
            direction,
            spring: config.spring,
        })
    }

    /// Update target bounds when layout changes mid-animation
    ///
    /// When layout changes while animating, we need to update what we're animating TO
    /// but keep animating smoothly from current visual position.
    pub fn update_target(&mut self, new_to_bounds: ElementBounds, scheduler: SchedulerHandle) {
        // Get current visual bounds (layout + current offset)
        let current_visual = self.current_visual_bounds();

        // Calculate new deltas from current visual to new layout
        let dx = current_visual.x - new_to_bounds.x;
        let dy = current_visual.y - new_to_bounds.y;
        let dw = current_visual.width - new_to_bounds.width;
        let dh = current_visual.height - new_to_bounds.height;

        // Update direction based on new change
        if dh > 1.0 || dw > 1.0 {
            self.direction = AnimationDirection::Collapsing;
        } else if dh < -1.0 || dw < -1.0 {
            self.direction = AnimationDirection::Expanding;
        }

        // Update to_bounds
        self.to_bounds = new_to_bounds;

        // Update or create animated values with new initial values, still targeting 0
        if let Some(ref mut anim) = self.offset.x {
            // Set current value to the new offset, keep targeting 0
            anim.set_immediate(dx);
            anim.set_target(0.0);
        }
        if let Some(ref mut anim) = self.offset.y {
            anim.set_immediate(dy);
            anim.set_target(0.0);
        }
        if let Some(ref mut anim) = self.size_delta.width {
            anim.set_immediate(dw);
            anim.set_target(0.0);
        }
        if let Some(ref mut anim) = self.size_delta.height {
            anim.set_immediate(dh);
            anim.set_target(0.0);
        }
    }

    /// Get current visual bounds (layout bounds + animated offset)
    pub fn current_visual_bounds(&self) -> ElementBounds {
        ElementBounds {
            x: self.to_bounds.x + self.offset.get_x(),
            y: self.to_bounds.y + self.offset.get_y(),
            width: self.to_bounds.width + self.size_delta.get_width(),
            height: self.to_bounds.height + self.size_delta.get_height(),
        }
    }

    /// Check if any animation is still running
    pub fn is_animating(&self) -> bool {
        self.offset.is_animating() || self.size_delta.is_animating()
    }

    /// Check if this is a collapsing animation
    pub fn is_collapsing(&self) -> bool {
        matches!(self.direction, AnimationDirection::Collapsing)
    }

    /// Check if this is an expanding animation
    pub fn is_expanding(&self) -> bool {
        matches!(self.direction, AnimationDirection::Expanding)
    }
}

// ============================================================================
// Animated Render Bounds (Pre-computed per frame)
// ============================================================================

/// Pre-computed render bounds for an element, accounting for:
/// - Own animation state
/// - Parent's animation state (inherited via parent_offset)
/// - Clip rect for content clipping
#[derive(Clone, Debug)]
pub struct AnimatedRenderBounds {
    /// Position in parent-relative coordinates (including animation offset)
    pub x: f32,
    pub y: f32,

    /// Visual size (may differ from layout during animation)
    pub width: f32,
    pub height: f32,

    /// Clip rect for content (in local coordinates)
    /// None = no clipping, Some = clip to rect
    pub clip_rect: Option<Rect>,
}

impl AnimatedRenderBounds {
    /// Create identity bounds (no offset, no clipping)
    pub fn identity() -> Self {
        Self {
            x: 0.0,
            y: 0.0,
            width: 0.0,
            height: 0.0,
            clip_rect: None,
        }
    }

    /// Create from layout bounds with no animation
    pub fn from_layout(bounds: ElementBounds) -> Self {
        Self {
            x: bounds.x,
            y: bounds.y,
            width: bounds.width,
            height: bounds.height,
            clip_rect: None,
        }
    }
}

// ============================================================================
// Configuration
// ============================================================================

/// Which properties to animate
#[derive(Clone, Debug, Default)]
pub struct AnimateProperties {
    /// Animate x, y position
    pub position: bool,
    /// Animate width, height
    pub size: bool,
}

/// Clipping behavior during animation
#[derive(Clone, Debug, Default)]
pub enum ClipBehavior {
    /// Clip content to animated bounds (default for collapse)
    #[default]
    ClipToAnimated,
    /// Clip content to layout bounds
    ClipToLayout,
    /// No additional clipping
    NoClip,
}

/// Configuration for visual animations on layout changes
#[derive(Clone, Debug)]
pub struct VisualAnimationConfig {
    /// Stable key for tracking across rebuilds
    pub key: Option<String>,

    /// Which properties to animate
    pub animate: AnimateProperties,

    /// Spring configuration
    pub spring: SpringConfig,

    /// Minimum change threshold (ignore tiny changes)
    pub threshold: f32,

    /// Clipping behavior during animation
    pub clip_behavior: ClipBehavior,
}

impl Default for VisualAnimationConfig {
    fn default() -> Self {
        Self::height()
    }
}

impl VisualAnimationConfig {
    /// Animate only height changes (most common for accordions)
    pub fn height() -> Self {
        Self {
            key: None,
            animate: AnimateProperties {
                position: false,
                size: true,
            },
            spring: SpringConfig::snappy(),
            threshold: 1.0,
            clip_behavior: ClipBehavior::ClipToAnimated,
        }
    }

    /// Animate only width changes (sidebars)
    pub fn width() -> Self {
        Self {
            key: None,
            animate: AnimateProperties {
                position: false,
                size: true,
            },
            spring: SpringConfig::snappy(),
            threshold: 1.0,
            clip_behavior: ClipBehavior::ClipToAnimated,
        }
    }

    /// Animate both width and height
    pub fn size() -> Self {
        Self {
            key: None,
            animate: AnimateProperties {
                position: false,
                size: true,
            },
            spring: SpringConfig::snappy(),
            threshold: 1.0,
            clip_behavior: ClipBehavior::ClipToAnimated,
        }
    }

    /// Animate position only (for reordering animations)
    pub fn position() -> Self {
        Self {
            key: None,
            animate: AnimateProperties {
                position: true,
                size: false,
            },
            spring: SpringConfig::snappy(),
            threshold: 1.0,
            clip_behavior: ClipBehavior::NoClip,
        }
    }

    /// Animate all bounds properties
    pub fn all() -> Self {
        Self {
            key: None,
            animate: AnimateProperties {
                position: true,
                size: true,
            },
            spring: SpringConfig::snappy(),
            threshold: 1.0,
            clip_behavior: ClipBehavior::ClipToAnimated,
        }
    }

    /// Set stable key for Stateful components
    pub fn with_key(mut self, key: impl Into<String>) -> Self {
        self.key = Some(key.into());
        self
    }

    /// Use spring configuration
    pub fn with_spring(mut self, config: SpringConfig) -> Self {
        self.spring = config;
        self
    }

    /// Set minimum change threshold
    pub fn with_threshold(mut self, threshold: f32) -> Self {
        self.threshold = threshold;
        self
    }

    /// Use a gentle spring (slow, smooth)
    pub fn gentle(self) -> Self {
        self.with_spring(SpringConfig::gentle())
    }

    /// Use a wobbly spring (with overshoot)
    pub fn wobbly(self) -> Self {
        self.with_spring(SpringConfig::wobbly())
    }

    /// Use a stiff spring (quick, snappy)
    pub fn stiff(self) -> Self {
        self.with_spring(SpringConfig::stiff())
    }

    /// Use a snappy spring (very responsive)
    pub fn snappy(self) -> Self {
        self.with_spring(SpringConfig::snappy())
    }

    /// Clip to animated bounds during animation
    pub fn clip_to_animated(mut self) -> Self {
        self.clip_behavior = ClipBehavior::ClipToAnimated;
        self
    }

    /// Clip to layout bounds during animation
    pub fn clip_to_layout(mut self) -> Self {
        self.clip_behavior = ClipBehavior::ClipToLayout;
        self
    }

    /// No additional clipping during animation
    pub fn no_clip(mut self) -> Self {
        self.clip_behavior = ClipBehavior::NoClip;
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_config_builders() {
        let config = VisualAnimationConfig::height();
        assert!(!config.animate.position);
        assert!(config.animate.size);

        let config = VisualAnimationConfig::all();
        assert!(config.animate.position);
        assert!(config.animate.size);

        let config = VisualAnimationConfig::position();
        assert!(config.animate.position);
        assert!(!config.animate.size);
    }

    #[test]
    fn test_animation_direction() {
        assert_eq!(
            AnimationDirection::Collapsing,
            AnimationDirection::Collapsing
        );
        assert_ne!(
            AnimationDirection::Expanding,
            AnimationDirection::Collapsing
        );
    }
}