ui_storybook/components/recorder/
save_panel.rs1use leptos::prelude::*;
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum SaveFormat {
33 #[default]
35 Mp4H264,
36 WebmVp9,
38}
39
40impl SaveFormat {
41 #[must_use]
44 pub fn slug(self) -> &'static str {
45 match self {
46 SaveFormat::Mp4H264 => "mp4-h264",
47 SaveFormat::WebmVp9 => "webm-vp9",
48 }
49 }
50
51 #[must_use]
53 pub fn label(self) -> &'static str {
54 match self {
55 SaveFormat::Mp4H264 => "MP4",
56 SaveFormat::WebmVp9 => "WebM",
57 }
58 }
59
60 #[must_use]
64 pub fn from_slug(slug: &str) -> Option<Self> {
65 match slug {
66 "mp4-h264" => Some(SaveFormat::Mp4H264),
67 "webm-vp9" => Some(SaveFormat::WebmVp9),
68 _ => None,
69 }
70 }
71
72 #[must_use]
74 pub fn all() -> [SaveFormat; 2] {
75 [SaveFormat::Mp4H264, SaveFormat::WebmVp9]
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum SavePanelView {
83 Choosing {
85 output_dir: String,
87 format: SaveFormat,
89 busy: bool,
92 },
93 Saved {
95 path: String,
97 },
98}
99
100#[component]
102pub fn SavePanel(
103 view: SavePanelView,
106 #[prop(optional, into)]
109 on_change_folder: Option<Callback<()>>,
110 #[prop(optional, into)]
113 on_format_change: Option<Callback<SaveFormat>>,
114 #[prop(optional, into)]
116 on_discard: Option<Callback<()>>,
117 #[prop(optional, into)]
120 on_edit: Option<Callback<()>>,
121 #[prop(optional, into)]
123 on_export: Option<Callback<()>>,
124 #[prop(optional, into)]
126 on_reveal: Option<Callback<()>>,
127 #[prop(optional, into)]
129 on_done: Option<Callback<()>>,
130) -> impl IntoView {
131 let body: AnyView = match view {
132 SavePanelView::Choosing {
133 output_dir,
134 format,
135 busy,
136 } => choosing_body(
137 output_dir,
138 format,
139 busy,
140 ChoosingCallbacks {
141 change_folder: on_change_folder,
142 format_change: on_format_change,
143 discard: on_discard,
144 edit: on_edit,
145 export: on_export,
146 },
147 ),
148 SavePanelView::Saved { path } => saved_body(path, on_reveal, on_done),
149 };
150 view! {
151 <footer class="recorder-page-action-bar recorder-save-panel" aria-label="Save recording">
152 {body}
153 </footer>
154 }
155}
156
157#[derive(Clone, Copy)]
160struct ChoosingCallbacks {
161 change_folder: Option<Callback<()>>,
162 format_change: Option<Callback<SaveFormat>>,
163 discard: Option<Callback<()>>,
164 edit: Option<Callback<()>>,
165 export: Option<Callback<()>>,
166}
167
168fn choosing_body(
173 output_dir: String,
174 format: SaveFormat,
175 busy: bool,
176 cbs: ChoosingCallbacks,
177) -> AnyView {
178 let ChoosingCallbacks {
179 change_folder: on_change_folder,
180 format_change: on_format_change,
181 discard: on_discard,
182 edit: on_edit,
183 export: on_export,
184 } = cbs;
185 let dir_title = output_dir.clone();
186 let change_click = move |_| {
187 if let Some(cb) = on_change_folder {
188 cb.run(());
189 }
190 };
191 let discard_click = move |_| {
192 if let Some(cb) = on_discard {
193 cb.run(());
194 }
195 };
196 let edit_click = move |_| {
197 if let Some(cb) = on_edit {
198 cb.run(());
199 }
200 };
201 let export_click = move |_| {
202 if let Some(cb) = on_export {
203 cb.run(());
204 }
205 };
206 let options = SaveFormat::all()
207 .into_iter()
208 .map(|f| {
209 view! {
210 <option value=f.slug() selected=f == format>{f.label()}</option>
211 }
212 })
213 .collect_view();
214 let export_label = if busy { "Exporting…" } else { "Export" };
215 view! {
216 <div class="recorder-save-fields">
217 <div class="recorder-save-row">
218 <span class="recorder-save-key">"Folder"</span>
219 <span class="recorder-save-folder" title=dir_title>{output_dir}</span>
220 <button
221 type="button"
222 class="recorder-save-change"
223 on:click=change_click
224 disabled=busy
225 >"Change…"</button>
226 </div>
227 <div class="recorder-save-row">
228 <span class="recorder-save-key">"Format"</span>
229 <select
230 class="recorder-save-format"
231 aria-label="Export format"
232 on:change:target=move |ev| {
235 if let Some(cb) = on_format_change
236 && let Some(fmt) = SaveFormat::from_slug(&ev.target().value())
237 {
238 cb.run(fmt);
239 }
240 }
241 disabled=busy
242 >
243 {options}
244 </select>
245 </div>
246 </div>
247 <div class="recorder-save-actions">
248 <button
249 type="button"
250 class="recorder-save-discard"
251 on:click=discard_click
252 disabled=busy
253 >"Discard"</button>
254 <button
255 type="button"
256 class="recorder-save-edit"
257 on:click=edit_click
258 disabled=busy
259 >"Edit"</button>
260 <button
261 type="button"
262 class="recorder-save-export"
263 on:click=export_click
264 disabled=busy
265 >{export_label}</button>
266 </div>
267 }
268 .into_any()
269}
270
271fn saved_body(
274 path: String,
275 on_reveal: Option<Callback<()>>,
276 on_done: Option<Callback<()>>,
277) -> AnyView {
278 let path_title = path.clone();
279 let done_click = move |_| {
280 if let Some(cb) = on_done {
281 cb.run(());
282 }
283 };
284 let reveal_click = move |_| {
285 if let Some(cb) = on_reveal {
286 cb.run(());
287 }
288 };
289 view! {
290 <div class="recorder-save-saved" role="status" aria-live="polite">
291 <span class="recorder-save-key">"Saved to"</span>
292 <span class="recorder-save-folder" title=path_title>{path}</span>
293 </div>
294 <div class="recorder-save-actions">
295 <button
296 type="button"
297 class="recorder-save-discard"
298 on:click=done_click
299 >"Done"</button>
300 <button
301 type="button"
302 class="recorder-save-export"
303 on:click=reveal_click
304 >"Reveal in Finder"</button>
305 </div>
306 }
307 .into_any()
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn slugs_are_unique_and_kebab() {
316 let slugs: Vec<&str> = SaveFormat::all().iter().map(|f| f.slug()).collect();
317 let mut sorted = slugs.clone();
318 sorted.sort_unstable();
319 sorted.dedup();
320 assert_eq!(sorted.len(), slugs.len(), "format slugs must be unique");
321 for s in slugs {
322 assert!(
323 s.chars()
324 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
325 "slug `{s}` is not kebab-case",
326 );
327 }
328 }
329
330 #[test]
331 fn from_slug_round_trips_every_variant() {
332 for f in SaveFormat::all() {
333 assert_eq!(SaveFormat::from_slug(f.slug()), Some(f));
334 }
335 assert_eq!(SaveFormat::from_slug("av1"), None);
336 assert_eq!(SaveFormat::from_slug(""), None);
337 }
338
339 #[test]
340 fn default_format_is_mp4() {
341 assert_eq!(SaveFormat::default(), SaveFormat::Mp4H264);
345 assert_eq!(SaveFormat::default().slug(), "mp4-h264");
346 }
347
348 #[test]
349 fn labels_are_non_empty_and_distinct() {
350 let labels: Vec<&str> = SaveFormat::all().iter().map(|f| f.label()).collect();
351 assert!(labels.iter().all(|l| !l.is_empty()));
352 assert_ne!(labels[0], labels[1]);
353 }
354}