Skip to main content

ui_storybook/components/recorder/
save_panel.rs

1//! `SavePanel` (M-SAVE.GATE) — the post-record Save panel that
2//! replaces the record/stop footer once a recording is parked
3//! awaiting export.
4//!
5//! Two visual states, both rendered inside the same
6//! `recorder-page-action-bar recorder-save-panel` footer so the panel
7//! drops into the recorder column with no layout shift:
8//!
9//! - **Choosing** — a folder row (configured output dir + a Change…
10//!   button), a format dropdown (`MP4` / `WebM`), and Discard / Edit /
11//!   Export actions (Edit saves the recording and opens it in the editor).
12//!   The `busy` flag dims the controls and flips the Export label to
13//!   "Exporting…" during the (software-`VP9`) `WebM` transcode.
14//! - **Saved** — a "Saved to `<path>`" confirmation with Done /
15//!   Reveal-in-Finder actions.
16//!
17//! Stateless: the parent (`app-ui`'s `RecorderPage`) owns the pending
18//! export, the chosen format, the export-in-flight signal, and the
19//! post-export saved path. It maps that state into a [`SavePanelView`]
20//! and wires the optional `Callback<()>` props to the Tauri IPC
21//! commands (`export_recording`, `discard_recording`,
22//! `reveal_in_file_manager`, the output-dir picker). Stories leave the
23//! callbacks unset.
24
25use leptos::prelude::*;
26
27/// Export container format offered by the Save panel.
28///
29/// The slugs match the recorder's IPC contract: `MP4`/H.264 is the
30/// scratch's native format (export = a move), `WebM`/`VP9` transcodes.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum SaveFormat {
33    /// H.264 `MP4` — the scratch already is this, so export is a move.
34    #[default]
35    Mp4H264,
36    /// `VP9`/`Opus` `WebM` — export transcodes (software `VP9`).
37    WebmVp9,
38}
39
40impl SaveFormat {
41    /// Stable slug used as the `<option value>` + the IPC format
42    /// argument (`export_recording(format, …)`).
43    #[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    /// Short human label shown in the dropdown.
52    #[must_use]
53    pub fn label(self) -> &'static str {
54        match self {
55            SaveFormat::Mp4H264 => "MP4",
56            SaveFormat::WebmVp9 => "WebM",
57        }
58    }
59
60    /// Parse a slug back into a `SaveFormat`. Returns `None` for any
61    /// unrecognised slug so the `on:change` handler can ignore it
62    /// rather than guess.
63    #[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    /// Every format the dropdown offers, in display order.
73    #[must_use]
74    pub fn all() -> [SaveFormat; 2] {
75        [SaveFormat::Mp4H264, SaveFormat::WebmVp9]
76    }
77}
78
79/// View-model for the Save panel — which of the two states to render
80/// plus the data each needs.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub enum SavePanelView {
83    /// Pre-export: pick the folder + format, then Export or Discard.
84    Choosing {
85        /// Configured output directory, shown tail-visible.
86        output_dir: String,
87        /// Currently-selected export format.
88        format: SaveFormat,
89        /// `true` while an export is in flight — disables the controls
90        /// and flips the Export label to "Exporting…".
91        busy: bool,
92    },
93    /// Post-export: the file landed at `path`; offer Reveal / Done.
94    Saved {
95        /// Absolute path of the exported file.
96        path: String,
97    },
98}
99
100/// The post-record Save panel.
101#[component]
102pub fn SavePanel(
103    /// View-model. The parent rebuilds this whenever the pending
104    /// export, chosen format, busy flag, or saved path changes.
105    view: SavePanelView,
106    /// Change-folder click (Choosing state). Wired to the native
107    /// folder picker.
108    #[prop(optional, into)]
109    on_change_folder: Option<Callback<()>>,
110    /// Format-dropdown change (Choosing state). Receives the newly
111    /// selected [`SaveFormat`].
112    #[prop(optional, into)]
113    on_format_change: Option<Callback<SaveFormat>>,
114    /// Discard click (Choosing state) — deletes the scratch.
115    #[prop(optional, into)]
116    on_discard: Option<Callback<()>>,
117    /// Edit click (Choosing state) — save the recording and open it in the
118    /// editor.
119    #[prop(optional, into)]
120    on_edit: Option<Callback<()>>,
121    /// Export click (Choosing state) — runs the move / transcode.
122    #[prop(optional, into)]
123    on_export: Option<Callback<()>>,
124    /// Reveal-in-Finder click (Saved state).
125    #[prop(optional, into)]
126    on_reveal: Option<Callback<()>>,
127    /// Done click (Saved state) — dismisses the panel.
128    #[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/// The Choosing-state callbacks, grouped so [`choosing_body`] stays under
158/// the argument-count lint. Every field is a `Copy` Leptos `Callback`.
159#[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
168/// The Choosing-state body: folder row + format dropdown + Discard / Edit /
169/// Export actions. Split out of [`SavePanel`] so neither branch trips the
170/// function-length lint; it carries no state (every prop is owned or a
171/// `Copy` callback).
172fn 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                    // `:target` (0.8) types `ev.target()` to the
233                    // `<select>` so `.value()` needs no cast.
234                    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
271/// The Saved-state body: the "Saved to `<path>`" confirmation plus
272/// Done / Reveal-in-Finder actions.
273fn 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        // The panel defaults to MP4 (the scratch's native format —
342        // export is a move, no transcode). Guards against a reorder of
343        // the enum flipping the default.
344        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}