rustmotion 0.8.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use gpui_component::ActiveTheme;
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
    div, relative, AnyElement, App, Context, Hsla, InteractiveElement, IntoElement, ParentElement,
    SharedString, StatefulInteractiveElement, Styled,
};

use crate::studio::app::state::Selection;
use crate::studio::scenario::ChangeKind;

use super::frames::HitPct;
use super::view::EditorView;

pub fn mark_for<'a>(
    pointer: Option<&str>,
    diff_marks: &'a [(String, ChangeKind)],
) -> Option<&'a ChangeKind> {
    let pointer = pointer?;
    diff_marks
        .iter()
        .find(|(p, _)| p == pointer)
        .map(|(_, kind)| kind)
}

fn box_style(
    is_selected: bool,
    is_hovered: bool,
    diff_mark: Option<&ChangeKind>,
    cx: &App,
) -> (Hsla, Hsla) {
    if let Some(mark) = diff_mark {
        let color = match mark {
            ChangeKind::Added => cx.theme().success,
            ChangeKind::Modified => cx.theme().accent,
            ChangeKind::Removed => cx.theme().danger,
        };
        return (color, gpui_kit::transparent_black());
    }
    if is_selected {
        return (cx.theme().accent, gpui_kit::transparent_black());
    }
    if is_hovered {
        return (cx.theme().accent, cx.theme().accent.opacity(0.12));
    }
    (gpui_kit::transparent_black(), gpui_kit::transparent_black())
}

impl EditorView {
    pub(super) fn render_hit_overlay(
        &mut self,
        hits: &[HitPct],
        selected: Option<&Selection>,
        diff_marks: &[(String, ChangeKind)],
        cx: &mut Context<Self>,
    ) -> AnyElement {
        let hovered = self.hovered_hit;
        let selected_node = selected.map(|s| s.node_id);
        let selected_pointer = selected.map(|s| s.pointer.as_str());

        div()
            .absolute()
            .inset_0()
            .children(hits.iter().map(|hit| {
                let node_id = hit.node_id;
                let pointer = hit.pointer.clone();
                let kind = hit.kind.clone();
                let is_selected = selected_node == Some(node_id)
                    || (hit.pointer.is_some() && hit.pointer.as_deref() == selected_pointer);
                let is_hovered = hovered == Some(node_id);
                let diff_mark = mark_for(hit.pointer.as_deref(), diff_marks);
                let (border_color, bg_color) = box_style(is_selected, is_hovered, diff_mark, cx);
                let has_border = is_selected || is_hovered || diff_mark.is_some();

                div()
                    .id(SharedString::from(format!("hit-{node_id}")))
                    .absolute()
                    .left(relative(hit.x / 100.0))
                    .top(relative(hit.y / 100.0))
                    .w(relative(hit.w / 100.0))
                    .h(relative(hit.h / 100.0))
                    .cursor_pointer()
                    .when(has_border, |el| el.border_1().border_color(border_color))
                    .bg(bg_color)
                    .on_hover(cx.listener(move |this, entered: &bool, _window, cx| {
                        if *entered {
                            if this.hovered_hit != Some(node_id) {
                                this.hovered_hit = Some(node_id);
                                cx.notify();
                            }
                        } else if this.hovered_hit == Some(node_id) {
                            this.hovered_hit = None;
                            cx.notify();
                        }
                    }))
                    .on_click(cx.listener(move |this, _event, _window, cx| {
                        cx.stop_propagation();
                        if let Some(ptr) = pointer.clone() {
                            this.editor.update(cx, |state, cx| {
                                state.selected = Some(Selection {
                                    node_id,
                                    pointer: ptr,
                                    kind: kind.clone(),
                                });
                                cx.notify();
                            });
                        }
                    }))
            }))
            .into_any_element()
    }
}

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

    #[test]
    fn mark_for_finds_the_matching_pointer() {
        let marks = vec![
            ("/scenes/0/children/0".to_string(), ChangeKind::Added),
            ("/scenes/0/children/1".to_string(), ChangeKind::Modified),
        ];
        assert_eq!(
            mark_for(Some("/scenes/0/children/1"), &marks),
            Some(&ChangeKind::Modified)
        );
        assert_eq!(mark_for(Some("/scenes/0/children/9"), &marks), None);
        assert_eq!(mark_for(None, &marks), None);
    }
}