Skip to main content

app_ui/
clip_inspector.rs

1//! Clip inspector — per-segment controls (ED.14: speed) (M-EDIT).
2//!
3//! The right-hand inspector for the *selected* clip. Today it carries the
4//! speed control: a row of multiplier presets that retime one segment via
5//! `EditOp::SetSpeed`. Because a segment's `timescale` changes how many
6//! project frames it occupies, setting speed reshapes the timeline — the
7//! filmstrip re-flows and the playback clock re-syncs automatically (the
8//! edit runs through the shared [`edit::History`], which pushes the new
9//! duration to the backend clock). The Style + Cursor tabs (ED.18 / ED.19)
10//! grow this same panel.
11
12use edit::EditProject;
13use leptos::prelude::*;
14
15/// Speed presets offered in the inspector (playback multipliers). `1.0` is
16/// real-time; `< 1` is slow-motion, `> 1` is fast-forward.
17pub const SPEED_PRESETS: [f64; 5] = [0.5, 1.0, 1.5, 2.0, 4.0];
18
19/// A compact label for a speed multiplier — `1.0 → "1×"`, `0.5 → "0.5×"`.
20/// (Rust's `f64` `Display` drops a whole number's trailing `.0`.)
21#[must_use]
22pub fn speed_label(timescale: f64) -> String {
23    format!("{timescale}×")
24}
25
26/// Whether `current` matches `preset` within float tolerance.
27#[must_use]
28pub fn is_active_speed(current: f64, preset: f64) -> bool {
29    (current - preset).abs() < 1e-6
30}
31
32/// The speed (`timescale`) of segment `index`, if it exists.
33#[must_use]
34pub fn selected_segment_speed(project: &EditProject, index: usize) -> Option<f64> {
35    project.segments.get(index).map(|s| s.timescale)
36}
37
38type ProjectSignal = Option<RwSignal<Option<EditProject>>>;
39type HistoryStore = Option<StoredValue<Option<edit::History>>>;
40
41/// The speed section for the selected clip `index` at its `current` speed.
42fn speed_panel(
43    index: usize,
44    current: f64,
45    project: ProjectSignal,
46    history: HistoryStore,
47) -> AnyView {
48    let presets = SPEED_PRESETS
49        .into_iter()
50        .map(|preset| {
51            let mut class = String::from("clip-speed-preset");
52            if is_active_speed(current, preset) {
53                class.push_str(" clip-speed-preset--active");
54            }
55            view! {
56                <button
57                    class=class
58                    on:click=move |_| {
59                        if let (Some(p), Some(h)) = (project, history) {
60                            crate::editor_edits::set_speed(p, h, index, preset);
61                        }
62                    }
63                >
64                    {speed_label(preset)}
65                </button>
66            }
67        })
68        .collect_view();
69    view! {
70        <div class="clip-inspector-section">
71            <h3 class="clip-inspector-title">"Speed"</h3>
72            <p class="clip-inspector-current">
73                {format!("Clip {} · {}", index + 1, speed_label(current))}
74            </p>
75            <div class="clip-speed-presets">{presets}</div>
76        </div>
77    }
78    .into_any()
79}
80
81/// The per-clip inspector. Reads the clip selection, project, and edit
82/// history from context; renders speed presets for the selected clip.
83#[component]
84pub fn ClipInspector() -> impl IntoView {
85    let project = use_context::<RwSignal<Option<EditProject>>>();
86    let history = use_context::<StoredValue<Option<edit::History>>>();
87    let selection = use_context::<RwSignal<Option<usize>>>().unwrap_or_else(|| RwSignal::new(None));
88    view! {
89        <div class="clip-inspector">
90            {move || match selection.get() {
91                None => view! {
92                    <p class="clip-inspector-empty">"Select a clip to edit its speed."</p>
93                }
94                .into_any(),
95                Some(index) => {
96                    let current = project
97                        .and_then(|s| s.get().as_ref().and_then(|p| selected_segment_speed(p, index)))
98                        .unwrap_or(1.0);
99                    speed_panel(index, current, project, history)
100                }
101            }}
102        </div>
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use edit::{ClipRef, EditProject, TimelineSegment};
110    use std::path::PathBuf;
111
112    fn project() -> EditProject {
113        EditProject::from_recording(ClipRef::new(
114            PathBuf::from("/tmp/rec.mp4"),
115            1920,
116            1080,
117            30,
118            900,
119        ))
120    }
121
122    #[test]
123    fn speed_labels_drop_trailing_zero() {
124        assert_eq!(speed_label(1.0), "1×");
125        assert_eq!(speed_label(2.0), "2×");
126        assert_eq!(speed_label(0.5), "0.5×");
127        assert_eq!(speed_label(1.5), "1.5×");
128    }
129
130    #[test]
131    fn active_speed_uses_tolerance() {
132        assert!(is_active_speed(1.0, 1.0));
133        assert!(is_active_speed(2.0 + 1e-9, 2.0));
134        assert!(!is_active_speed(1.0, 2.0));
135    }
136
137    #[test]
138    fn selected_speed_reads_timescale() {
139        let mut p = project();
140        p.segments = vec![
141            TimelineSegment::new(0, 300),
142            TimelineSegment::with_speed(300, 900, 2.0),
143        ];
144        assert!((selected_segment_speed(&p, 0).unwrap() - 1.0).abs() < 1e-9);
145        assert!((selected_segment_speed(&p, 1).unwrap() - 2.0).abs() < 1e-9);
146        assert_eq!(selected_segment_speed(&p, 2), None);
147    }
148}