tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
#[cfg(feature = "image")]
use std::collections::hash_map::DefaultHasher;
#[cfg(feature = "image")]
use std::hash::{Hash, Hasher};
use std::time::Duration;
#[cfg(any(feature = "image", feature = "profiling-tracing"))]
use web_time::Instant;

#[cfg(feature = "image")]
use crate::backend::ratatui_backend::image_support;
use crate::core::component::Component;
use crate::core::node::NodeKind;
#[cfg(feature = "image")]
use crate::style::Rect;
use crate::widgets::{ScrollEvent, ScrollMetrics, ScrollWheelBehavior, calc_scroll_view_window};

use super::{AppRunner, spinner_frame_for_speed};

impl<C: Component> AppRunner<C> {
    pub(crate) fn update_spinner_frames(&mut self) {
        let spinner_ids = self.core.tree.spinner_ids().to_vec();

        for id in spinner_ids {
            match &mut self.core.tree.node_mut(id).kind {
                NodeKind::Spinner(node) if node.auto_frame => {
                    node.frame = spinner_frame_for_speed(self.animation.spinner_frame, node.speed);
                }
                NodeKind::DraggableTabBar(node) => {
                    for tab in std::sync::Arc::make_mut(&mut node.tabs) {
                        if let Some(spinner) = tab
                            .leading
                            .as_mut()
                            .and_then(|content| content.spinner_mut())
                            && spinner.auto_frame
                        {
                            spinner.spinner.frame = Some(spinner_frame_for_speed(
                                self.animation.spinner_frame,
                                spinner.spinner.speed,
                            ));
                        }
                    }
                }
                NodeKind::List(node) => {
                    for item in std::sync::Arc::make_mut(&mut node.items) {
                        if let Some(spinner) = item.status.as_mut().and_then(|s| s.spinner_mut())
                            && spinner.auto_frame
                        {
                            spinner.frame = spinner_frame_for_speed(
                                self.animation.spinner_frame,
                                spinner.speed,
                            );
                        }
                        if let Some(spinner) = item.gutter.as_mut().and_then(|g| g.spinner_mut())
                            && spinner.auto_frame
                        {
                            spinner.frame = spinner_frame_for_speed(
                                self.animation.spinner_frame,
                                spinner.speed,
                            );
                        }
                    }
                }
                _ => {}
            }
        }
    }

    pub(crate) fn update_animated_widgets(&mut self, dt: Duration) -> (bool, bool, bool) {
        let animated_ids = self.core.tree.animated_widget_ids().to_vec();

        let mut changed = false;
        let mut needs_paint = false;
        let mut needs_layout = false;

        for id in animated_ids {
            if let NodeKind::Animated(node) = &mut self.core.tree.node_mut(id).kind {
                let result = node.tick(dt);
                changed |= result.changed;
                needs_paint |= result.paint_dirty;
                needs_layout |= result.layout_dirty;
            }
        }

        self.core.tree.refresh_animated_widget_activity();

        (changed, needs_paint, needs_layout)
    }

    pub(crate) fn update_smooth_scrolls(&mut self, dt: Duration) -> (bool, bool, bool) {
        let animated_ids = self.core.tree.animated_scroll_ids().to_vec();

        let mut changed = false;
        let mut needs_paint = false;
        let mut needs_layout = false;

        for id in animated_ids {
            if !self.core.tree.is_valid(id) {
                continue;
            }

            let rect = self.core.tree.node(id).rect;
            let key = self.core.tree.node(id).key.clone();
            let mut scroll_view_wheel_event = None;
            match &mut self.core.tree.node_mut(id).kind {
                NodeKind::ScrollView(node) => {
                    let result = node.smooth_scroll.tick(dt, node.max_offset);
                    let _still_animating = result.still_animating;
                    if result.changed {
                        let next = node.smooth_scroll.current_offset(node.max_offset);
                        node.offset = next;
                        node.scroll_offset = next.min(u16::MAX as usize) as u16;
                        node.scroll_override = Some(next);
                        changed = true;
                        needs_layout = true;
                    }

                    if !node.scroll_wheel
                        || matches!(node.scroll_wheel_behavior, ScrollWheelBehavior::Immediate)
                    {
                        if node.wheel_scroll.is_animating() {
                            node.wheel_scroll.cancel_at(node.offset);
                        }
                    } else if let ScrollWheelBehavior::Smooth(config) = node.scroll_wheel_behavior {
                        let result = node.wheel_scroll.tick(dt, node.max_offset, config);
                        let _still_animating = result.still_animating;
                        if result.changed {
                            let next = node.wheel_scroll.current_offset(node.max_offset);
                            node.offset = next;
                            node.scroll_offset = next.min(u16::MAX as usize) as u16;
                            node.scroll_override = Some(next);
                            node.scroll_handler_dirty = true;
                            let window = calc_scroll_view_window(
                                next,
                                node.content_height as usize,
                                node.viewport_height as usize,
                                node.show_scroll_indicators,
                            );
                            scroll_view_wheel_event = Some((
                                next,
                                ScrollMetrics {
                                    len: node.content_height as usize,
                                    visible: window.visible_rows,
                                    max_offset: window.max_offset,
                                },
                                node.on_scroll_to.clone(),
                                node.on_scroll.clone(),
                            ));
                            changed = true;
                            needs_layout = true;
                        }
                    }
                }
                NodeKind::DocumentView(node) => {
                    let inner = rect.inner(node.border, node.padding);
                    let visible = node.content_layout(inner).content_height as usize;
                    let max_offset = node.total_visual_lines.saturating_sub(visible);
                    let result = node.smooth_scroll.tick(dt, max_offset);
                    let _still_animating = result.still_animating;
                    if result.changed {
                        let next = node.smooth_scroll.current_offset(max_offset);
                        node.scroll_offset = next;
                        node.scroll_override = Some(next);
                        changed = true;
                        needs_paint = true;
                    }
                }
                NodeKind::TextArea(node) => {
                    let h_scrollbar_over_border = node.h_scrollbar
                        && matches!(
                            node.h_scrollbar_variant,
                            crate::style::ScrollbarVariant::Integrated
                        )
                        && node.border;
                    let visible =
                        node.geometry.content_viewport_h(h_scrollbar_over_border) as usize;
                    let max_offset = node.visual_lines_count.saturating_sub(visible);
                    let result = node.smooth_scroll.tick(dt, max_offset);
                    let _still_animating = result.still_animating;
                    if result.changed {
                        let next = node.smooth_scroll.current_offset(max_offset);
                        node.scroll_offset = next;
                        node.scroll_override = Some(next);
                        changed = true;
                        needs_paint = true;
                    }
                }
                _ => {}
            }

            if let Some((next, metrics, on_scroll_to, on_scroll)) = scroll_view_wheel_event {
                if let Some(key) = key {
                    self.core.tree.scroll_input_offset_by_key.insert(key, next);
                }
                if let Some(cb) = on_scroll_to.as_ref() {
                    cb.emit(next);
                } else if let Some(cb) = on_scroll.as_ref() {
                    cb.emit(ScrollEvent {
                        offset: next,
                        metrics,
                    });
                }
            }
        }

        self.core.tree.refresh_animated_scroll_activity();

        (changed, needs_paint, needs_layout)
    }

