Skip to main content

app_ui/
editor_surface.rs

1//! `EditorSurface` — the live editor surface (ED.5/ED.7 / M-EDIT).
2//!
3//! Renders the real [`EditorShell`] chrome driven by the loaded
4//! [`EditProject`], with a wired transport bar (ED.7) in the timeline slot:
5//! play/pause, frame-step, jump-to-ends, a scrubber, a speed selector, a
6//! `MM:SS.ff` timecode, and keyboard shortcuts — all driving the backend
7//! editor session over [`crate::editor_ipc`]. The canvas / inspector slots
8//! are filled by later chunks (preview ED.6, inspector ED.18).
9
10use edit::EditProject;
11use leptos::prelude::*;
12use ui_storybook::components::editor::{EditorShell, EditorShellView, ToolbarActionView};
13
14use crate::editor_ipc::{self, EditorStatus, TransportAction};
15
16/// The editor toolbar action set (matches the reference design). When no
17/// clip is loaded every action is disabled.
18fn default_toolbar(loaded: bool) -> Vec<ToolbarActionView> {
19    let disabled = !loaded;
20    let action = |id, label, icon, selected| ToolbarActionView {
21        id,
22        label,
23        icon,
24        selected,
25        disabled,
26    };
27    vec![
28        action("aspect", "16:9", "▭", true),
29        action("split", "Split", "✂", false),
30        action("trim", "Trim", "⇥", false),
31        action("crop", "Crop", "⛶", false),
32        action("zoom", "Zoom", "⌖", false),
33        action("annotate", "Annotate", "✎", false),
34        action("captions", "Captions", "≡", false),
35    ]
36}
37
38/// File name of the clip (the document title).
39fn clip_title(project: &EditProject) -> String {
40    project
41        .source
42        .path
43        .file_name()
44        .and_then(|name| name.to_str())
45        .unwrap_or("recording")
46        .to_owned()
47}
48
49/// `"1920×1080 · 30 fps · m:ss"` subtitle from the clip metadata.
50fn clip_subtitle(project: &EditProject) -> String {
51    let src = &project.source;
52    let secs = if src.source_fps > 0 {
53        src.frame_count / u64::from(src.source_fps)
54    } else {
55        0
56    };
57    let (mins, rem) = (secs / 60, secs % 60);
58    format!(
59        "{}×{} · {} fps · {mins}:{rem:02}",
60        src.width, src.height, src.source_fps
61    )
62}
63
64/// Format a frame index as `MM:SS.ff` (ff = frame within the second).
65#[must_use]
66pub fn format_timecode(frame: u64, fps: u32) -> String {
67    let fps = u64::from(fps.max(1));
68    let total_secs = frame / fps;
69    let mins = total_secs / 60;
70    let secs = total_secs % 60;
71    let frames = frame % fps;
72    format!("{mins:02}:{secs:02}.{frames:02}")
73}
74
75/// Map the loaded project (or its absence) to the shell view-model.
76fn shell_view_for(project: Option<&EditProject>) -> EditorShellView {
77    match project {
78        Some(p) => EditorShellView {
79            document_title: clip_title(p),
80            document_subtitle: Some(clip_subtitle(p)),
81            has_clip_loaded: true,
82            toolbar_actions: default_toolbar(true),
83            export_enabled: true,
84            share_enabled: true,
85        },
86        None => EditorShellView {
87            document_title: String::new(),
88            document_subtitle: None,
89            has_clip_loaded: false,
90            toolbar_actions: default_toolbar(false),
91            export_enabled: false,
92            share_enabled: false,
93        },
94    }
95}
96
97/// Transport bar (ED.7) — bound to the backend editor session via
98/// [`crate::editor_ipc`]. Fine-grained reactive: only the timecode, the
99/// play glyph, and the scrubber value re-render as the playhead advances.
100#[component]
101fn EditorTransportBar() -> impl IntoView {
102    let status = use_context::<RwSignal<EditorStatus>>()
103        .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
104    view! {
105        <div class="editor-transport" role="group" aria-label="Playback transport">
106            <button class="transport-btn" title="Jump to start"
107                on:click=move |_| editor_ipc::editor_transport(&TransportAction::Seek { frame: 0 })>
108                "⏮"
109            </button>
110            <button class="transport-btn" title="Step back"
111                on:click=move |_| editor_ipc::editor_transport(&TransportAction::Step { delta: -1 })>
112                "‹"
113            </button>
114            <button class="transport-btn transport-play" title="Play / pause (Space)"
115                on:click=move |_| editor_ipc::editor_transport(&TransportAction::TogglePlay)>
116                {move || if status.get().playing { "⏸" } else { "▶" }}
117            </button>
118            <button class="transport-btn" title="Step forward"
119                on:click=move |_| editor_ipc::editor_transport(&TransportAction::Step { delta: 1 })>
120                "›"
121            </button>
122            <button class="transport-btn" title="Jump to end"
123                on:click=move |_| editor_ipc::editor_transport(&TransportAction::Seek {
124                    frame: status.get_untracked().duration_frames.saturating_sub(1),
125                })>
126                "⏭"
127            </button>
128            <span class="transport-timecode">
129                {move || format_timecode(status.get().current_frame, status.get().fps)}
130                " / "
131                {move || format_timecode(status.get().duration_frames.saturating_sub(1), status.get().fps)}
132            </span>
133            <input
134                type="range"
135                class="transport-scrub"
136                min="0"
137                prop:max=move || status.get().duration_frames.saturating_sub(1).to_string()
138                prop:value=move || status.get().current_frame.to_string()
139                on:input=move |ev| {
140                    if let Ok(frame) = event_target_value(&ev).parse::<u64>() {
141                        editor_ipc::editor_transport(&TransportAction::Seek { frame });
142                    }
143                }
144            />
145            <select class="transport-rate" title="Playback speed"
146                on:change=move |ev| {
147                    if let Ok(rate) = event_target_value(&ev).parse::<f32>() {
148                        editor_ipc::editor_transport(&TransportAction::SetRate { rate });
149                    }
150                }>
151                <option value="0.5">"0.5×"</option>
152                <option value="1" selected=true>"1×"</option>
153                <option value="2">"2×"</option>
154            </select>
155        </div>
156    }
157}
158
159/// Handle a keydown on the editor surface: transport (Space / arrows /
160/// I·O) and edit (S split, ⌘Z undo·redo, Delete ripple) shortcuts.
161fn handle_editor_keydown(
162    ev: &web_sys::KeyboardEvent,
163    project: Option<RwSignal<Option<EditProject>>>,
164    history: Option<StoredValue<Option<edit::History>>>,
165    selection: Option<RwSignal<Option<usize>>>,
166    status: RwSignal<EditorStatus>,
167) {
168    match ev.key().as_str() {
169        " " | "Spacebar" => {
170            ev.prevent_default();
171            editor_ipc::editor_transport(&TransportAction::TogglePlay);
172        }
173        "ArrowLeft" => {
174            ev.prevent_default();
175            let delta = if ev.shift_key() { -5 } else { -1 };
176            editor_ipc::editor_transport(&TransportAction::Step { delta });
177        }
178        "ArrowRight" => {
179            ev.prevent_default();
180            let delta = if ev.shift_key() { 5 } else { 1 };
181            editor_ipc::editor_transport(&TransportAction::Step { delta });
182        }
183        "i" | "I" => {
184            let s = status.get_untracked();
185            editor_ipc::editor_transport(&TransportAction::SetInOut {
186                a: s.current_frame,
187                b: s.out_frame.max(s.current_frame + 1),
188            });
189        }
190        "o" | "O" => {
191            let s = status.get_untracked();
192            editor_ipc::editor_transport(&TransportAction::SetInOut {
193                a: s.in_frame.min(s.current_frame),
194                b: s.current_frame + 1,
195            });
196        }
197        // ED.11 — the razor: split the clip under the playhead.
198        "s" | "S" => {
199            ev.prevent_default();
200            if let (Some(p), Some(h)) = (project, history) {
201                crate::editor_edits::split_at(p, h, status.get_untracked().current_frame);
202            }
203        }
204        // ED.11 — the trim bin: undo / redo (⌘Z / ⌘⇧Z).
205        "z" | "Z" if ev.meta_key() || ev.ctrl_key() => {
206            ev.prevent_default();
207            if let (Some(p), Some(h)) = (project, history) {
208                if ev.shift_key() {
209                    crate::editor_edits::redo(p, h);
210                } else {
211                    crate::editor_edits::undo(p, h);
212                }
213            }
214        }
215        // ED.11 — ripple-delete the selected clip (close the gap).
216        "Delete" | "Backspace" => {
217            ev.prevent_default();
218            if let (Some(p), Some(h)) = (project, history) {
219                let sel = selection.and_then(|s| s.get_untracked());
220                crate::editor_edits::ripple_delete_selected(p, h, sel);
221                // The deleted clip is gone, so the old index would now point
222                // at a different segment — clear it so a later edit can't
223                // target the wrong clip.
224                if let Some(s) = selection {
225                    s.set(None);
226                }
227            }
228        }
229        _ => {}
230    }
231}
232
233/// The editor surface. Reads the loaded [`EditProject`] + the playhead
234/// [`EditorStatus`] from context and renders the [`EditorShell`] with a
235/// wired transport. Keyboard: Space = play/pause, ←/→ step (Shift = 5),
236/// I/O set in/out, S split, ⌘Z undo·redo, Delete ripple-delete.
237#[component]
238pub fn EditorSurface() -> impl IntoView {
239    let project = use_context::<RwSignal<Option<EditProject>>>();
240    let status = use_context::<RwSignal<EditorStatus>>()
241        .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
242    let history = use_context::<StoredValue<Option<edit::History>>>();
243    let selection = use_context::<RwSignal<Option<usize>>>();
244    // Drag-over highlight for the drop zone (set by the app-root drag
245    // listeners). Falls back to a local signal outside the AppShell.
246    let drag_active = use_context::<RwSignal<bool>>().unwrap_or_else(|| RwSignal::new(false));
247    // AUT-510: install the live-preview hooks once per mount (re-open on edit
248    // + the repaint poll). Must be in the body, not the reactive view block,
249    // or each rebuild would leak another poll.
250    crate::editor_preview_canvas::install_editor_preview(project, status);
251    // Toolbar chips emit their id on click; map the actionable ones to edit
252    // ops at the playhead (the same ops the keyboard shortcuts fire). `split`
253    // is the must-fix (the razor); `zoom` drops a punch-in. Other chips
254    // (trim/crop/aspect/annotate/captions) are not wired yet — they no-op
255    // until their interactions land, rather than silently doing nothing with
256    // no code path at all.
257    let on_toolbar_action = Callback::new(move |id: String| {
258        if let (Some(p), Some(h)) = (project, history) {
259            let frame = status.get_untracked().current_frame;
260            match id.as_str() {
261                "split" => crate::editor_edits::split_at(p, h, frame),
262                // The toolbar Zoom button punches in on the cursor (the
263                // defining move); the zoom lane's "+ Zoom" stays centre-default.
264                "zoom" => crate::editor_edits::add_zoom_at_cursor(p, h, frame),
265                _ => {}
266            }
267        }
268    });
269    view! {
270        <section
271            class="app-surface app-surface--editor"
272            tabindex="0"
273            on:keydown=move |ev| handle_editor_keydown(&ev, project, history, selection, status)
274        >
275            {move || {
276                let vm = match project {
277                    Some(signal) => shell_view_for(signal.get().as_ref()),
278                    None => shell_view_for(None),
279                };
280                let loaded = vm.has_clip_loaded;
281                view! {
282                    <EditorShell
283                        view=vm
284                        on_action=on_toolbar_action
285                        canvas=ToChildren::to_children(move || {
286                            if loaded {
287                                // AUT-510: the live preview canvas. Sized to the
288                                // project's aspect-ratio canvas (AUT-513), which the
289                                // backend composes the source into (putImageData
290                                // doesn't scale; CSS fits it to the pane). Painted by
291                                // the poll installed above, by DOM id.
292                                let (cw, ch) = project
293                                    .and_then(|s| s.with(|o| o.as_ref().map(EditProject::canvas_dims)))
294                                    .unwrap_or((1, 1));
295                                view! {
296                                    <canvas
297                                        id=crate::editor_preview_canvas::EDITOR_PREVIEW_CANVAS_ID
298                                        class="editor-preview-canvas"
299                                        width=cw
300                                        height=ch
301                                        aria-label="Editor preview"
302                                    />
303                                }
304                                .into_any()
305                            } else {
306                                // Always-available drop target — drop a video
307                                // anywhere to open it here (handled globally by
308                                // the app-root file-drop listener).
309                                view! {
310                                    <div class=move || {
311                                        if drag_active.get() {
312                                            "editor-dropzone editor-dropzone--active"
313                                        } else {
314                                            "editor-dropzone"
315                                        }
316                                    }>
317                                        <div class="editor-dropzone-icon" aria-hidden="true">"⬇"</div>
318                                        <p class="editor-dropzone-title">"Drop a video to edit"</p>
319                                        <p class="editor-dropzone-hint">
320                                            "Drop a recording here, finish a new recording, or pick one from the Library."
321                                        </p>
322                                    </div>
323                                }
324                                .into_any()
325                            }
326                        })
327                        inspector=ToChildren::to_children(move || view! {
328                            <crate::export_bar::ExportBar />
329                            <crate::style_inspector::StyleInspector />
330                            <crate::cursor_inspector::CursorInspector />
331                            <crate::framing_inspector::FramingInspector />
332                            <crate::clip_inspector::ClipInspector />
333                        })
334                        timeline=ToChildren::to_children(move || view! {
335                            <crate::timeline_view::TimelineRuler />
336                            <crate::filmstrip::VideoFilmstrip />
337                            <crate::zoom_lane::ZoomLane />
338                            <crate::zoom_dopesheet::ZoomDopesheet />
339                            <crate::waveform::AudioWaveform />
340                            <EditorTransportBar />
341                        })
342                    />
343                }
344            }}
345        </section>
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use edit::ClipRef;
353    use std::path::PathBuf;
354
355    fn project() -> EditProject {
356        EditProject::from_recording(ClipRef::new(
357            PathBuf::from("/tmp/Screen-2026-05-30.mp4"),
358            1920,
359            1080,
360            30,
361            900,
362        ))
363    }
364
365    #[test]
366    fn empty_view_has_no_clip_and_disabled_actions() {
367        let v = shell_view_for(None);
368        assert!(!v.has_clip_loaded);
369        assert!(!v.export_enabled);
370        assert!(v.toolbar_actions.iter().all(|a| a.disabled));
371        assert!(v.document_subtitle.is_none());
372    }
373
374    #[test]
375    fn loaded_view_titles_and_enables() {
376        let p = project();
377        let v = shell_view_for(Some(&p));
378        assert!(v.has_clip_loaded);
379        assert!(v.export_enabled);
380        assert_eq!(v.document_title, "Screen-2026-05-30.mp4");
381        assert_eq!(
382            v.document_subtitle.as_deref(),
383            Some("1920×1080 · 30 fps · 0:30")
384        );
385        assert!(v.toolbar_actions.iter().all(|a| !a.disabled));
386        assert!(
387            v.toolbar_actions
388                .iter()
389                .any(|a| a.id == "aspect" && a.selected)
390        );
391    }
392
393    #[test]
394    fn wired_toolbar_actions_exist_as_chips() {
395        // The toolbar dispatch (EditorSurface's on_toolbar_action) only acts on
396        // these ids; guard that each still names a real chip, so a rename of
397        // the toolbar can't silently make the Split/Zoom buttons inert again.
398        let v = shell_view_for(None);
399        for wired in ["split", "zoom"] {
400            assert!(
401                v.toolbar_actions.iter().any(|a| a.id == wired),
402                "wired toolbar action `{wired}` must exist as a chip"
403            );
404        }
405    }
406
407    #[test]
408    fn timecode_formats_mm_ss_ff() {
409        assert_eq!(format_timecode(0, 30), "00:00.00");
410        assert_eq!(format_timecode(75, 30), "00:02.15"); // 2s + 15 frames
411        assert_eq!(format_timecode(1800, 30), "01:00.00"); // 60s
412        assert_eq!(format_timecode(29, 30), "00:00.29");
413        // fps 0 is treated as 1 (no div-by-zero).
414        assert_eq!(format_timecode(5, 0), "00:05.00");
415    }
416}