Skip to main content

app_ui/
editor_ipc.rs

1//! Leptos-side bindings for the editor IPC (ED.5 / M-EDIT).
2//!
3//! `app-ui` is a WASM crate and can't depend on `screen-app` (Tauri-
4//! native), but it *can* depend on the pure `edit` crate — so the
5//! `open_in_editor` command's payload deserializes straight into
6//! [`edit::EditProject`] with no hand-mirrored type.
7//!
8//! The flow mirrors the player IPC: a fire-and-forget invoke wrapper
9//! ([`screen_open_in_editor`], bound to the `__screenOpenInEditor` helper
10//! in `index.html`) plus an `editor-project` `CustomEvent` listener that
11//! pushes the loaded project into a Leptos signal.
12
13use edit::EditProject;
14use leptos::prelude::{RwSignal, Set};
15use serde::{Deserialize, Serialize};
16use wasm_bindgen::JsCast;
17use wasm_bindgen::prelude::*;
18use web_sys::CustomEvent;
19
20#[wasm_bindgen]
21extern "C" {
22    /// Ask the shell to open `path` in the editor. Fire-and-forget: the
23    /// loaded project arrives asynchronously as an `editor-project`
24    /// browser `CustomEvent` (see [`install_editor_project_listener`]).
25    /// `catch` so it degrades to a no-op outside Tauri.
26    #[wasm_bindgen(js_namespace = window, js_name = "__screenOpenInEditor", catch)]
27    pub fn screen_open_in_editor(path: &str) -> Result<JsValue, JsValue>;
28}
29
30/// Install an `editor-project` `CustomEvent` listener that deserializes
31/// the loaded [`EditProject`] and pushes it into `project`.
32///
33/// The closure has app-lifetime, so it is leaked via `Closure::forget`.
34pub fn install_editor_project_listener(project: RwSignal<Option<EditProject>>) {
35    let Some(window) = web_sys::window() else {
36        return;
37    };
38    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
39        if let Ok(custom) = event.dyn_into::<CustomEvent>()
40            && let Ok(loaded) = serde_wasm_bindgen::from_value::<EditProject>(custom.detail())
41        {
42            project.set(Some(loaded));
43        }
44    }) as Box<dyn FnMut(_)>);
45    let _ =
46        window.add_event_listener_with_callback("editor-project", closure.as_ref().unchecked_ref());
47    closure.forget();
48}
49
50/// IPC-stable mirror of `screen_app::editor_session::EditorStatusView`
51/// (plain-text reference — `app-ui` can't depend on the Tauri-native
52/// crate). Field names must match the Rust-side `Serialize` shape.
53#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)]
54pub struct EditorStatus {
55    /// Current playhead frame.
56    pub current_frame: u64,
57    /// Total project length in frames.
58    pub duration_frames: u64,
59    /// Whether the clock is advancing.
60    pub playing: bool,
61    /// Project frame rate.
62    #[serde(default)]
63    pub fps: u32,
64    /// Playback rate multiplier.
65    #[serde(default)]
66    pub rate: f32,
67    /// In-point (inclusive).
68    pub in_frame: u64,
69    /// Out-point (exclusive).
70    pub out_frame: u64,
71    /// Whether looping is enabled.
72    pub looping: bool,
73}
74
75/// Mirror of the backend `TransportAction` (serialize side). The JS bridge
76/// forwards this object to the `editor_transport` command, which
77/// re-deserializes it on the Rust side.
78#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum TransportAction {
81    /// Start advancing.
82    Play,
83    /// Stop advancing.
84    Pause,
85    /// Toggle play/pause.
86    TogglePlay,
87    /// Advance the clock by `dt_ms` (the UI's per-frame tick).
88    Tick {
89        /// Elapsed milliseconds.
90        dt_ms: u32,
91    },
92    /// Seek to an exact frame.
93    Seek {
94        /// Target frame.
95        frame: u64,
96    },
97    /// Step `delta` frames (negative = back) and pause.
98    Step {
99        /// Frame delta.
100        delta: i64,
101    },
102    /// Set the playback rate.
103    SetRate {
104        /// New rate.
105        rate: f32,
106    },
107    /// Set in/out points.
108    SetInOut {
109        /// One bound.
110        a: u64,
111        /// The other bound.
112        b: u64,
113    },
114    /// Clear in/out points.
115    ClearInOut,
116    /// Enable/disable looping.
117    SetLooping {
118        /// Loop flag.
119        looping: bool,
120    },
121    /// Update the project length after a duration-changing edit (ripple).
122    SetDuration {
123        /// New total project length in frames.
124        frames: u64,
125    },
126    /// No-op — read the current status (mirrors the backend variant).
127    Status,
128}
129
130#[wasm_bindgen]
131extern "C" {
132    /// Send a transport action to the backend editor session. `catch` so it
133    /// degrades to a no-op outside Tauri.
134    #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorTransport", catch)]
135    fn screen_editor_transport_js(action: JsValue) -> Result<JsValue, JsValue>;
136}
137
138/// Send a transport action. The resulting status arrives asynchronously as
139/// an `editor-status` event (see [`install_editor_status_listener`]).
140pub fn editor_transport(action: &TransportAction) {
141    if let Ok(js) = serde_wasm_bindgen::to_value(action) {
142        let _ = screen_editor_transport_js(js);
143    }
144}
145
146/// Install an `editor-status` `CustomEvent` listener pushing the parsed
147/// [`EditorStatus`] into `status`. App-lifetime; leaked via `forget`.
148pub fn install_editor_status_listener(status: RwSignal<EditorStatus>) {
149    let Some(window) = web_sys::window() else {
150        return;
151    };
152    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
153        if let Ok(custom) = event.dyn_into::<CustomEvent>()
154            && let Ok(parsed) = serde_wasm_bindgen::from_value::<EditorStatus>(custom.detail())
155        {
156            status.set(parsed);
157        }
158    }) as Box<dyn FnMut(_)>);
159    let _ =
160        window.add_event_listener_with_callback("editor-status", closure.as_ref().unchecked_ref());
161    closure.forget();
162}
163
164// ── ED.22: export progress + cancel ─────────────────────────────────────
165
166/// Progress payload from the backend `editor-export-progress` event.
167#[derive(Clone, Copy, Debug, Default, Deserialize)]
168pub struct ExportProgress {
169    /// Frames composed + encoded so far.
170    pub done: u64,
171    /// Total frames in the export.
172    pub total: u64,
173}
174
175/// UI-facing export state, driven by the export event bridge.
176#[derive(Clone, Debug, PartialEq, Eq, Default)]
177pub enum ExportUiState {
178    /// No export in flight.
179    #[default]
180    Idle,
181    /// Export running: `done` of `total` frames.
182    Running {
183        /// Frames done.
184        done: u64,
185        /// Total frames.
186        total: u64,
187    },
188    /// Export finished — output at `path`.
189    Done {
190        /// Output file path.
191        path: String,
192    },
193    /// Export failed (or was cancelled).
194    Error {
195        /// Failure message.
196        message: String,
197    },
198}
199
200/// Progress as a whole percent `0..=100` (0 when `total` is 0).
201#[must_use]
202pub fn export_percent(done: u64, total: u64) -> u32 {
203    if total == 0 {
204        return 0;
205    }
206    u32::try_from(done.saturating_mul(100) / total)
207        .unwrap_or(100)
208        .min(100)
209}
210
211#[wasm_bindgen]
212extern "C" {
213    /// Start an edited export. Resolves to the output path (re-dispatched as
214    /// `editor-export-done`); rejects as `editor-export-error`.
215    #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorExport", catch)]
216    fn screen_editor_export_js(project: JsValue, format: JsValue) -> Result<JsValue, JsValue>;
217
218    /// Request cancellation of the in-flight export.
219    #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorExportCancel", catch)]
220    fn screen_editor_export_cancel_js() -> Result<JsValue, JsValue>;
221
222    /// Open / update the live editor preview (AUT-510) for `project` — builds
223    /// the compose pipeline (and re-applies edits) so `editor_preview_frame`
224    /// composes the current edit. Fire-and-forget.
225    #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorPreviewOpen", catch)]
226    fn screen_editor_preview_open_js(project: JsValue) -> Result<JsValue, JsValue>;
227}
228
229/// Open / update the live editor preview for `project` (AUT-510). Call on the
230/// initial load and whenever an edit mutates the project, so the next composed
231/// frame reflects it. The composed frames themselves come from the canvas poll
232/// (`__screenEditorPreviewFrame`).
233pub fn editor_preview_open(project: &EditProject) {
234    if let Ok(js) = serde_wasm_bindgen::to_value(project) {
235        let _ = screen_editor_preview_open_js(js);
236    }
237}
238
239/// Start exporting `project` to `format` (e.g. `"mp4"`). Progress + result
240/// arrive as events (see [`install_editor_export_listeners`]).
241pub fn editor_export(project: &EditProject, format: &str) {
242    if let Ok(js) = serde_wasm_bindgen::to_value(project) {
243        let _ = screen_editor_export_js(js, JsValue::from_str(format));
244    }
245}
246
247/// Request cancellation of the in-flight export.
248pub fn editor_export_cancel() {
249    let _ = screen_editor_export_cancel_js();
250}
251
252fn on_custom_event(name: &str, mut handler: impl FnMut(CustomEvent) + 'static) {
253    let Some(window) = web_sys::window() else {
254        return;
255    };
256    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
257        if let Ok(custom) = event.dyn_into::<CustomEvent>() {
258            handler(custom);
259        }
260    }) as Box<dyn FnMut(_)>);
261    let _ = window.add_event_listener_with_callback(name, closure.as_ref().unchecked_ref());
262    closure.forget();
263}
264
265/// Install the export event listeners (progress / done / error), each
266/// pushing into `state`. App-lifetime; closures leaked via `forget`.
267pub fn install_editor_export_listeners(state: RwSignal<ExportUiState>) {
268    on_custom_event("editor-export-progress", move |custom| {
269        if let Ok(p) = serde_wasm_bindgen::from_value::<ExportProgress>(custom.detail()) {
270            state.set(ExportUiState::Running {
271                done: p.done,
272                total: p.total,
273            });
274        }
275    });
276    on_custom_event("editor-export-done", move |custom| {
277        let path = custom.detail().as_string().unwrap_or_default();
278        state.set(ExportUiState::Done { path });
279    });
280    on_custom_event("editor-export-error", move |custom| {
281        let message = custom
282            .detail()
283            .as_string()
284            .unwrap_or_else(|| "export failed".to_owned());
285        state.set(ExportUiState::Error { message });
286    });
287}
288
289#[wasm_bindgen]
290extern "C" {
291    /// Save the current project to its `.screenproj` (ED.23). The written
292    /// path is re-dispatched as an `editor-saved` event.
293    #[wasm_bindgen(js_namespace = window, js_name = "__screenEditorSaveProject", catch)]
294    fn screen_editor_save_project_js(project: JsValue) -> Result<JsValue, JsValue>;
295}
296
297/// Save `project` to its `.screenproj`. The written path arrives as an
298/// `editor-saved` event (see [`install_editor_saved_listener`]).
299pub fn editor_save_project(project: &EditProject) {
300    if let Ok(js) = serde_wasm_bindgen::to_value(project) {
301        let _ = screen_editor_save_project_js(js);
302    }
303}
304
305/// Install an `editor-saved` listener pushing the written path into `saved`.
306pub fn install_editor_saved_listener(saved: RwSignal<Option<String>>) {
307    on_custom_event("editor-saved", move |custom| {
308        saved.set(custom.detail().as_string());
309    });
310}
311
312// ── ED.24: recordings library ───────────────────────────────────────────
313
314/// Library mirror of the backend `RecordingEntry` (deserialize side).
315#[derive(Clone, Debug, PartialEq, Eq, Default, Deserialize)]
316pub struct RecordingEntry {
317    /// Absolute path to the `.mp4`.
318    pub path: String,
319    /// Display name (file stem).
320    pub name: String,
321    /// Whether a saved `.screenproj` sits beside it.
322    pub has_project: bool,
323}
324
325#[wasm_bindgen]
326extern "C" {
327    /// List recordings; results arrive as a `recordings-listed` event.
328    #[wasm_bindgen(js_namespace = window, js_name = "__screenListRecordings", catch)]
329    fn screen_list_recordings_js() -> Result<JsValue, JsValue>;
330}
331
332/// Ask the shell to list recordings (results via `recordings-listed`).
333pub fn list_recordings() {
334    let _ = screen_list_recordings_js();
335}
336
337/// Install a `recordings-listed` listener pushing the entries into `entries`.
338pub fn install_recordings_listener(entries: RwSignal<Vec<RecordingEntry>>) {
339    on_custom_event("recordings-listed", move |custom| {
340        if let Ok(list) = serde_wasm_bindgen::from_value::<Vec<RecordingEntry>>(custom.detail()) {
341            entries.set(list);
342        }
343    });
344}
345
346// ── Drop-to-edit ─────────────────────────────────────────────────────────
347
348/// Whether `path` has a recognized video extension. The editor drop
349/// listener ignores non-video drops so dropping an unrelated file is a
350/// no-op rather than a failed `gst-discoverer` probe.
351#[must_use]
352pub(crate) fn looks_like_video(path: &str) -> bool {
353    let lower = path.to_ascii_lowercase();
354    [".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi"]
355        .iter()
356        .any(|ext| lower.ends_with(ext))
357}
358
359/// Install a `file-dropped` listener that opens a dropped **video** in the
360/// editor: it calls [`screen_open_in_editor`], whose `editor-project` reply
361/// loads the project and (via the app-root effect) switches to the Editor
362/// tab. Dropping a video anywhere in the app surface opens it for editing.
363/// App-lifetime; the closure is leaked via `forget`.
364pub fn install_file_drop_to_editor_listener() {
365    on_custom_event("file-dropped", move |custom| {
366        if let Some(path) = custom.detail().as_string()
367            && looks_like_video(&path)
368        {
369            let _ = screen_open_in_editor(&path);
370        }
371    });
372}
373
374/// Install `file-drag-enter` / `file-drag-leave` listeners that drive
375/// `active` — the editor drop zone reads it to show a drag-over highlight.
376/// App-lifetime; closures leaked via `forget`.
377pub fn install_drag_active_listeners(active: RwSignal<bool>) {
378    on_custom_event("file-drag-enter", move |_| active.set(true));
379    on_custom_event("file-drag-leave", move |_| active.set(false));
380}
381
382#[cfg(test)]
383mod export_tests {
384    use super::export_percent;
385
386    #[test]
387    fn percent_is_clamped_and_zero_safe() {
388        assert_eq!(export_percent(0, 0), 0);
389        assert_eq!(export_percent(0, 200), 0);
390        assert_eq!(export_percent(100, 200), 50);
391        assert_eq!(export_percent(200, 200), 100);
392        assert_eq!(export_percent(999, 200), 100); // clamped
393    }
394}
395
396#[cfg(test)]
397mod drop_tests {
398    use super::looks_like_video;
399
400    #[test]
401    fn recognizes_video_extensions_case_insensitively() {
402        assert!(looks_like_video("/recordings/Screen-2026-05-31.mp4"));
403        assert!(looks_like_video("/x/clip.MOV"));
404        assert!(looks_like_video("/x/a.WebM"));
405        assert!(looks_like_video("/x/b.mkv"));
406        // Non-video / extension-less drops are ignored.
407        assert!(!looks_like_video("/x/notes.txt"));
408        assert!(!looks_like_video("/x/image.png"));
409        assert!(!looks_like_video("/x/screenshot.mp4.txt"));
410        assert!(!looks_like_video("/x/noext"));
411    }
412}