    #[cfg(feature = "image")]
    pub(crate) fn update_image_frames(&mut self, delta_ms: u32) -> bool {
        if self.surface.is_inline() {
            return false;
        }

        let Some(viewport) = self.image_animation_viewport() else {
            return false;
        };

        let image_ids = self.core.tree.animated_image_ids().to_vec();

        let mut any_advanced = false;
        for id in image_ids {
            if self
                .core
                .tree
                .node(id)
                .rect
                .intersection(&viewport)
                .is_empty()
            {
                continue;
            }
            if let NodeKind::Image(node) = &mut self.core.tree.node_mut(id).kind
                && node.tick_animation(delta_ms)
            {
                any_advanced = true;
            }
        }

        any_advanced
    }

    #[cfg(feature = "image")]
    pub(crate) fn next_image_frame_due_in_ms(&self) -> Option<u32> {
        if self.surface.is_inline() {
            return None;
        }

        let viewport = self.image_animation_viewport()?;

        self.core
            .tree
            .animated_image_ids()
            .iter()
            .filter_map(|&id| {
                let node = self.core.tree.node(id);
                if node.rect.intersection(&viewport).is_empty() {
                    return None;
                }
                match &node.kind {
                    NodeKind::Image(image) => image.next_frame_due_in_ms(),
                    _ => None,
                }
            })
            .min()
    }

    #[cfg(feature = "image")]
    pub(crate) fn image_animations_suspended(&self) -> bool {
        self.animation
            .image_animation_suspend_until
            .is_some_and(|deadline| Instant::now() < deadline)
    }

    #[cfg(feature = "image")]
    pub(crate) fn suspend_image_animations_for(&mut self, duration: Duration) {
        let now = Instant::now();
        let requested_deadline = now + duration;
        self.animation.image_animation_suspend_until = Some(
            self.animation
                .image_animation_suspend_until
                .map(|current| current.max(requested_deadline))
                .unwrap_or(requested_deadline),
        );
        self.animation.last_image_tick = now;
    }

    #[cfg(feature = "image")]
    pub(crate) fn refresh_image_layout_suspension(&mut self) {
        if self.surface.is_inline() {
            self.animation.last_image_layout_hash = None;
            self.animation.image_animation_suspend_until = None;
            self.animation.last_image_tick = Instant::now();
            return;
        }

        let new_hash = self.animated_image_layout_hash();
        if self.animation.last_image_layout_hash != new_hash {
            self.animation.last_image_layout_hash = new_hash;
            if new_hash.is_some() {
                let pause = Duration::from_millis(super::image_layout_stabilize_ms() as u64);
                self.suspend_image_animations_for(pause);
                image_support::suspend_image_rendering_for(pause);
            } else {
                self.animation.image_animation_suspend_until = None;
                self.animation.last_image_tick = Instant::now();
            }
        }
    }

    #[cfg(feature = "image")]
    fn animated_image_layout_hash(&self) -> Option<u64> {
        let mut hasher = DefaultHasher::new();
        let mut found = false;

        for &id in self.core.tree.animated_image_ids() {
            let node = self.core.tree.node(id);
            let NodeKind::Image(image) = &node.kind else {
                continue;
            };
            found = true;
            image.source_hash.hash(&mut hasher);
            node.rect.hash(&mut hasher);
        }

        found.then_some(hasher.finish())
    }

    #[cfg(feature = "image")]
    fn image_animation_viewport(&self) -> Option<Rect> {
        if !self.core.tree.is_valid(self.core.tree.root) {
            return None;
        }
        Some(self.core.tree.node(self.core.tree.root).rect)
    }
}