Skip to main content

ravenrs_movie/
editor.rs

1//! The editor, in the style of screen-recording editors: effects on the left, the
2//! canvas in the middle, properties on the right and the timeline along the bottom.
3
4use std::cell::{Cell, RefCell};
5use std::rc::Rc;
6
7use ravenrs_app_i18n::{Language, Texts};
8use ravenrs_core::{Border, Color, CursorIcon, Key, Point, Rect, Size};
9use ravenrs_layout::{Align, Axis, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle, Weight};
12use ravenrs_widgets::{
13    AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Divider, Drag, DragPhase, HStack, IconButton,
14    IconName, List, ListItem, NodeId, Paint, QuadPaint, ScrollView, Select, Semantics, Slider, Spacer,
15    Stepper, Text, TextField, Timeline, TimelineEdit, TimelineItem, TimelineMarker, TimelineTrack, Toggle,
16    Tone, ToolRail, VStack, View, ViewExt,
17};
18
19use crate::composition::{Composition, camera_at};
20use crate::movie::Movie;
21use crate::player::{MovieLabels, MoviePlayer};
22use crate::project::{CalloutStyle, Clip, ClipKind, Marker, Overlay, OverlayKind, Project, Region};
23
24const RAIL_WIDTH: f32 = 76.0;
25const LIBRARY_WIDTH: f32 = 236.0;
26const PROPERTIES_WIDTH: f32 = 288.0;
27const TOP_BAR: f32 = 52.0;
28const TRANSPORT: f32 = 48.0;
29const TIMELINE_BAR: f32 = 40.0;
30const TRACK_HEADS: f32 = 112.0;
31const OVERLAY_ROW: f32 = 34.0;
32const CLIP_ROW: f32 = 56.0;
33const THUMBNAIL: f32 = 64.0;
34const HANDLE: f32 = 8.0;
35const FRAME: f64 = 1.0 / 30.0;
36const SPEEDS: [f32; 5] = [0.5, 1.0, 1.5, 2.0, 4.0];
37/// How long a new effect lasts, in milliseconds.
38const NEW_EFFECT_MS: u64 = 3000;
39/// How far one undo goes back at most.
40const UNDO_LIMIT: usize = 200;
41
42/// The words a [`MovieEditor`] shows. English by default.
43#[derive(Clone, Debug)]
44#[non_exhaustive]
45#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
46pub struct EditorLabels {
47    /// Tries the film the way people will watch it.
48    pub try_it: String,
49    /// Goes back from trying it.
50    pub back_to_editing: String,
51    /// Adds a chapter at the playhead.
52    pub chapter: String,
53    /// A chapter's first name.
54    pub new_chapter: String,
55    /// The tool tab with callouts and captions.
56    pub tab_annotations: String,
57    /// The tool tab with what acts on the screen.
58    pub tab_screen: String,
59    /// The tool tab with fades.
60    pub tab_transitions: String,
61    /// The tool tab for the pointer.
62    pub tab_pointer: String,
63    /// The tool tab with clickable spots.
64    pub tab_interaction: String,
65    /// The tool bar's name.
66    pub tools: String,
67    /// The hint at the foot of the library.
68    pub library_hint: String,
69    /// The film's row on the timeline.
70    pub track_film: String,
71    /// How a callout looks.
72    pub style: String,
73    /// A balloon with a colored edge.
74    pub style_balloon: String,
75    /// A filled label.
76    pub style_label: String,
77    /// A callout comes and goes gradually.
78    pub soft: String,
79    /// What being soft means.
80    pub soft_hint: String,
81    /// When it starts.
82    pub start: String,
83    /// The film's size and length, under its name.
84    pub timeline: String,
85    /// Takes the last edit back.
86    pub undo: String,
87    /// Puts it back.
88    pub redo: String,
89    /// Cuts the clip at the playhead.
90    pub split: String,
91    /// Deletes what is selected.
92    pub delete: String,
93    /// Saves the film with its edits.
94    pub save: String,
95    /// Plays.
96    pub play: String,
97    /// Pauses.
98    pub pause: String,
99    /// The heading of the effects.
100    pub effects: String,
101    /// The heading of the properties.
102    pub properties: String,
103    /// The properties panel with nothing selected.
104    pub nothing_selected: String,
105    /// A clip of the film.
106    pub film_clip: String,
107    /// A title card.
108    pub title_card: String,
109    /// A balloon with text.
110    pub callout: String,
111    /// A line of subtitles.
112    pub caption: String,
113    /// Lights up one region.
114    pub spotlight: String,
115    /// Covers a region.
116    pub hide: String,
117    /// Zooms into a region.
118    pub zoom: String,
119    /// Comes in from black.
120    pub fade_in: String,
121    /// Goes out to black.
122    pub fade_out: String,
123    /// A circle around the pointer.
124    pub pointer_highlight: String,
125    /// A spot that talks to the watcher.
126    pub hotspot: String,
127    /// The text of what is selected.
128    pub text: String,
129    /// A title card's second line.
130    pub subtitle: String,
131    /// How long it lasts.
132    pub duration: String,
133    /// How fast a clip plays.
134    pub speed: String,
135    /// A hotspot waits for a click.
136    pub wait_for_click: String,
137    /// The zoom of the timeline itself.
138    pub timeline_zoom: String,
139    /// The position of the playhead.
140    pub position: String,
141    /// Goes to the chapter before.
142    pub previous_chapter: String,
143    /// Goes to the chapter after.
144    pub next_chapter: String,
145    /// How a region is placed.
146    pub drag_hint: String,
147    /// The first words of a new callout.
148    pub new_text: String,
149}
150
151impl Default for EditorLabels {
152    fn default() -> Self {
153        Self::from_texts(&Texts::new(Language::English))
154    }
155}
156
157#[cfg_attr(feature = "live", ravenrs_live::expose)]
158impl EditorLabels {
159    /// The texts of a catalog, in its language. What it does not have comes from English.
160    pub fn from_texts(texts: &Texts) -> Self {
161        Self {
162            try_it: texts.get("movie.editor.try_it"),
163            back_to_editing: texts.get("movie.editor.back_to_editing"),
164            chapter: texts.get("movie.editor.chapter"),
165            new_chapter: texts.get("movie.editor.new_chapter"),
166            tab_annotations: texts.get("movie.editor.tab_annotations"),
167            tab_screen: texts.get("movie.editor.tab_screen"),
168            tab_transitions: texts.get("movie.editor.tab_transitions"),
169            tab_pointer: texts.get("movie.editor.tab_pointer"),
170            tab_interaction: texts.get("movie.editor.tab_interaction"),
171            tools: texts.get("movie.editor.tools"),
172            library_hint: texts.get("movie.editor.library_hint"),
173            track_film: texts.get("movie.editor.track_film"),
174            style: texts.get("movie.editor.style"),
175            style_balloon: texts.get("movie.editor.style_balloon"),
176            style_label: texts.get("movie.editor.style_label"),
177            soft: texts.get("movie.editor.soft"),
178            soft_hint: texts.get("movie.editor.soft_hint"),
179            start: texts.get("movie.editor.start"),
180            timeline: texts.get("movie.editor.timeline"),
181            undo: texts.get("movie.editor.undo"),
182            redo: texts.get("movie.editor.redo"),
183            split: texts.get("movie.editor.split"),
184            delete: texts.get("movie.editor.delete"),
185            save: texts.get("movie.editor.save"),
186            play: texts.get("movie.editor.play"),
187            pause: texts.get("movie.editor.pause"),
188            effects: texts.get("movie.editor.effects"),
189            properties: texts.get("movie.editor.properties"),
190            nothing_selected: texts.get("movie.editor.nothing_selected"),
191            film_clip: texts.get("movie.editor.film_clip"),
192            title_card: texts.get("movie.editor.title_card"),
193            callout: texts.get("movie.editor.callout"),
194            caption: texts.get("movie.editor.caption"),
195            spotlight: texts.get("movie.editor.spotlight"),
196            hide: texts.get("movie.editor.hide"),
197            zoom: texts.get("movie.editor.zoom"),
198            fade_in: texts.get("movie.editor.fade_in"),
199            fade_out: texts.get("movie.editor.fade_out"),
200            pointer_highlight: texts.get("movie.editor.pointer_highlight"),
201            hotspot: texts.get("movie.editor.hotspot"),
202            text: texts.get("movie.editor.text"),
203            subtitle: texts.get("movie.editor.subtitle"),
204            duration: texts.get("movie.editor.duration"),
205            speed: texts.get("movie.editor.speed"),
206            wait_for_click: texts.get("movie.editor.wait_for_click"),
207            timeline_zoom: texts.get("movie.editor.timeline_zoom"),
208            position: texts.get("movie.editor.position"),
209            previous_chapter: texts.get("movie.editor.previous_chapter"),
210            next_chapter: texts.get("movie.editor.next_chapter"),
211            drag_hint: texts.get("movie.editor.drag_hint"),
212            new_text: texts.get("movie.editor.new_text"),
213        }
214    }
215
216    /// The words in Brazilian Portuguese.
217    pub fn pt_br() -> Self {
218        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
219    }
220}
221
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
223enum Tab {
224    Annotations,
225    Screen,
226    Transitions,
227    Pointer,
228    Interaction,
229}
230
231#[derive(Clone, Copy, Debug, PartialEq, Eq)]
232enum Selection {
233    Nothing,
234    Clip(usize),
235    Overlay(u64),
236}
237
238#[derive(Debug)]
239struct Inner {
240    movie: RefCell<Movie>,
241    position: Cell<u64>,
242    running: Cell<Option<(f64, u64)>>,
243    selection: Cell<Selection>,
244    undo: RefCell<Vec<Project>>,
245    redo: RefCell<Vec<Project>>,
246    /// What the last edit was, so that typing a word is one undo and not five.
247    last_edit: RefCell<Option<String>>,
248    /// Pixels of the timeline per second.
249    density: Cell<f32>,
250    tab: Cell<Tab>,
251    /// The canvas shows the player, the way people will watch.
252    trying: Cell<bool>,
253}
254
255/// What is being edited, kept by the app so that it can be read and saved. All
256/// the edits the editor can make are methods here too, for scripts and tests.
257#[derive(Clone, Debug)]
258#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
259pub struct EditorState {
260    inner: Rc<Inner>,
261    revision: State<u64>,
262}
263
264#[cfg_attr(feature = "live", ravenrs_live::expose)]
265impl EditorState {
266    /// Starts editing `movie`, with whatever edits it already has.
267    pub fn new(movie: Movie) -> Self {
268        Self {
269            inner: Rc::new(Inner {
270                movie: RefCell::new(movie),
271                position: Cell::new(0),
272                running: Cell::new(None),
273                selection: Cell::new(Selection::Nothing),
274                undo: RefCell::new(Vec::new()),
275                redo: RefCell::new(Vec::new()),
276                last_edit: RefCell::new(None),
277                density: Cell::new(28.0),
278                tab: Cell::new(Tab::Annotations),
279                trying: Cell::new(false),
280            }),
281            revision: State::new(0),
282        }
283    }
284
285    /// The film with the edits so far.
286    pub fn movie(&self) -> Movie {
287        self.inner.movie.borrow().clone()
288    }
289
290    fn touch(&self) {
291        self.revision.update(|n| *n += 1);
292    }
293
294    /// Makes an edit. Edits with the same `key` in a row count as one undo.
295    fn edit(&self, key: Option<&str>, work: impl FnOnce(&mut Project)) {
296        let inner = &self.inner;
297        let mut project = inner.movie.borrow().project().clone();
298        let same = key.is_some() && inner.last_edit.borrow().as_deref() == key;
299        if !same {
300            let mut undo = inner.undo.borrow_mut();
301            undo.push(project.clone());
302            if undo.len() > UNDO_LIMIT {
303                undo.remove(0);
304            }
305            inner.redo.borrow_mut().clear();
306        }
307        *inner.last_edit.borrow_mut() = key.map(str::to_owned);
308        work(&mut project);
309        let movie = inner.movie.borrow().with_project(project);
310        *inner.movie.borrow_mut() = movie;
311        self.keep_position_inside();
312        self.touch();
313    }
314
315    fn keep_position_inside(&self) {
316        let duration = self.movie().duration_ms();
317        if self.inner.position.get() > duration {
318            self.inner.position.set(duration);
319        }
320    }
321
322    fn swap(&self, from: &RefCell<Vec<Project>>, to: &RefCell<Vec<Project>>) {
323        let Some(project) = from.borrow_mut().pop() else {
324            return;
325        };
326        to.borrow_mut().push(self.movie().project().clone());
327        let movie = self.movie().with_project(project);
328        *self.inner.movie.borrow_mut() = movie;
329        *self.inner.last_edit.borrow_mut() = None;
330        self.inner.selection.set(Selection::Nothing);
331        self.keep_position_inside();
332        self.touch();
333    }
334
335    /// Takes the last edit back.
336    pub fn undo(&self) {
337        self.swap(&self.inner.undo, &self.inner.redo);
338    }
339
340    /// Puts back what [`Self::undo`] took.
341    pub fn redo(&self) {
342        self.swap(&self.inner.redo, &self.inner.undo);
343    }
344
345    /// Where the playhead is, in milliseconds of the timeline.
346    pub fn position_ms(&self) -> u64 {
347        self.inner.position.get()
348    }
349
350    /// Moves the playhead.
351    pub fn seek(&self, ms: u64) {
352        self.inner.position.set(ms.min(self.movie().duration_ms()));
353        if self.inner.running.get().is_some() {
354            self.inner.running.set(Some((f64::NAN, self.inner.position.get())));
355        }
356        self.touch();
357    }
358
359    /// Plays from the playhead, or pauses.
360    pub fn toggle_playing(&self) {
361        if self.inner.running.get().is_some() {
362            self.inner.running.set(None);
363        } else {
364            if self.position_ms() >= self.movie().duration_ms() {
365                self.inner.position.set(0);
366            }
367            self.inner.running.set(Some((f64::NAN, self.position_ms())));
368        }
369        self.touch();
370    }
371
372    /// Cuts the clip at the playhead in two.
373    pub fn split(&self) {
374        let at = self.position_ms();
375        self.edit(None, |project| {
376            project.split(at);
377        });
378    }
379
380    /// Deletes what is selected: a clip takes its time with it.
381    pub fn delete_selected(&self) {
382        match self.inner.selection.get() {
383            Selection::Clip(index) => self.edit(None, |project| project.delete_clip(index)),
384            Selection::Overlay(id) => self.edit(None, |project| project.remove_overlay(id)),
385            Selection::Nothing => return,
386        }
387        self.inner.selection.set(Selection::Nothing);
388        self.touch();
389    }
390
391    /// Selects the clip at this place on the main track.
392    pub fn select_clip(&self, index: usize) {
393        self.inner.selection.set(Selection::Clip(index));
394        *self.inner.last_edit.borrow_mut() = None;
395        self.touch();
396    }
397
398    /// Selects an overlay.
399    pub fn select_overlay(&self, id: u64) {
400        self.inner.selection.set(Selection::Overlay(id));
401        *self.inner.last_edit.borrow_mut() = None;
402        self.touch();
403    }
404
405    /// Adds an effect at the playhead and selects it.
406    pub fn add_overlay(&self, kind: OverlayKind) -> u64 {
407        let at = self.position_ms();
408        let mut id = 0;
409        self.edit(None, |project| {
410            let length = NEW_EFFECT_MS.min(project.duration_ms().saturating_sub(at).max(500));
411            id = project.add_overlay(kind, at, length);
412        });
413        self.inner.selection.set(Selection::Overlay(id));
414        self.touch();
415        id
416    }
417
418    /// Lights `region` for the last seconds of the film, replacing what `previous`
419    /// lit, and says how to replace this one. It marks where a problem is.
420    pub fn light_at_end(&self, region: Region, previous: Option<u64>) -> u64 {
421        let mut id = 0;
422        self.edit(None, |project| {
423            if let Some(previous) = previous {
424                project.remove_overlay(previous);
425            }
426            let length = NEW_EFFECT_MS.min(project.duration_ms()).max(1);
427            let at = project.duration_ms().saturating_sub(length);
428            id = project.add_overlay(OverlayKind::Spotlight { region }, at, length);
429        });
430        self.touch();
431        id
432    }
433
434    /// Puts a title card at the playhead and selects it.
435    pub fn add_title(&self, title: &str) {
436        let at = self.position_ms();
437        let mut index = 0;
438        self.edit(None, |project| index = project.insert_title(at, title, "", NEW_EFFECT_MS));
439        self.inner.selection.set(Selection::Clip(index));
440        self.touch();
441    }
442
443    /// Starts a chapter at the playhead.
444    pub fn add_chapter(&self, title: &str) {
445        let at_ms = self.position_ms();
446        self.edit(None, |project| project.markers.push(Marker { title: title.to_owned(), at_ms }));
447    }
448
449    /// Changes how fast the selected clip plays.
450    pub fn set_speed(&self, speed: f32) {
451        if let Selection::Clip(index) = self.inner.selection.get() {
452            self.edit(None, |project| project.set_speed(index, speed));
453        }
454    }
455
456    fn change_overlay(&self, id: u64, key: &str, work: impl FnOnce(&mut Overlay)) {
457        self.edit(Some(&format!("{key}:{id}")), |project| {
458            if let Some(overlay) = project.overlay_mut(id) {
459                work(overlay);
460            }
461        });
462    }
463
464    fn selected_overlay(&self) -> Option<Overlay> {
465        let Selection::Overlay(id) = self.inner.selection.get() else {
466            return None;
467        };
468        self.movie().project().overlays.iter().find(|o| o.id == id).cloned()
469    }
470
471    fn selected_clip(&self) -> Option<(usize, Clip)> {
472        let Selection::Clip(index) = self.inner.selection.get() else {
473            return None;
474        };
475        self.movie().project().clips.get(index).cloned().map(|clip| (index, clip))
476    }
477
478    /// Runs the clock while playing. Called while building: only plain cells change.
479    fn advance(&self, now: f64) {
480        let Some((since, from)) = self.inner.running.get() else {
481            return;
482        };
483        if since.is_nan() {
484            self.inner.running.set(Some((now, from)));
485            return;
486        }
487        let at = from + ((now - since).max(0.0) * 1000.0) as u64;
488        let duration = self.movie().duration_ms();
489        if at >= duration {
490            self.inner.position.set(duration);
491            self.inner.running.set(None);
492        } else {
493            self.inner.position.set(at);
494        }
495    }
496}
497
498fn region_of(kind: &OverlayKind) -> Option<Region> {
499    match kind {
500        OverlayKind::Callout { region, .. }
501        | OverlayKind::Spotlight { region }
502        | OverlayKind::Hide { region }
503        | OverlayKind::Zoom { region }
504        | OverlayKind::Hotspot { region, .. } => Some(*region),
505        _ => None,
506    }
507}
508
509fn set_region(kind: &mut OverlayKind, new: Region) {
510    match kind {
511        OverlayKind::Callout { region, .. }
512        | OverlayKind::Spotlight { region }
513        | OverlayKind::Hide { region }
514        | OverlayKind::Zoom { region }
515        | OverlayKind::Hotspot { region, .. } => *region = new,
516        _ => {}
517    }
518}
519
520fn label_of<'a>(kind: &OverlayKind, labels: &'a EditorLabels) -> &'a str {
521    match kind {
522        OverlayKind::Callout { .. } => &labels.callout,
523        OverlayKind::Caption { .. } => &labels.caption,
524        OverlayKind::Spotlight { .. } => &labels.spotlight,
525        OverlayKind::Hide { .. } => &labels.hide,
526        OverlayKind::Zoom { .. } => &labels.zoom,
527        OverlayKind::FadeIn => &labels.fade_in,
528        OverlayKind::FadeOut => &labels.fade_out,
529        OverlayKind::PointerHighlight => &labels.pointer_highlight,
530        OverlayKind::Hotspot { .. } => &labels.hotspot,
531    }
532}
533
534/// What an overlay goes under on the timeline.
535#[derive(Clone, Copy, PartialEq, Eq)]
536enum Row {
537    Interaction,
538    Annotations,
539    Screen,
540    Pointer,
541}
542
543fn row_of(kind: &OverlayKind) -> Row {
544    match kind {
545        OverlayKind::Hotspot { .. } => Row::Interaction,
546        OverlayKind::Callout { .. } | OverlayKind::Caption { .. } => Row::Annotations,
547        OverlayKind::PointerHighlight => Row::Pointer,
548        _ => Row::Screen,
549    }
550}
551
552/// The ids blocks have on the timeline: clips below, overlays above.
553const OVERLAY_IDS: u64 = 1 << 32;
554/// The steps that came with the film, which only say where they are.
555const FILM_STEP_IDS: u64 = 2 << 32;
556/// A step lasts no time, but it has to be seen and clicked.
557const STEP_MS: u64 = 700;
558
559fn on_timeline_edit(state: &EditorState, edit: TimelineEdit) {
560    match edit {
561        TimelineEdit::Began { .. } => *state.inner.last_edit.borrow_mut() = None,
562        TimelineEdit::Moved { id, start_ms } if (OVERLAY_IDS..FILM_STEP_IDS).contains(&id) => {
563            let id = id - OVERLAY_IDS;
564            state.inner.selection.set(Selection::Overlay(id));
565            state.change_overlay(id, "move", |overlay| overlay.start_ms = start_ms);
566        }
567        TimelineEdit::TrimmedStart { id, by_ms } if (OVERLAY_IDS..FILM_STEP_IDS).contains(&id) => {
568            state.change_overlay(id - OVERLAY_IDS, "resize", |overlay| {
569                let end = overlay.end_ms();
570                let start = (overlay.start_ms as i64 + by_ms).clamp(0, end as i64 - 100) as u64;
571                overlay.start_ms = start;
572                overlay.duration_ms = end - start;
573            });
574        }
575        TimelineEdit::TrimmedEnd { id, by_ms } if (OVERLAY_IDS..FILM_STEP_IDS).contains(&id) => {
576            state.change_overlay(id - OVERLAY_IDS, "resize", |overlay| {
577                overlay.duration_ms = (overlay.duration_ms as i64 + by_ms).max(100) as u64;
578            });
579        }
580        TimelineEdit::TrimmedStart { id, by_ms } => {
581            let index = id as usize;
582            state.edit(Some(&format!("trim:{index}")), |project| project.trim_start(index, by_ms));
583        }
584        TimelineEdit::TrimmedEnd { id, by_ms } => {
585            let index = id as usize;
586            state.edit(Some(&format!("trim:{index}")), |project| project.trim_end(index, by_ms));
587        }
588        _ => {}
589    }
590}
591
592fn timeline(state: &EditorState, labels: &EditorLabels) -> Timeline {
593    let movie = state.movie();
594    let project = movie.project();
595    let selection = state.inner.selection.get();
596    let overlays_in = |row: Row| -> Vec<TimelineItem> {
597        project
598            .overlays
599            .iter()
600            .filter(|overlay| row_of(&overlay.kind) == row)
601            .map(|overlay| {
602                let tone = match row {
603                    Row::Interaction => Tone::Warning,
604                    Row::Annotations => Tone::Accent,
605                    Row::Screen => Tone::Success,
606                    Row::Pointer => Tone::Neutral,
607                };
608                TimelineItem::new(
609                    overlay.id + OVERLAY_IDS,
610                    overlay.start_ms,
611                    overlay.duration_ms,
612                    label_of(&overlay.kind, labels),
613                )
614                .tone(tone)
615                .selected(selection == Selection::Overlay(overlay.id))
616                .movable()
617                .trimmable()
618            })
619            .collect()
620    };
621    let clips: Vec<TimelineItem> = project
622        .clips
623        .iter()
624        .enumerate()
625        .map(|(index, clip)| {
626            let start = project.clip_start_ms(index);
627            let label = match &clip.kind {
628                ClipKind::Film { .. } if (clip.speed - 1.0).abs() > 0.01 => {
629                    format!("{} {} · {}×", labels.film_clip, index + 1, clip.speed)
630                }
631                ClipKind::Film { .. } => format!("{} {}", labels.film_clip, index + 1),
632                ClipKind::Title { title, .. } => title.clone(),
633            };
634            let moment = if start == 0 { movie.poster_ms() } else { start + 1 };
635            let item = TimelineItem::new(index as u64, start, clip.duration_ms(), label)
636                .tone(if matches!(clip.kind, ClipKind::Title { .. }) { Tone::Success } else { Tone::Accent })
637                .selected(selection == Selection::Clip(index));
638            // What a stretch of film starts with, in vectors like the rest. A card
639            // has its words on the block already, and they would not fit in a thumbnail.
640            if matches!(clip.kind, ClipKind::Film { .. }) {
641                item.leading(Composition::new(movie.clone(), moment).width(THUMBNAIL)).trimmable()
642            } else {
643                item
644            }
645        })
646        .collect();
647    // The steps recorded with the film, beside those added here.
648    let added: Vec<&str> = project
649        .overlays
650        .iter()
651        .filter_map(|overlay| match &overlay.kind {
652            OverlayKind::Hotspot { title, .. } => Some(title.as_str()),
653            _ => None,
654        })
655        .collect();
656    let mut steps = overlays_in(Row::Interaction);
657    for (index, spot) in movie.hotspots().iter().enumerate() {
658        if !added.contains(&spot.title.as_str()) {
659            let length = spot.until_ms.saturating_sub(spot.from_ms).max(STEP_MS);
660            steps.push(
661                TimelineItem::new(FILM_STEP_IDS + index as u64, spot.from_ms, length, spot.title.clone())
662                    .tone(Tone::Warning),
663            );
664        }
665    }
666    let starts: Vec<u64> = movie.hotspots().iter().map(|spot| spot.from_ms).collect();
667    let (select, seek, edit) = (state.clone(), state.clone(), state.clone());
668    Timeline::new(vec![
669        TimelineTrack::new(labels.tab_interaction.clone(), OVERLAY_ROW, steps),
670        TimelineTrack::new(labels.tab_annotations.clone(), OVERLAY_ROW, overlays_in(Row::Annotations)),
671        TimelineTrack::new(labels.tab_screen.clone(), OVERLAY_ROW, overlays_in(Row::Screen)),
672        TimelineTrack::new(labels.tab_pointer.clone(), OVERLAY_ROW, overlays_in(Row::Pointer)),
673        TimelineTrack::new(labels.track_film.clone(), CLIP_ROW, clips),
674    ])
675    .markers(
676        movie.chapters().iter().map(|c| TimelineMarker { at_ms: c.at_ms, title: c.title.clone() }).collect(),
677    )
678    .position_ms(state.position_ms())
679    .pixels_per_second(state.inner.density.get())
680    .heads_width(TRACK_HEADS)
681    .on_seek(move |ms| seek.seek(ms))
682    .on_select(move |id| {
683        if id >= FILM_STEP_IDS {
684            if let Some(at) = usize::try_from(id - FILM_STEP_IDS).ok().and_then(|i| starts.get(i)) {
685                select.seek(*at);
686            }
687        } else if id >= OVERLAY_IDS {
688            select.select_overlay(id - OVERLAY_IDS);
689        } else {
690            select.select_clip(id as usize);
691        }
692    })
693    .on_edit(move |change| on_timeline_edit(&edit, change))
694}
695
696/// The selected region on the canvas, to drag and resize.
697struct RegionHandle {
698    state: EditorState,
699    id: u64,
700    frame: Rect,
701    scale: f32,
702}
703
704impl View for RegionHandle {
705    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
706        let theme = *cx.theme();
707        let color = theme.color(Semantic::FocusRing);
708        let (state, id, scale) = (self.state.clone(), self.id, self.scale);
709        let size = HANDLE * 1.5;
710        let corner = cx.container(
711            LayoutStyle {
712                absolute: Some((self.frame.width() - size, self.frame.height() - size)),
713                width: Some(size),
714                height: Some(size),
715                ..LayoutStyle::stack(Axis::Vertical)
716            },
717            &[],
718            Paint::Quad(QuadPaint { corner_radius: 2.0, ..QuadPaint::new(color) }),
719        );
720        cx.semantics(corner, Semantics::hidden());
721        let start = cx.use_state(|| Rc::new(Cell::new(None::<(Point, Region)>))).get();
722        let resize = state.clone();
723        cx.on_drag(
724            corner,
725            Rc::new(move |drag: Drag| {
726                if let Some(mut region) = resize.selected_overlay().and_then(|o| region_of(&o.kind)) {
727                    if drag.phase == DragPhase::Start {
728                        *resize.inner.last_edit.borrow_mut() = None;
729                    }
730                    region.width = ((drag.position.x - drag.container.x()) / scale).max(16.0);
731                    region.height = ((drag.position.y - drag.container.y()) / scale).max(16.0);
732                    resize.change_overlay(id, "region", |overlay| set_region(&mut overlay.kind, region));
733                }
734            }),
735        );
736        cx.cursor(corner, CursorIcon::ColResize);
737        let node = cx.container(
738            LayoutStyle {
739                absolute: Some((self.frame.x(), self.frame.y())),
740                width: Some(self.frame.width()),
741                height: Some(self.frame.height()),
742                ..LayoutStyle::stack(Axis::Vertical)
743            },
744            &[corner],
745            Paint::Quad(QuadPaint {
746                fill: Color::TRANSPARENT.into(),
747                corner_radius: 0.0,
748                border: Some(Border { width: 2.0, color }),
749                shadow: None,
750            }),
751        );
752        cx.on_drag(
753            node,
754            Rc::new(move |drag: Drag| {
755                let Some(region) = state.selected_overlay().and_then(|o| region_of(&o.kind)) else {
756                    return;
757                };
758                if drag.phase == DragPhase::Start {
759                    start.set(Some((drag.position, region)));
760                    *state.inner.last_edit.borrow_mut() = None;
761                    return;
762                }
763                if let Some((from, was)) = start.get() {
764                    let moved = Region {
765                        x: was.x + (drag.position.x - from.x) / scale,
766                        y: was.y + (drag.position.y - from.y) / scale,
767                        ..was
768                    };
769                    state.change_overlay(id, "region", |overlay| set_region(&mut overlay.kind, moved));
770                }
771            }),
772        );
773        cx.cursor(node, CursorIcon::Pointer);
774        cx.semantics(node, Semantics::hidden());
775        node
776    }
777}
778
779/// The canvas: the moment under the playhead, and the region being placed.
780struct Canvas {
781    state: EditorState,
782    width: f32,
783}
784
785impl View for Canvas {
786    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
787        let state = &self.state;
788        let movie = state.movie();
789        let logical = movie.logical_size();
790        let height = self.width * logical.height / logical.width.max(1.0);
791        let selected = state.selected_overlay();
792        let placing_zoom = matches!(selected.as_ref().map(|o| &o.kind), Some(OverlayKind::Zoom { .. }));
793        let at = state.position_ms();
794        let mut film = Composition::new(movie.clone(), at).width(self.width);
795        if placing_zoom {
796            film = film.ignoring_zoom();
797        }
798        let mut children = vec![cx.child(&film)];
799        if let Some(overlay) = &selected
800            && let Some(region) = region_of(&overlay.kind)
801            && overlay.covers(at)
802        {
803            let camera = camera_at(&movie, at, self.width, placing_zoom);
804            children.push(cx.child(&RegionHandle {
805                state: state.clone(),
806                id: overlay.id,
807                frame: camera.region(region),
808                scale: camera.scale(),
809            }));
810        }
811        let node = cx.container(
812            LayoutStyle {
813                width: Some(self.width),
814                height: Some(height),
815                overflow_hidden: true,
816                ..LayoutStyle::stack(Axis::Vertical)
817            },
818            &children,
819            Paint::None,
820        );
821        cx.clip(node);
822        node
823    }
824}
825
826/// What runs when the save button is clicked.
827type SaveHandler = Rc<dyn Fn(&Movie)>;
828
829/// An editor for a film, in the style of screen-recording editors.
830///
831/// Tools are on the left, with what each offers; the canvas in the middle; what
832/// is selected on the right; and the tracks along the bottom. Clips are cut at
833/// the playhead, trimmed at their edges and deleted, closing the gap. Effects sit
834/// on tracks over them, where they slide and stretch, and regions are dragged on
835/// the canvas. Nothing changes the film, every edit can be undone, and the film
836/// can be tried at any time with the same player people will watch it in.
837///
838/// ```no_run
839/// use ravenrs_movie::{EditorState, Movie, MovieEditor};
840///
841/// let film = Movie::from_bytes(&std::fs::read("manual.ravenmovie").unwrap_or_default())?;
842/// let state = EditorState::new(film);
843/// let editor = MovieEditor::new(state).title("Getting started").on_save(|film: &Movie| {
844///     let _ = std::fs::write("manual.ravenmovie", film.to_bytes());
845/// });
846/// # Ok::<(), ravenrs_movie::MovieError>(())
847/// ```
848#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
849pub struct MovieEditor {
850    state: EditorState,
851    labels: EditorLabels,
852    player_labels: MovieLabels,
853    title: String,
854    on_save: Option<SaveHandler>,
855    area: Option<Size>,
856}
857
858impl std::fmt::Debug for MovieEditor {
859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        f.debug_struct("MovieEditor").field("state", &self.state).finish_non_exhaustive()
861    }
862}
863
864/// `2:05`.
865fn clock(ms: u64) -> String {
866    format!("{}:{:02}", ms / 60_000, ms / 1000 % 60)
867}
868
869fn panel_title(text: &str) -> Text {
870    Text::new(text.to_owned()).style(TextStyle::Headline)
871}
872
873fn muted(text: &str) -> Text {
874    Text::new(text.to_owned()).style(TextStyle::Callout).foreground(Semantic::InkMuted)
875}
876
877#[cfg_attr(feature = "live", ravenrs_live::expose)]
878impl MovieEditor {
879    /// An editor over `state`.
880    pub fn new(state: EditorState) -> Self {
881        Self {
882            state,
883            labels: EditorLabels::default(),
884            player_labels: MovieLabels::default(),
885            title: String::new(),
886            on_save: None,
887            area: None,
888        }
889    }
890
891    /// The words it shows.
892    pub fn labels(mut self, labels: EditorLabels) -> Self {
893        self.labels = labels;
894        self
895    }
896
897    /// The words of the player it tries the film in.
898    pub fn player_labels(mut self, labels: MovieLabels) -> Self {
899        self.player_labels = labels;
900        self
901    }
902
903    /// The film's name, in the top bar.
904    pub fn title(mut self, title: impl Into<String>) -> Self {
905        self.title = title.into();
906        self
907    }
908
909    /// Shows a save button that runs `handler` with the film and its edits.
910    pub fn on_save(mut self, handler: impl Fn(&Movie) + 'static) -> Self {
911        self.on_save = Some(Rc::new(handler));
912        self
913    }
914
915    /// The room it has, for when it is not the whole window.
916    pub fn area(mut self, area: Size) -> Self {
917        self.area = Some(area);
918        self
919    }
920
921    fn top_bar(&self) -> impl View + use<> {
922        let labels = &self.labels;
923        let state = &self.state;
924        let movie = state.movie();
925        let (undo, redo, trying) = (state.clone(), state.clone(), state.clone());
926        let is_trying = state.inner.trying.get();
927        let can_undo = !state.inner.undo.borrow().is_empty();
928        let can_redo = !state.inner.redo.borrow().is_empty();
929        let kilobytes = movie.to_bytes().len() / 1024;
930        let save = self.on_save.clone().map(|save| {
931            let state = state.clone();
932            Button::new(labels.save.clone())
933                .icon(IconName::Save)
934                .size(ButtonSize::Small)
935                .on_click(move || save(&state.movie()))
936        });
937        HStack::new((
938            VStack::new((
939                (!self.title.is_empty()).then(|| Text::new(self.title.clone()).font_weight(Weight::SEMIBOLD)),
940                muted(&format!("{kilobytes} KB · {}", clock(movie.duration_ms()))),
941            ))
942            .spacing(0.0),
943            IconButton::new(IconName::Undo2)
944                .label(labels.undo.clone())
945                .disabled(!can_undo)
946                .on_click(move || undo.undo()),
947            IconButton::new(IconName::Redo2)
948                .label(labels.redo.clone())
949                .disabled(!can_redo)
950                .on_click(move || redo.redo()),
951            Spacer::new(),
952            Button::new(if is_trying { labels.back_to_editing.clone() } else { labels.try_it.clone() })
953                .icon(if is_trying { IconName::Pencil } else { IconName::Play })
954                .style(ButtonStyle::Secondary)
955                .size(ButtonSize::Small)
956                .on_click(move || {
957                    trying.inner.trying.set(!trying.inner.trying.get());
958                    trying.inner.running.set(None);
959                    trying.touch();
960                }),
961            save,
962        ))
963        .spacing(8.0)
964        .align(Align::Center)
965        .padding((0.0, 12.0))
966        .frame(None, Some(TOP_BAR))
967        .background(Semantic::Surface)
968    }
969
970    fn rail(&self) -> impl View + use<> {
971        let labels = &self.labels;
972        const TABS: [Tab; 5] =
973            [Tab::Annotations, Tab::Screen, Tab::Transitions, Tab::Pointer, Tab::Interaction];
974        let (read, write) = (self.state.clone(), self.state.clone());
975        let selected = Binding::new(
976            move || TABS.iter().position(|tab| *tab == read.inner.tab.get()).unwrap_or(0),
977            move |index: usize| {
978                write.inner.tab.set(TABS.get(index).copied().unwrap_or(Tab::Annotations));
979                write.touch();
980            },
981        );
982        ToolRail::new(
983            selected,
984            [
985                (IconName::MessageSquare, labels.tab_annotations.clone()),
986                (IconName::Maximize2, labels.tab_screen.clone()),
987                (IconName::Layers, labels.tab_transitions.clone()),
988                (IconName::MousePointer, labels.tab_pointer.clone()),
989                (IconName::Crosshair, labels.tab_interaction.clone()),
990            ],
991        )
992        .width(RAIL_WIDTH)
993        .background(Semantic::Surface)
994        .frame(Some(RAIL_WIDTH), None)
995    }
996
997    fn library(&self) -> impl View + use<> {
998        let labels = &self.labels;
999        let logical = self.state.movie().logical_size();
1000        let middle = Region::new(
1001            logical.width * 0.3,
1002            logical.height * 0.3,
1003            logical.width * 0.4,
1004            logical.height * 0.25,
1005        );
1006        let note = Region::new(
1007            logical.width * 0.62,
1008            logical.height * 0.12,
1009            logical.width * 0.3,
1010            logical.height * 0.12,
1011        );
1012        let text = labels.new_text.clone();
1013        let tab = self.state.inner.tab.get();
1014        let kinds: Vec<(String, IconName, OverlayKind)> = match tab {
1015            Tab::Annotations => vec![
1016                (
1017                    labels.callout.clone(),
1018                    IconName::MessageSquare,
1019                    OverlayKind::Callout {
1020                        text: text.clone(),
1021                        region: note,
1022                        style: CalloutStyle::Balloon,
1023                        soft: true,
1024                    },
1025                ),
1026                (labels.caption.clone(), IconName::Type, OverlayKind::Caption { text: text.clone() }),
1027            ],
1028            Tab::Screen => vec![
1029                (labels.spotlight.clone(), IconName::Sparkles, OverlayKind::Spotlight { region: middle }),
1030                (labels.zoom.clone(), IconName::ZoomIn, OverlayKind::Zoom { region: middle }),
1031                (labels.hide.clone(), IconName::EyeOff, OverlayKind::Hide { region: middle }),
1032            ],
1033            Tab::Transitions => vec![
1034                (labels.fade_in.clone(), IconName::Eye, OverlayKind::FadeIn),
1035                (labels.fade_out.clone(), IconName::EyeOff, OverlayKind::FadeOut),
1036            ],
1037            Tab::Pointer => {
1038                vec![(
1039                    labels.pointer_highlight.clone(),
1040                    IconName::MousePointer,
1041                    OverlayKind::PointerHighlight,
1042                )]
1043            }
1044            Tab::Interaction => vec![(
1045                labels.hotspot.clone(),
1046                IconName::Crosshair,
1047                OverlayKind::Hotspot { region: note, title: labels.hotspot.clone(), text, stop: true },
1048            )],
1049        };
1050        let title = match tab {
1051            Tab::Annotations => &labels.tab_annotations,
1052            Tab::Screen => &labels.tab_screen,
1053            Tab::Transitions => &labels.tab_transitions,
1054            Tab::Pointer => &labels.tab_pointer,
1055            Tab::Interaction => &labels.tab_interaction,
1056        };
1057        let mut rows: Vec<ListItem> = Vec::with_capacity(kinds.len() + 1);
1058        if tab == Tab::Annotations {
1059            let (titles, first_words) = (self.state.clone(), labels.new_text.clone());
1060            rows.push(
1061                ListItem::new(labels.title_card.clone())
1062                    .icon(IconName::Layers)
1063                    .on_click(move || titles.add_title(&first_words)),
1064            );
1065        }
1066        for (name, icon, kind) in kinds {
1067            let state = self.state.clone();
1068            rows.push(ListItem::new(name).icon(icon).on_click(move || {
1069                state.add_overlay(kind.clone());
1070            }));
1071        }
1072        VStack::new((panel_title(title), ScrollView::new((List::new(rows),)), muted(&labels.library_hint)))
1073            .spacing(12.0)
1074            .align(Align::Stretch)
1075            .padding(12.0)
1076            .background(Semantic::Surface)
1077            .frame(Some(LIBRARY_WIDTH), None)
1078    }
1079
1080    fn properties(&self) -> impl View + use<> {
1081        let labels = &self.labels;
1082        let state = &self.state;
1083        let mut items: Vec<AnyView> = Vec::new();
1084        if let Some(overlay) = state.selected_overlay() {
1085            let id = overlay.id;
1086            items.push(Box::new(panel_title(label_of(&overlay.kind, labels))));
1087            let has_text = matches!(
1088                overlay.kind,
1089                OverlayKind::Callout { .. } | OverlayKind::Caption { .. } | OverlayKind::Hotspot { .. }
1090            );
1091            if has_text {
1092                let (read, write) = (state.clone(), state.clone());
1093                let text = Binding::new(
1094                    move || match read.selected_overlay().map(|o| o.kind) {
1095                        Some(
1096                            OverlayKind::Callout { text, .. }
1097                            | OverlayKind::Caption { text }
1098                            | OverlayKind::Hotspot { text, .. },
1099                        ) => text,
1100                        _ => String::new(),
1101                    },
1102                    move |new: String| {
1103                        write.change_overlay(id, "text", |overlay| match &mut overlay.kind {
1104                            OverlayKind::Callout { text, .. }
1105                            | OverlayKind::Caption { text }
1106                            | OverlayKind::Hotspot { text, .. } => *text = new,
1107                            _ => {}
1108                        });
1109                    },
1110                );
1111                items.push(Box::new(muted(&labels.text)));
1112                items.push(Box::new(TextField::new(text)));
1113            }
1114            if let OverlayKind::Callout { style, .. } = &overlay.kind {
1115                let (balloon, tag) = (state.clone(), state.clone());
1116                let pick = |state: EditorState, chosen: CalloutStyle| {
1117                    move || {
1118                        state.change_overlay(id, "style", |overlay| {
1119                            if let OverlayKind::Callout { style, .. } = &mut overlay.kind {
1120                                *style = chosen;
1121                            }
1122                        });
1123                    }
1124                };
1125                let chosen = |this: CalloutStyle| {
1126                    if *style == this { ButtonStyle::Primary } else { ButtonStyle::Secondary }
1127                };
1128                items.push(Box::new(muted(&labels.style)));
1129                items.push(Box::new(
1130                    HStack::new((
1131                        Button::new(labels.style_balloon.clone())
1132                            .size(ButtonSize::Small)
1133                            .style(chosen(CalloutStyle::Balloon))
1134                            .on_click(pick(balloon, CalloutStyle::Balloon)),
1135                        Button::new(labels.style_label.clone())
1136                            .size(ButtonSize::Small)
1137                            .style(chosen(CalloutStyle::Label))
1138                            .on_click(pick(tag, CalloutStyle::Label)),
1139                    ))
1140                    .spacing(4.0),
1141                ));
1142                let (read, write) = (state.clone(), state.clone());
1143                let soft = Binding::new(
1144                    move || {
1145                        matches!(
1146                            read.selected_overlay().map(|o| o.kind),
1147                            Some(OverlayKind::Callout { soft: true, .. })
1148                        )
1149                    },
1150                    move |new: bool| {
1151                        write.change_overlay(id, "soft", |overlay| {
1152                            if let OverlayKind::Callout { soft, .. } = &mut overlay.kind {
1153                                *soft = new;
1154                            }
1155                        });
1156                    },
1157                );
1158                items.push(Box::new(Toggle::new(soft).label(labels.soft.clone())));
1159            }
1160            if matches!(overlay.kind, OverlayKind::Hotspot { .. }) {
1161                let (read, write) = (state.clone(), state.clone());
1162                let stop = Binding::new(
1163                    move || {
1164                        matches!(
1165                            read.selected_overlay().map(|o| o.kind),
1166                            Some(OverlayKind::Hotspot { stop: true, .. })
1167                        )
1168                    },
1169                    move |new: bool| {
1170                        write.change_overlay(id, "stop", |overlay| {
1171                            if let OverlayKind::Hotspot { stop, .. } = &mut overlay.kind {
1172                                *stop = new;
1173                            }
1174                        });
1175                    },
1176                );
1177                items.push(Box::new(Toggle::new(stop).label(labels.wait_for_click.clone())));
1178            }
1179            let total = state.movie().duration_ms().max(1) as f32 / 1000.0;
1180            let (read, write) = (state.clone(), state.clone());
1181            let start = Binding::new(
1182                move || read.selected_overlay().map_or(0.0, |o| o.start_ms as f32 / 1000.0),
1183                move |seconds: f32| {
1184                    write.change_overlay(id, "start", |o| o.start_ms = (seconds * 1000.0) as u64)
1185                },
1186            );
1187            items.push(Box::new(muted(&format!("{}: {}", labels.start, clock(overlay.start_ms)))));
1188            items.push(Box::new(Slider::new(start).range(0.0, total).step(0.1).label(labels.start.clone())));
1189            let (read, write) = (state.clone(), state.clone());
1190            let duration = Binding::new(
1191                move || read.selected_overlay().map_or(1.0, |o| o.duration_ms as f32 / 1000.0),
1192                move |seconds: f32| {
1193                    write.change_overlay(id, "duration", |o| o.duration_ms = (seconds * 1000.0) as u64)
1194                },
1195            );
1196            items.push(Box::new(muted(&format!(
1197                "{}: {:.1} s",
1198                labels.duration,
1199                overlay.duration_ms as f32 / 1000.0
1200            ))));
1201            items.push(Box::new(
1202                Slider::new(duration).range(0.5, total.max(1.0)).step(0.1).label(labels.duration.clone()),
1203            ));
1204            if region_of(&overlay.kind).is_some() {
1205                let axis = |state: &EditorState, vertical: bool, name: &str| {
1206                    let (read, write) = (state.clone(), state.clone());
1207                    let value = Binding::new(
1208                        move || {
1209                            let region = read.selected_overlay().and_then(|o| region_of(&o.kind));
1210                            region.map_or(0, |r| if vertical { r.y as i32 } else { r.x as i32 })
1211                        },
1212                        move |new: i32| {
1213                            write.change_overlay(id, "region", |overlay| {
1214                                if let Some(mut region) = region_of(&overlay.kind) {
1215                                    if vertical {
1216                                        region.y = new as f32;
1217                                    } else {
1218                                        region.x = new as f32;
1219                                    }
1220                                    set_region(&mut overlay.kind, region);
1221                                }
1222                            });
1223                        },
1224                    );
1225                    Stepper::new(value).range(0, 8000).label(name.to_owned())
1226                };
1227                items.push(Box::new(axis(state, false, "X")));
1228                items.push(Box::new(axis(state, true, "Y")));
1229                items.push(Box::new(muted(&labels.drag_hint)));
1230            }
1231        } else if let Some((index, clip)) = state.selected_clip() {
1232            match &clip.kind {
1233                ClipKind::Film { .. } => {
1234                    items.push(Box::new(panel_title(&labels.film_clip)));
1235                    items.push(Box::new(muted(&clock(clip.duration_ms()))));
1236                }
1237                ClipKind::Title { .. } => {
1238                    items.push(Box::new(panel_title(&labels.title_card)));
1239                    let words = |second: bool| {
1240                        let (read, write) = (state.clone(), state.clone());
1241                        Binding::new(
1242                            move || match read.selected_clip().map(|(_, clip)| clip.kind) {
1243                                Some(ClipKind::Title { title, subtitle, .. }) => {
1244                                    if second {
1245                                        subtitle
1246                                    } else {
1247                                        title
1248                                    }
1249                                }
1250                                _ => String::new(),
1251                            },
1252                            move |new: String| {
1253                                write.edit(Some(&format!("card:{index}:{second}")), |project| {
1254                                    if let Some(ClipKind::Title { title, subtitle, .. }) =
1255                                        project.clips.get_mut(index).map(|clip| &mut clip.kind)
1256                                    {
1257                                        *(if second { subtitle } else { title }) = new;
1258                                    }
1259                                });
1260                            },
1261                        )
1262                    };
1263                    items.push(Box::new(muted(&labels.text)));
1264                    items.push(Box::new(TextField::new(words(false))));
1265                    items.push(Box::new(muted(&labels.subtitle)));
1266                    items.push(Box::new(TextField::new(words(true))));
1267                }
1268            }
1269        } else {
1270            items.push(Box::new(panel_title(&labels.properties)));
1271            items.push(Box::new(muted(&labels.nothing_selected)));
1272        }
1273        ScrollView::new((VStack::new(items).spacing(10.0).align(Align::Stretch),))
1274            .padding(12.0)
1275            .background(Semantic::Surface)
1276            .frame(Some(PROPERTIES_WIDTH), None)
1277    }
1278
1279    fn transport(&self) -> impl View + use<> {
1280        let labels = &self.labels;
1281        let state = &self.state;
1282        let running = state.inner.running.get().is_some();
1283        let movie = state.movie();
1284        let at = state.position_ms();
1285        let chapter_to = |target: Option<u64>| {
1286            let seek = state.clone();
1287            move || {
1288                if let Some(ms) = target {
1289                    seek.seek(ms);
1290                }
1291            }
1292        };
1293        let previous = movie.chapters().iter().rev().find(|c| c.at_ms < at).map(|c| c.at_ms);
1294        let next = movie.chapters().iter().find(|c| c.at_ms > at).map(|c| c.at_ms);
1295        let toggle = state.clone();
1296        HStack::new((
1297            IconButton::new(IconName::ChevronLeft)
1298                .label(labels.previous_chapter.clone())
1299                .disabled(previous.is_none())
1300                .on_click(chapter_to(previous)),
1301            Button::new(if running { labels.pause.clone() } else { labels.play.clone() })
1302                .icon(if running { IconName::Pause } else { IconName::Play })
1303                .size(ButtonSize::Small)
1304                .on_click(move || toggle.toggle_playing()),
1305            IconButton::new(IconName::ChevronRight)
1306                .label(labels.next_chapter.clone())
1307                .disabled(next.is_none())
1308                .on_click(chapter_to(next)),
1309            Text::new(format!("{} / {}", clock(at), clock(movie.duration_ms()))).style(TextStyle::Callout),
1310        ))
1311        .spacing(6.0)
1312        .align(Align::Center)
1313        .frame(None, Some(TRANSPORT))
1314    }
1315
1316    fn timeline_bar(&self) -> impl View + use<> {
1317        let labels = &self.labels;
1318        let state = &self.state;
1319        let (split, delete, chapter) = (state.clone(), state.clone(), state.clone());
1320        let new_chapter = labels.new_chapter.clone();
1321        let selected = state.inner.selection.get() != Selection::Nothing;
1322        let speed_clip = state.selected_clip().filter(|(_, clip)| matches!(clip.kind, ClipKind::Film { .. }));
1323        let (read, write) = (state.clone(), state.clone());
1324        let speed = Binding::new(
1325            move || {
1326                let speed = read.selected_clip().map_or(1.0, |(_, clip)| clip.speed);
1327                SPEEDS.iter().position(|s| (*s - speed).abs() < 0.01).unwrap_or(1)
1328            },
1329            move |choice: usize| write.set_speed(SPEEDS.get(choice).copied().unwrap_or(1.0)),
1330        );
1331        let (read, write) = (state.clone(), state.clone());
1332        let density = Binding::new(
1333            move || read.inner.density.get(),
1334            move |value: f32| {
1335                write.inner.density.set(value);
1336                write.touch();
1337            },
1338        );
1339        let tool = |label: &str, icon: IconName| {
1340            Button::new(label.to_owned()).icon(icon).style(ButtonStyle::Ghost).size(ButtonSize::Small)
1341        };
1342        HStack::new((
1343            tool(&labels.split, IconName::Scissors).on_click(move || split.split()),
1344            tool(&labels.delete, IconName::Trash2)
1345                .disabled(!selected)
1346                .on_click(move || delete.delete_selected()),
1347            tool(&labels.chapter, IconName::Flag).on_click(move || chapter.add_chapter(&new_chapter)),
1348            speed_clip.map(|_| {
1349                HStack::new((
1350                    muted(&labels.speed),
1351                    Select::new(speed, SPEEDS.iter().map(|s| format!("{s}×"))).width(90.0),
1352                ))
1353                .spacing(6.0)
1354                .align(Align::Center)
1355            }),
1356            Spacer::new(),
1357            Slider::new(density).range(5.0, 200.0).step(1.0).label(labels.timeline_zoom.clone()).width(130.0),
1358        ))
1359        .spacing(4.0)
1360        .align(Align::Center)
1361        .padding((0.0, 12.0))
1362        .frame(None, Some(TIMELINE_BAR))
1363    }
1364}
1365
1366impl View for MovieEditor {
1367    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1368        let _ = self.state.revision.get();
1369        let now = cx.now();
1370        self.state.advance(now);
1371        if self.state.inner.running.get().is_some() {
1372            cx.wake_at(now + FRAME);
1373        }
1374        let (undo, redo, delete) = (self.state.clone(), self.state.clone(), self.state.clone());
1375        cx.shortcut(Key::Character("z".into()), true, Rc::new(move || undo.undo()));
1376        cx.shortcut(Key::Character("y".into()), true, Rc::new(move || redo.redo()));
1377        cx.shortcut(Key::Delete, false, Rc::new(move || delete.delete_selected()));
1378
1379        let viewport = self.area.unwrap_or_else(|| cx.viewport());
1380        let movie = self.state.movie();
1381        let logical = movie.logical_size();
1382        let timeline = timeline(&self.state, &self.labels);
1383        let timeline_height = timeline.total_height() + TIMELINE_BAR;
1384        let middle_height = (viewport.height - TOP_BAR - timeline_height).max(200.0);
1385        let by_width = (viewport.width - RAIL_WIDTH - LIBRARY_WIDTH - PROPERTIES_WIDTH - 48.0).max(240.0);
1386        let by_height =
1387            (middle_height - TRANSPORT - 16.0).max(120.0) * logical.width / logical.height.max(1.0);
1388        let canvas_width = by_width.min(by_height);
1389
1390        let center: AnyView = if self.state.inner.trying.get() {
1391            Box::new(
1392                ScrollView::new((MoviePlayer::new(movie.clone())
1393                    .labels(self.player_labels.clone())
1394                    .width(by_width.max(480.0)),))
1395                .padding(8.0)
1396                .grow(1.0),
1397            )
1398        } else {
1399            Box::new(
1400                VStack::new((
1401                    Spacer::new(),
1402                    Canvas { state: self.state.clone(), width: canvas_width },
1403                    Spacer::new(),
1404                    self.transport(),
1405                ))
1406                .spacing(0.0)
1407                .align(Align::Center)
1408                .grow(1.0),
1409            )
1410        };
1411        let tree = VStack::new((
1412            self.top_bar(),
1413            Divider::horizontal(),
1414            HStack::new((self.rail(), self.library(), center, self.properties()))
1415                .spacing(0.0)
1416                .align(Align::Stretch)
1417                .grow(1.0),
1418            Divider::horizontal(),
1419            VStack::new((self.timeline_bar(), timeline))
1420                .spacing(0.0)
1421                .align(Align::Stretch)
1422                .background(Semantic::Surface),
1423        ))
1424        .spacing(0.0)
1425        .align(Align::Stretch)
1426        .grow(1.0);
1427        cx.child(&tree)
1428    }
1429}