Skip to main content

app_ui/
framing_inspector.rs

1//! Framing inspector — output aspect ratio + crop (ED.15 / M-EDIT).
2//!
3//! Project-level "look" controls in the inspector: the **aspect ratio**
4//! (the hard matte that decides the export canvas — 16:9, 9:16, 1:1, 4:3)
5//! and the **crop** (a normalized sub-rect of the source). Both are stored
6//! on the [`EditProject`] and re-derived at render time; the visible
7//! reframe of the preview + the export `videocrop` ride with the
8//! render-integration / export pass (ED.20 / ED.21). This chunk is the
9//! authoring side: presets + numeric crop entry, undoable through the
10//! shared [`edit::History`].
11
12use edit::EditProject;
13use edit::style::{AspectRatio, CropRect};
14use leptos::prelude::*;
15
16type ProjectSignal = Option<RwSignal<Option<EditProject>>>;
17type HistoryStore = Option<StoredValue<Option<edit::History>>>;
18
19/// Aspect-ratio presets, in display order.
20pub const ASPECTS: [(AspectRatio, &str); 4] = [
21    (AspectRatio::Wide, "16:9"),
22    (AspectRatio::Vertical, "9:16"),
23    (AspectRatio::Square, "1:1"),
24    (AspectRatio::Classic, "4:3"),
25];
26
27/// Crop field labels, in `[x, y, w, h]` order.
28pub const CROP_FIELDS: [&str; 4] = ["X", "Y", "W", "H"];
29
30/// A crop rect as a `[x, y, width, height]` array.
31#[must_use]
32pub fn crop_to_array(c: CropRect) -> [f32; 4] {
33    [c.x, c.y, c.width, c.height]
34}
35
36/// Rebuild a [`CropRect`] from a `[x, y, width, height]` array.
37#[must_use]
38pub fn crop_from_array(a: [f32; 4]) -> CropRect {
39    CropRect {
40        x: a[0],
41        y: a[1],
42        width: a[2],
43        height: a[3],
44    }
45}
46
47/// Parse a percent string (`"0".."100"`) to a unit fraction, clamped to
48/// `[0, 1]`. A non-numeric entry reads as `0`.
49#[must_use]
50pub fn parse_crop_pct(s: &str) -> f32 {
51    s.trim().parse::<f32>().unwrap_or(0.0).clamp(0.0, 100.0) / 100.0
52}
53
54/// Display a unit fraction as a whole-percent string (`0.8 → "80"`).
55#[must_use]
56pub fn crop_pct_label(v: f32) -> String {
57    format!("{}", (v * 100.0).round())
58}
59
60fn aspect_buttons(project: ProjectSignal, history: HistoryStore) -> AnyView {
61    let current = project
62        .and_then(|s| s.get().map(|p| p.aspect))
63        .unwrap_or_default();
64    ASPECTS
65        .into_iter()
66        .map(|(ratio, label)| {
67            let mut class = String::from("aspect-preset");
68            if ratio == current {
69                class.push_str(" aspect-preset--active");
70            }
71            view! {
72                <button
73                    class=class
74                    on:click=move |_| {
75                        if let (Some(p), Some(h)) = (project, history) {
76                            crate::editor_edits::set_aspect(p, h, ratio);
77                        }
78                    }
79                >
80                    {label}
81                </button>
82            }
83        })
84        .collect_view()
85        .into_any()
86}
87
88fn crop_inputs(project: ProjectSignal, history: HistoryStore) -> AnyView {
89    let cur = project
90        .and_then(|s| s.get().and_then(|p| p.crop))
91        .unwrap_or_else(CropRect::full);
92    let arr = crop_to_array(cur);
93    CROP_FIELDS
94        .into_iter()
95        .enumerate()
96        .map(|(i, label)| {
97            let value = crop_pct_label(arr[i]);
98            view! {
99                <label class="crop-field">
100                    <span class="crop-field-label">{label}</span>
101                    <input
102                        class="crop-field-input"
103                        type="number"
104                        min="0"
105                        max="100"
106                        prop:value=value
107                        on:change=move |ev| {
108                            if let (Some(p), Some(h)) = (project, history) {
109                                let mut a = p
110                                    .get_untracked()
111                                    .and_then(|pr| pr.crop)
112                                    .map_or_else(|| crop_to_array(CropRect::full()), crop_to_array);
113                                a[i] = parse_crop_pct(&event_target_value(&ev));
114                                crate::editor_edits::set_crop(p, h, crop_from_array(a));
115                            }
116                        }
117                    />
118                </label>
119            }
120        })
121        .collect_view()
122        .into_any()
123}
124
125/// The framing inspector: aspect-ratio presets + numeric crop entry.
126#[component]
127pub fn FramingInspector() -> impl IntoView {
128    let project = use_context::<RwSignal<Option<EditProject>>>();
129    let history = use_context::<StoredValue<Option<edit::History>>>();
130    view! {
131        <div class="framing-inspector">
132            <div class="clip-inspector-section">
133                <h3 class="clip-inspector-title">"Aspect ratio"</h3>
134                <div class="aspect-presets">{move || aspect_buttons(project, history)}</div>
135            </div>
136            <div class="clip-inspector-section">
137                <h3 class="clip-inspector-title">"Crop (%)"</h3>
138                <div class="crop-fields">{move || crop_inputs(project, history)}</div>
139                <button
140                    class="crop-reset"
141                    on:click=move |_| {
142                        if let (Some(p), Some(h)) = (project, history) {
143                            crate::editor_edits::set_crop(p, h, CropRect::full());
144                        }
145                    }
146                >
147                    "Reset crop"
148                </button>
149            </div>
150        </div>
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn percent_round_trips_within_tolerance() {
160        assert!((parse_crop_pct("80") - 0.8).abs() < 1e-6);
161        assert_eq!(crop_pct_label(0.8), "80");
162        assert_eq!(crop_pct_label(0.0), "0");
163        assert_eq!(crop_pct_label(1.0), "100");
164    }
165
166    #[test]
167    fn percent_parse_clamps_and_defaults() {
168        assert!((parse_crop_pct("150") - 1.0).abs() < 1e-6); // clamp high
169        assert!((parse_crop_pct("-5")).abs() < 1e-6); // clamp low
170        assert!((parse_crop_pct("abc")).abs() < 1e-6); // non-numeric → 0
171    }
172
173    #[test]
174    fn crop_array_round_trips() {
175        let c = CropRect {
176            x: 0.1,
177            y: 0.2,
178            width: 0.7,
179            height: 0.6,
180        };
181        assert_eq!(crop_from_array(crop_to_array(c)), c);
182    }
183
184    #[test]
185    fn aspect_presets_cover_all_ratios() {
186        assert_eq!(ASPECTS.len(), 4);
187        assert!(ASPECTS.iter().any(|(r, _)| *r == AspectRatio::Wide));
188        assert!(ASPECTS.iter().any(|(r, _)| *r == AspectRatio::Vertical));
189    }
190}