app_ui/
framing_inspector.rs1use 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
19pub 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
27pub const CROP_FIELDS: [&str; 4] = ["X", "Y", "W", "H"];
29
30#[must_use]
32pub fn crop_to_array(c: CropRect) -> [f32; 4] {
33 [c.x, c.y, c.width, c.height]
34}
35
36#[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#[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#[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#[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); assert!((parse_crop_pct("-5")).abs() < 1e-6); assert!((parse_crop_pct("abc")).abs() < 1e-6); }
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}