simple_editor\ui/
inspector.rs

1//! Inspector panel. Nothing selected → Project panel (name, width, height, fps, duration, Save / Export /
2//! Export Frame buttons, export settings summary). One clip selected →
3//! clip name, enabled, colour label, timing (start / duration / source in), retime readout (Ctrl+R), and:
4//!  * visual clips: Position X/Y, Scale, Rotation (DragValue), Opacity (0-100% slider) — each row + a
5//!    diamond keyframe toggle (`Animated::toggle_key(clip.local(playhead))`, highlighted when a key exists at the
6//!    playhead) + "clear keys"; edits go through `Animated::set_at(local_t, v)` so keyframed props get
7//!    keys; Blend mode combo.
8//!  * audio clips: Volume (dB slider mapped to linear gain) and Pan (-1..1 slider) with the same keyframe
9//!    controls, plus fade in/out lengths.
10//!  * effects summary (enable toggles, remove) and transitions touching the clip.
11//!  * the clip's asset: "Used in: N clips", editable description and tags.
12//!  * text clips: multiline text, font family combo (`fonts`) + "Import font…" (handed to the app via
13//!    `take_pending_font_import`), size, bold/italic, colour pickers (fill, outline, drop shadow, box),
14//!    outline width, drop shadow on/off + x/y/blur, alignment, line/letter spacing, box padding.
15//!  * sequence clips: the sequence's name + "Open sequence" (handed to the app via `take_open_sequence`).
16//!  * round 3: the clip's label from `Project.labels` (plus a compact "Edit labels…" editor), its mask
17//!    (shape, position, radius, rotation, feather, expand, opacity, invert, "Edit in viewport" — the same
18//!    grid the Effects panel shows under a per-effect mask), "Open node editor", the shape style
19//!    of Shape clips (kind, fill/stroke, width, sides, corner, draw rate, page), clip markers (add /
20//!    remove), an audio bus override, and a note on Adjustment clips.
21//! Call `undo(project)` once per gesture (on `drag_started()` / first `changed()` of a widget) before mutating.
22//! Returns true if the project changed.
23
24use crate::hotkeys::Action;
25use crate::model::{Animated, BlendMode, ClipKind, Id, Label, Mask, Project, ShapeKind, ShapeStyle};
26use crate::settings::Settings;
27use crate::theme::Palette;
28use crate::ui::markers_ui::x_button;
29use crate::ui::{edit_start, key_buttons, mask_grid, timecode, Gesture};
30use eframe::egui::{self, DragValue, Grid, Response, RichText, Slider};
31use std::cell::RefCell;
32
33/// Test-only: remember a widget rect so headless tests can click the real button.
34#[cfg(test)]
35fn mark(ui: &egui::Ui, name: &str, r: &Response) {
36    ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("insp", name.to_string())), r.rect));
37}
38#[cfg(not(test))]
39fn mark(_ui: &egui::Ui, _name: &str, _r: &Response) {}
40
41fn gain_to_db(g: f64) -> f64 {
42    if g <= 0.0 {
43        -60.0
44    } else {
45        (20.0 * g.log10()).max(-60.0)
46    }
47}
48
49fn db_to_gain(db: f64) -> f64 {
50    if db <= -60.0 {
51        0.0
52    } else {
53        10f64.powf(db / 20.0)
54    }
55}
56
57// Hand-offs to the app (the show() signature has no room for these; the app polls them each frame).
58thread_local! {
59    static PENDING_FONT: RefCell<Option<String>> = const { RefCell::new(None) };
60    static OPEN_SEQUENCE: RefCell<Option<Id>> = const { RefCell::new(None) };
61    static EDIT_MASK: RefCell<Option<Id>> = const { RefCell::new(None) };
62    static OPEN_NODES: RefCell<Option<Id>> = const { RefCell::new(None) };
63    static UNLINK_NODES: RefCell<Option<Id>> = const { RefCell::new(None) };
64    /// Project-panel button (Save / Export… / Export Frame… / edit export settings) — the app runs it
65    /// through the same Action dispatch the menus use.
66    static PENDING_ACTION: RefCell<Option<Action>> = const { RefCell::new(None) };
67}
68
69/// Clip the user asked to turn back into a plain effect stack (`Project::unlink_graph`).
70pub fn take_unlink_nodes() -> Option<Id> {
71    UNLINK_NODES.with(|p| p.borrow_mut().take())
72}
73
74/// Ask for it from elsewhere — the effects panel offers the same escape hatch.
75pub fn ask_unlink_nodes(id: Id) {
76    UNLINK_NODES.with(|p| *p.borrow_mut() = Some(id));
77}
78
79/// Clip whose mask the user wants to draw in the viewport ("Edit in viewport") — the app switches the
80/// active tool to a mask tool and points the preview at this clip.
81pub fn take_edit_mask() -> Option<Id> {
82    EDIT_MASK.with(|p| p.borrow_mut().take())
83}
84
85/// Clip the user asked to open in the node editor.
86pub fn take_open_nodes() -> Option<Id> {
87    OPEN_NODES.with(|p| p.borrow_mut().take())
88}
89
90/// Font file (.ttf/.otf) the user picked with "Import font…" — the app adds it to settings.user_fonts
91/// and reloads the text rasterizer.
92pub fn take_pending_font_import() -> Option<String> {
93    PENDING_FONT.with(|p| p.borrow_mut().take())
94}
95
96/// Sequence the user asked to open from the inspector.
97pub fn take_open_sequence() -> Option<Id> {
98    OPEN_SEQUENCE.with(|p| p.borrow_mut().take())
99}
100
101/// Action a project-panel button asked to run (Save / Export… / Export Frame…) — the app pushes it
102/// through the same `Action` dispatch the menus and hotkeys use.
103pub fn take_pending_action() -> Option<Action> {
104    PENDING_ACTION.with(|p| p.borrow_mut().take())
105}
106
107#[allow(clippy::too_many_arguments)]
108pub fn show(
109    ui: &mut egui::Ui,
110    project: &mut Project,
111    selection: &[Id],
112    sel_transitions: &[Id],
113    playhead: f64,
114    fonts: &[String],
115    palette: &Palette,
116    settings: &Settings,
117    undo: &mut dyn FnMut(&Project),
118) -> bool {
119    match selection.iter().find(|&&id| project.clip(id).is_some()) {
120        None if !sel_transitions.is_empty() => transition_section(ui, project, sel_transitions, undo),
121        None => project_section(ui, project, settings, undo),
122        Some(&id) => clip_section(ui, project, id, selection.len(), playhead, fonts, palette, undo),
123    }
124}
125
126/// Selected transitions (timeline bands): one set of editors; each field you change is written to
127/// every selected transition, fields you leave alone keep their per-transition values.
128fn transition_section(ui: &mut egui::Ui, project: &mut Project, ids: &[Id], undo: &mut dyn FnMut(&Project)) -> bool {
129    use crate::model::{Ease, TransitionKind};
130    let list: Vec<crate::model::Transition> = ids
131        .iter()
132        .filter_map(|&id| project.tracks.iter().flat_map(|t| &t.transitions).find(|t| t.id == id).cloned())
133        .collect();
134    let Some(first) = list.first().cloned() else {
135        ui.label("Select a clip or a transition");
136        return false;
137    };
138    let mut g = Gesture::default();
139    if list.len() == 1 {
140        ui.strong("Transition");
141    } else {
142        ui.strong(format!("Transitions ({} selected)", list.len()));
143    }
144    let mut e = first.clone();
145    Grid::new("insp_transition").num_columns(2).show(ui, |ui| {
146        ui.label("Kind");
147        egui::ComboBox::from_id_salt("insp_tr_kind").selected_text(e.kind.name()).show_ui(ui, |ui| {
148            for k in TransitionKind::ALL {
149                g.note(&ui.selectable_value(&mut e.kind, k, k.name()));
150            }
151        });
152        ui.end_row();
153        ui.label("Duration");
154        // clamp_existing_to_range(false): merely drawing an out-of-range value must not fake an edit
155        g.note(&ui.add(
156            DragValue::new(&mut e.duration).range(0.1..=5.0).clamp_existing_to_range(false).speed(0.02).suffix(" s"),
157        ));
158        ui.end_row();
159        if e.kind == TransitionKind::FadeToColor {
160            ui.label("Color");
161            g.note(&ui.color_edit_button_srgba_unmultiplied(&mut e.color));
162            ui.end_row();
163        }
164        if e.kind.has_direction() {
165            ui.label("Direction");
166            ui.horizontal(|ui| {
167                for (i, name) in ["Left", "Right", "Up", "Down"].iter().enumerate() {
168                    g.note(&ui.selectable_value(&mut e.direction, i as u8, *name));
169                }
170            });
171            ui.end_row();
172        }
173        ui.label("Ease");
174        egui::ComboBox::from_id_salt("insp_tr_ease").selected_text(e.ease.name()).show_ui(ui, |ui| {
175            for ea in Ease::ALL {
176                g.note(&ui.selectable_value(&mut e.ease, ea, ea.name()));
177            }
178        });
179        ui.end_row();
180    });
181    let mut removed = false;
182    let label = if list.len() == 1 { "Remove transition".into() } else { format!("Remove {} transitions", list.len()) };
183    let r = ui.button(label);
184    mark(ui, "tr_remove", &r);
185    if r.clicked() {
186        g.click();
187        removed = true;
188    }
189    if g.start {
190        undo(project);
191    }
192    if !g.changed {
193        return false;
194    }
195    if removed {
196        for &id in ids {
197            project.remove_transition(id);
198        }
199        return true;
200    }
201    for &id in ids {
202        if let Some(t) = project.transition_mut(id) {
203            if e.kind != first.kind {
204                t.kind = e.kind;
205            }
206            if e.duration != first.duration {
207                t.duration = e.duration;
208            }
209            if e.color != first.color {
210                t.color = e.color;
211            }
212            if e.direction != first.direction {
213                t.direction = e.direction;
214            }
215            if e.ease != first.ease {
216                t.ease = e.ease;
217            }
218        }
219    }
220    true
221}
222
223fn project_section(
224    ui: &mut egui::Ui,
225    project: &mut Project,
226    settings: &Settings,
227    undo: &mut dyn FnMut(&Project),
228) -> bool {
229    let mut edited = false;
230    ui.strong("Project");
231    Grid::new("inspector_project").num_columns(2).show(ui, |ui| {
232        ui.label("Name");
233        let mut name = project.name.clone(); // ponytail: per-frame clone so undo can snapshot before the write
234        let r = ui.text_edit_singleline(&mut name);
235        if r.gained_focus() {
236            undo(project); // once per visit to the field, not per keystroke
237        }
238        if r.changed() {
239            project.name = name;
240            edited = true;
241        }
242        ui.end_row();
243        let mut num = |ui: &mut egui::Ui,
244                       project: &mut Project,
245                       label: &str,
246                       get: fn(&Project) -> f64,
247                       set: fn(&mut Project, f64),
248                       range: std::ops::RangeInclusive<f64>,
249                       speed: f64| {
250            ui.label(label);
251            let mut v = get(project);
252            let r = ui.add(DragValue::new(&mut v).range(range).speed(speed));
253            if edit_start(&r) {
254                undo(project);
255            }
256            if r.changed() {
257                set(project, v);
258                edited = true;
259            }
260            ui.end_row();
261        };
262        num(ui, project, "Width", |p| p.width as f64, |p, v| p.width = v as u32, 16.0..=8192.0, 1.0);
263        num(ui, project, "Height", |p| p.height as f64, |p, v| p.height = v as u32, 16.0..=8192.0, 1.0);
264        num(ui, project, "FPS", |p| p.fps, |p, v| p.fps = v, 1.0..=240.0, 0.1);
265        ui.label("Duration");
266        ui.monospace(timecode(project.duration(), project.fps));
267        ui.end_row();
268    });
269
270    ui.separator();
271    ui.horizontal(|ui| {
272        let r = ui.button("Save");
273        mark(ui, "project_save", &r);
274        if r.clicked() {
275            PENDING_ACTION.with(|p| *p.borrow_mut() = Some(Action::Save));
276        }
277        let r = crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::ExportArrow, "Export…");
278        mark(ui, "project_export", &r);
279        if r.clicked() {
280            PENDING_ACTION.with(|p| *p.borrow_mut() = Some(Action::ExportVideo));
281        }
282        let r = ui.button("Export Frame…");
283        mark(ui, "project_export_frame", &r);
284        if r.clicked() {
285            PENDING_ACTION.with(|p| *p.borrow_mut() = Some(Action::ExportFrame));
286        }
287    });
288
289    ui.separator();
290    ui.strong("Export settings");
291    let scaler_name = crate::ui::export_ui::SCALERS
292        .iter()
293        .find(|(k, _)| *k == settings.export_scaler.as_str())
294        .map(|(_, v)| *v)
295        .unwrap_or(settings.export_scaler.as_str());
296    let res = if settings.export_resolution.is_empty() || settings.export_resolution == "project" {
297        "Project size".to_string()
298    } else {
299        settings.export_resolution.clone()
300    };
301    ui.horizontal_wrapped(|ui| {
302        ui.weak(format!(
303            "{} · CRF {} · {} · {} · {}",
304            settings.encoder, settings.crf, settings.preset, res, scaler_name
305        ));
306    });
307    if ui.small_button("Edit export settings…").clicked() {
308        PENDING_ACTION.with(|p| *p.borrow_mut() = Some(Action::ExportVideo));
309    }
310    edited
311}
312
313#[allow(clippy::too_many_arguments)]
314fn clip_section(
315    ui: &mut egui::Ui,
316    project: &mut Project,
317    id: Id,
318    n_selected: usize,
319    playhead: f64,
320    fonts: &[String],
321    palette: &Palette,
322    undo: &mut dyn FnMut(&Project),
323) -> bool {
324    // ponytail: edit a per-frame clone of the clip and write it back at the end — lets `undo` snapshot the
325    // untouched project first without borrow gymnastics. Upgrade: per-field scratch copies if it ever shows.
326    let Some(orig) = project.clip(id) else {
327        return false;
328    };
329    let mut clip = orig.clone();
330    let track = project.track_of(id).map(|t| project.tracks[t].name.clone()).unwrap_or_default();
331    let fps = project.fps;
332    let lt = clip.local(playhead);
333    let mut g = Gesture::default();
334    // snapshots: the widgets edit a clone of the clip, so the project must not stay borrowed
335    let labels: Vec<Label> = project.labels.clone();
336    let buses: Vec<(Id, String)> = project.buses.iter().map(|b| (b.id, b.name.clone())).collect();
337    let labels_open_id = egui::Id::new("inspector_labels_open");
338    let mut edit_labels: bool = ui.ctx().data(|d| d.get_temp(labels_open_id).unwrap_or(false));
339    let mut label_ops: Vec<LabelOp> = Vec::new();
340    let mut path_op: Option<PathOp> = None;
341
342    if n_selected > 1 {
343        ui.label(format!("{n_selected} clips selected"));
344    }
345    if clip.container {
346        ui.strong("Container Slot");
347        Grid::new("inspector_container").num_columns(2).show(ui, |ui| {
348            ui.label("Slot label");
349            let r = ui.text_edit_singleline(&mut clip.container_label);
350            g.note_text(&r);
351            ui.end_row();
352
353            ui.label("Media");
354            ui.horizontal(|ui| {
355                if clip.asset == 0 {
356                    ui.weak("(Empty Slot)");
357                } else if let Some(a) = project.asset(clip.asset) {
358                    ui.label(a.name());
359                } else {
360                    ui.weak("Missing asset");
361                }
362                if ui.small_button("Replace…").clicked() {
363                    PENDING_ACTION.with(|p| *p.borrow_mut() = Some(Action::ReplaceContainerMedia));
364                }
365            });
366            ui.end_row();
367        });
368        ui.separator();
369    }
370    ui.strong("Clip");
371    Grid::new("inspector_clip").num_columns(2).show(ui, |ui| {
372        ui.label("Name");
373        let r = ui.text_edit_singleline(&mut clip.name);
374        #[cfg(test)]
375        ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("test_name_field"), r.id));
376        g.note_text(&r);
377        ui.end_row();
378        ui.label("Enabled");
379        g.note(&ui.checkbox(&mut clip.enabled, ""));
380        ui.end_row();
381        ui.label("Label");
382        ui.horizontal(|ui| {
383            let name = if clip.label == 0 {
384                "None".to_string()
385            } else {
386                labels.get(clip.label as usize - 1).map(|l| l.name.clone()).unwrap_or_else(|| "None".into())
387            };
388            egui::ComboBox::from_id_salt("clip_label").selected_text(name).show_ui(ui, |ui| {
389                g.note(&ui.selectable_value(&mut clip.label, 0, "None"));
390                for (i, l) in labels.iter().enumerate() {
391                    let [r, gc, b] = l.color;
392                    let t = RichText::new(l.name.clone()).color(egui::Color32::from_rgb(r, gc, b));
393                    g.note(&ui.selectable_value(&mut clip.label, i as u8 + 1, t));
394                }
395                ui.separator();
396                if ui.selectable_label(false, "Edit labels…").clicked() {
397                    edit_labels = true;
398                    ui.close();
399                }
400            });
401            if clip.label != 0 {
402                if let Some(l) = labels.get(clip.label as usize - 1) {
403                    let [r, gc, b] = l.color;
404                    crate::ui::tools::glyph_label(ui, crate::ui::tools::Glyph::Dot, egui::Color32::from_rgb(r, gc, b));
405                }
406            }
407        });
408        ui.end_row();
409        ui.label("Track");
410        ui.label(&track);
411        ui.end_row();
412        ui.label("Start");
413        ui.monospace(timecode(clip.start, fps));
414        ui.end_row();
415        ui.label("Duration");
416        ui.monospace(timecode(clip.duration, fps));
417        ui.end_row();
418        if clip.uses_asset() {
419            ui.label("Source in");
420            ui.monospace(timecode(clip.src_in, fps));
421            ui.end_row();
422            ui.label("Speed");
423            ui.horizontal(|ui| {
424                let mut s = format!("{:.0} %", clip.speed * 100.0);
425                if clip.reverse {
426                    s.push_str(", reversed");
427                }
428                if clip.freeze.is_some() {
429                    s.push_str(", freeze frame");
430                }
431                ui.label(s);
432                ui.weak("Retime… Ctrl+R");
433            });
434            ui.end_row();
435        }
436        for (label, a) in clip.props_mut() {
437            ui.label(label);
438            ui.horizontal(|ui| {
439                let mut v = a.at(lt);
440                let r = match label {
441                    "Volume" => {
442                        let mut db = gain_to_db(v);
443                        let r = ui.add(Slider::new(&mut db, -60.0..=12.0).suffix(" dB").fixed_decimals(1));
444                        if r.changed() {
445                            v = db_to_gain(db);
446                        }
447                        r
448                    }
449                    "Pan" => {
450                        let r = ui.add(Slider::new(&mut v, -1.0..=1.0).fixed_decimals(2));
451                        #[cfg(test)]
452                        ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("test_pan_slider"), r.id));
453                        r
454                    }
455                    "Scale" => ui.add(DragValue::new(&mut v).speed(0.01).range(0.01..=20.0)),
456                    "Opacity" => {
457                        let mut pct = v * 100.0;
458                        let r = ui.add(Slider::new(&mut pct, 0.0..=100.0).suffix(" %").fixed_decimals(0));
459                        if r.changed() {
460                            v = pct / 100.0;
461                        }
462                        #[cfg(test)]
463                        ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("test_opacity_slider"), r.id));
464                        r
465                    }
466                    _ => ui.add(DragValue::new(&mut v).speed(1.0)),
467                };
468                if r.changed() {
469                    a.set_at(lt, v);
470                }
471                g.note(&r);
472                key_buttons(ui, a, lt, palette, &mut g);
473            });
474            ui.end_row();
475        }
476        // gain fades on audio, opacity fades on visual clips (same fields, same ramps)
477        if clip.kind != ClipKind::Adjustment {
478            let dur = clip.duration;
479            ui.label("Fade in");
480            g.note(&ui.add(DragValue::new(&mut clip.fade_in).range(0.0..=dur).speed(0.05).suffix(" s")));
481            ui.end_row();
482            ui.label("Fade out");
483            g.note(&ui.add(DragValue::new(&mut clip.fade_out).range(0.0..=dur).speed(0.05).suffix(" s")));
484            ui.end_row();
485        }
486        if clip.is_visual() {
487            ui.label("Blend");
488            egui::ComboBox::from_id_salt("blend").selected_text(clip.blend.name()).show_ui(ui, |ui| {
489                for b in BlendMode::ALL {
490                    g.note(&ui.selectable_value(&mut clip.blend, b, b.name()));
491                }
492            });
493            ui.end_row();
494        }
495    });
496
497    if !clip.effects.is_empty() {
498        ui.separator();
499        ui.strong("Effects");
500        let mut rm: Option<usize> = None;
501        for (i, e) in clip.effects.iter_mut().enumerate() {
502            ui.horizontal(|ui| {
503                g.note(&ui.checkbox(&mut e.enabled, e.kind.name()));
504                if crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this effect").clicked() {
505                    rm = Some(i);
506                }
507            });
508        }
509        if let Some(i) = rm {
510            clip.effects.remove(i);
511            g.click();
512        }
513    }
514
515    let transitions: Vec<String> =
516        project.transitions_of(id).iter().map(|(_, tr)| format!("{} · {:.2} s", tr.kind.name(), tr.duration)).collect();
517    if !transitions.is_empty() {
518        ui.separator();
519        ui.strong("Transitions");
520        for t in &transitions {
521            ui.weak(t);
522        }
523    }
524
525    if clip.kind == ClipKind::Sequence {
526        ui.separator();
527        ui.strong("Sequence");
528        let name = project.sequence(clip.sequence).map(|s| s.name.clone()).unwrap_or_else(|| "(missing)".into());
529        ui.horizontal(|ui| {
530            ui.label(name);
531            if ui.button("Open sequence").clicked() {
532                OPEN_SEQUENCE.with(|p| *p.borrow_mut() = Some(clip.sequence));
533            }
534        });
535    }
536
537    // asset details (description / tags live on the asset, not the clip)
538    let mut ga = Gesture::default();
539    let mut asset_desc: Option<String> = None;
540    let mut asset_tags: Option<Vec<String>> = None;
541    if clip.uses_asset() {
542        if let Some(a) = project.asset(clip.asset) {
543            ui.separator();
544            ui.strong("Asset");
545            ui.add(egui::Label::new(RichText::new(a.name()).weak()).truncate()).on_hover_text(&a.path);
546            ui.weak(format!("Used in: {} clips", asset_use_count(project, a.id)));
547            let mut desc = a.description.clone();
548            let r = ui.add(egui::TextEdit::multiline(&mut desc).desired_rows(2).hint_text("description"));
549            if r.changed() {
550                asset_desc = Some(desc);
551            }
552            ga.note_text(&r);
553            // The raw text is kept so a comma survives typing; it is stored with the tags it produced and
554            // dropped again as soon as the asset's tags were changed elsewhere (library details box).
555            let tags_id = egui::Id::new(("asset_tags", a.id));
556            let mut buf = ui
557                .ctx()
558                .data_mut(|d| d.get_temp::<(String, Vec<String>)>(tags_id))
559                .filter(|(_, src)| *src == a.tags)
560                .map(|(b, _)| b)
561                .unwrap_or_else(|| a.tags.join(", "));
562            let r = ui.add(egui::TextEdit::singleline(&mut buf).hint_text("tags, comma, separated"));
563            if r.changed() {
564                let tags: Vec<String> =
565                    buf.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect();
566                ui.ctx().data_mut(|d| d.insert_temp(tags_id, (buf, tags.clone())));
567                asset_tags = Some(tags);
568            }
569            ga.note_text(&r);
570        }
571    }
572
573    if clip.kind == ClipKind::Text {
574        let style = clip.text.get_or_insert_with(Default::default);
575        ui.separator();
576        ui.strong("Text");
577        g.note_text(&ui.text_edit_multiline(&mut style.text));
578        Grid::new("inspector_text").num_columns(2).show(ui, |ui| {
579            ui.label("Font");
580            ui.horizontal(|ui| {
581                egui::ComboBox::from_id_salt("font").selected_text(style.font.clone()).show_ui(ui, |ui| {
582                    if !fonts.iter().any(|f| *f == style.font) {
583                        let _ = ui.selectable_label(true, style.font.as_str());
584                    }
585                    for f in fonts {
586                        g.note(&ui.selectable_value(&mut style.font, f.clone(), f));
587                    }
588                });
589                if ui.small_button("Import font…").clicked() {
590                    if let Some(p) = rfd::FileDialog::new().add_filter("Fonts", &["ttf", "otf"]).pick_file() {
591                        PENDING_FONT.with(|f| *f.borrow_mut() = Some(p.to_string_lossy().into_owned()));
592                    }
593                }
594            });
595            ui.end_row();
596            ui.label("Size");
597            ui.horizontal(|ui| {
598                g.note(&ui.add(DragValue::new(&mut style.size).range(1.0..=1000.0)));
599                g.note(&ui.checkbox(&mut style.bold, "Bold"));
600                g.note(&ui.checkbox(&mut style.italic, "Italic"));
601            });
602            ui.end_row();
603            ui.label("Fill");
604            g.note(&ui.color_edit_button_srgba_unmultiplied(&mut style.color));
605            ui.end_row();
606            ui.label("Outline");
607            ui.horizontal(|ui| {
608                g.note(&ui.color_edit_button_srgba_unmultiplied(&mut style.outline_color));
609                g.note(&ui.add(DragValue::new(&mut style.outline_width).range(0.0..=50.0).speed(0.1)));
610            });
611            ui.end_row();
612            ui.label("Drop shadow");
613            ui.horizontal(|ui| {
614                g.note(&ui.checkbox(&mut style.shadow, ""));
615                g.note(&ui.color_edit_button_srgba_unmultiplied(&mut style.shadow_color));
616            });
617            ui.end_row();
618            ui.label("Shadow x/y/blur");
619            ui.horizontal(|ui| {
620                g.note(&ui.add(DragValue::new(&mut style.shadow_x).range(-200.0..=200.0)));
621                g.note(&ui.add(DragValue::new(&mut style.shadow_y).range(-200.0..=200.0)));
622                g.note(&ui.add(DragValue::new(&mut style.shadow_blur).range(0.0..=50.0).speed(0.1)));
623            });
624            ui.end_row();
625            ui.label("Align");
626            ui.horizontal(|ui| {
627                for (i, name) in ["Left", "Center", "Right"].iter().enumerate() {
628                    g.note(&ui.selectable_value(&mut style.align, i as u8, *name));
629                }
630            });
631            ui.end_row();
632            ui.label("Line spacing");
633            g.note(&ui.add(DragValue::new(&mut style.line_spacing).range(0.5..=3.0).speed(0.01)));
634            ui.end_row();
635            ui.label("Letter spacing");
636            g.note(&ui.add(DragValue::new(&mut style.letter_spacing).range(-10.0..=50.0).speed(0.1)));
637            ui.end_row();
638            ui.label("Box");
639            ui.horizontal(|ui| {
640                g.note(&ui.color_edit_button_srgba_unmultiplied(&mut style.box_color));
641                g.note(&ui.add(DragValue::new(&mut style.box_padding).range(0.0..=100.0).speed(0.5)));
642            });
643            ui.end_row();
644        });
645    }
646
647    // ---------- round 3 sections ----------
648    if clip.kind == ClipKind::Adjustment {
649        ui.separator();
650        ui.weak("Adjustment layer — its effects apply to everything below it on the timeline.");
651    }
652
653    // node graph
654    ui.separator();
655    ui.horizontal(|ui| {
656        ui.strong("Nodes");
657        let has = clip.graph.is_some();
658        let r = ui.button("Open node editor").on_hover_text(if has {
659            "Edit the node graph"
660        } else {
661            "Convert this clip's effect stack to nodes"
662        });
663        mark(ui, "open_nodes", &r);
664        if r.clicked() {
665            OPEN_NODES.with(|p| *p.borrow_mut() = Some(id));
666        }
667        if has {
668            let n = clip.graph.as_ref().map(|gr| gr.nodes.len()).unwrap_or(0);
669            ui.weak(format!("{n} nodes"));
670            let r = ui.button("Unlink").on_hover_text("Back to a plain effect list (a simple chain only)");
671            mark(ui, "unlink_nodes", &r);
672            if r.clicked() {
673                UNLINK_NODES.with(|p| *p.borrow_mut() = Some(id));
674            }
675        }
676    });
677
678    // mask — a mask shapes pixels, so an audio clip gets no mask UI at all (not even a dead button)
679    if clip.is_visual() {
680        ui.separator();
681        ui.horizontal(|ui| {
682            ui.strong("Mask");
683            if clip.mask.is_none() {
684                let r = ui.button("Add mask");
685                mark(ui, "add_mask", &r);
686                if r.clicked() {
687                    clip.mask = Some(Mask::default());
688                    g.click();
689                }
690            } else {
691                let r = ui.button("Edit in viewport").on_hover_text("Drag the mask over the preview");
692                mark(ui, "edit_mask", &r);
693                if r.clicked() {
694                    EDIT_MASK.with(|p| *p.borrow_mut() = Some(id));
695                }
696                if x_button(ui).on_hover_text("Remove mask").clicked() {
697                    clip.mask = None;
698                    g.click();
699                }
700            }
701        });
702        if let Some(m) = &mut clip.mask {
703            mask_grid(ui, m, lt, palette, &mut g, egui::Id::new("inspector_mask"));
704        }
705    }
706
707    // shape style
708    if let Some(sh) = &mut clip.shape {
709        ui.separator();
710        ui.strong("Shape");
711        Grid::new("inspector_shape").num_columns(2).show(ui, |ui| {
712            ui.label("Kind");
713            egui::ComboBox::from_id_salt("shape_kind").selected_text(sh.kind.name()).show_ui(ui, |ui| {
714                for k in ShapeKind::ALL {
715                    g.note(&ui.selectable_value(&mut sh.kind, k, k.name()));
716                }
717            });
718            ui.end_row();
719            ui.label("Fill");
720            g.note(&ui.color_edit_button_srgba_unmultiplied(&mut sh.fill));
721            ui.end_row();
722            ui.label("Stroke");
723            ui.horizontal(|ui| {
724                g.note(&ui.color_edit_button_srgba_unmultiplied(&mut sh.stroke));
725                g.note(&ui.add(DragValue::new(&mut sh.stroke_width).range(0.0..=200.0).speed(0.2)));
726            });
727            ui.end_row();
728            ui.label("Sides");
729            g.note(&ui.add(DragValue::new(&mut sh.sides).range(3..=64)));
730            ui.end_row();
731            ui.label("Corner");
732            g.note(&ui.add(DragValue::new(&mut sh.corner).range(0.0..=500.0).speed(0.5)));
733            ui.end_row();
734            for label in ["Width", "Height"] {
735                ui.label(label);
736                ui.horizontal(|ui| {
737                    let a: &mut Animated = if label == "Width" { &mut sh.w } else { &mut sh.h };
738                    let mut v = a.at(lt);
739                    let r = ui.add(DragValue::new(&mut v).range(1.0..=20000.0).speed(1.0));
740                    if r.changed() {
741                        a.set_at(lt, v);
742                    }
743                    g.note(&r);
744                    key_buttons(ui, a, lt, palette, &mut g);
745                });
746                ui.end_row();
747            }
748            if sh.kind == ShapeKind::Draw {
749                ui.label("Draw rate");
750                ui.horizontal(|ui| {
751                    g.note(&ui.add(DragValue::new(&mut sh.draw_rate).range(0.0..=8.0).speed(0.05)));
752                    ui.weak(format!("{} strokes · {:.1} s", sh.strokes.len(), sh.draw_duration()));
753                });
754                ui.end_row();
755                ui.label("Page");
756                g.note(&ui.color_edit_button_srgba_unmultiplied(&mut sh.page));
757                ui.end_row();
758            }
759        });
760    } else if clip.kind == ClipKind::Shape {
761        ui.separator();
762        if ui.button("Add shape style").clicked() {
763            clip.shape = Some(ShapeStyle::default());
764            g.click();
765        }
766    }
767
768    // reusable paths: a drawing or a polygon outline is saved on the project, and any clip can then
769    // travel along one (its X/Y become keyframes over the clip's own length)
770    // ponytail: only the cheap "is there an outline" test runs per frame; the points are copied when
771    // Save is actually clicked
772    let outline = clip.shape.as_ref().is_some_and(|s| !s.strokes.is_empty() || s.points.len() >= 2);
773    let paths: Vec<(Id, String)> = project.paths.iter().map(|p| (p.id, p.name.clone())).collect();
774    if outline || !paths.is_empty() {
775        ui.separator();
776        ui.horizontal(|ui| {
777            ui.strong("Path");
778            if outline {
779                let r = ui.small_button("Save").on_hover_text("Keep this outline in the project as a reusable path");
780                mark(ui, "save_path", &r);
781                if r.clicked() {
782                    path_op = Some(PathOp::Save);
783                }
784            }
785            if !paths.is_empty() {
786                egui::ComboBox::from_id_salt("clip_path").selected_text("Animate X/Y").width(130.0).show_ui(ui, |ui| {
787                    for (pid, name) in &paths {
788                        if ui.selectable_label(false, name).clicked() {
789                            path_op = Some(PathOp::Apply(*pid));
790                        }
791                    }
792                });
793            }
794        });
795    }
796
797    // audio bus override
798    if clip.kind == ClipKind::Audio || clip.kind == ClipKind::Video {
799        ui.separator();
800        ui.horizontal(|ui| {
801            ui.strong("Bus");
802            let name = buses
803                .iter()
804                .find(|(bid, _)| *bid == clip.bus)
805                .map(|(_, n)| n.clone())
806                .unwrap_or_else(|| "Track default".into());
807            egui::ComboBox::from_id_salt("clip_bus").selected_text(name).width(140.0).show_ui(ui, |ui| {
808                g.note(&ui.selectable_value(&mut clip.bus, 0, "Track default"));
809                for (bid, n) in &buses {
810                    g.note(&ui.selectable_value(&mut clip.bus, *bid, n));
811                }
812            });
813        });
814    }
815
816    // clip markers
817    ui.separator();
818    ui.horizontal(|ui| {
819        ui.strong("Markers");
820        let r = ui.small_button("+ at playhead");
821        mark(ui, "add_marker", &r);
822        if r.clicked() {
823            let t = lt.clamp(0.0, clip.duration);
824            let mid = project.new_id();
825            clip.markers.push(crate::model::Marker { id: mid, t, ..Default::default() });
826            clip.markers.sort_by(|a, b| a.t.total_cmp(&b.t));
827            g.click();
828        }
829    });
830    let mut rm_marker: Option<usize> = None;
831    for (i, m) in clip.markers.iter_mut().enumerate() {
832        ui.horizontal(|ui| {
833            let r = ui.add(DragValue::new(&mut m.t).range(0.0..=1e6).speed(0.05).suffix(" s").fixed_decimals(2));
834            g.note(&r);
835            let w = (ui.available_width() - 30.0).max(50.0);
836            let r = ui.add(egui::TextEdit::singleline(&mut m.name).desired_width(w).hint_text("marker"));
837            g.note_text(&r);
838            if x_button(ui).on_hover_text("Delete marker").clicked() {
839                rm_marker = Some(i);
840            }
841        });
842    }
843    if let Some(i) = rm_marker {
844        clip.markers.remove(i);
845        g.click();
846    }
847    if clip.markers.is_empty() {
848        ui.weak("No clip markers");
849    }
850
851    // compact labels editor (labels live on the project, not the clip)
852    if edit_labels {
853        ui.separator();
854        ui.horizontal(|ui| {
855            ui.strong("Labels");
856            if ui.small_button("Add").clicked() {
857                label_ops.push(LabelOp::Add);
858            }
859            if ui.small_button("Done").clicked() {
860                edit_labels = false;
861            }
862        });
863        for (i, l) in labels.iter().enumerate() {
864            ui.horizontal(|ui| {
865                let mut color = l.color;
866                if ui.color_edit_button_srgb(&mut color).changed() {
867                    label_ops.push(LabelOp::Color(i, color));
868                }
869                let mut name = l.name.clone();
870                let w = (ui.available_width() - 30.0).max(50.0);
871                if ui.add(egui::TextEdit::singleline(&mut name).desired_width(w)).changed() {
872                    label_ops.push(LabelOp::Rename(i, name));
873                }
874                if x_button(ui).on_hover_text("Remove label").clicked() {
875                    label_ops.push(LabelOp::Remove(i));
876                }
877            });
878        }
879    }
880    ui.ctx().data_mut(|d| d.insert_temp(labels_open_id, edit_labels));
881
882    if g.start || ga.start || !label_ops.is_empty() || path_op.is_some() {
883        undo(project);
884    }
885    if g.changed {
886        if let Some(c) = project.clip_mut(id) {
887            *c = clip.clone();
888        }
889    }
890    if ga.changed {
891        if let Some(a) = project.asset_mut(clip.asset) {
892            if let Some(d) = asset_desc {
893                a.description = d;
894            }
895            if let Some(t) = asset_tags {
896                a.tags = t;
897            }
898        }
899    }
900    let labels_changed = !label_ops.is_empty();
901    for op in label_ops {
902        match op {
903            LabelOp::Add => {
904                project.add_label(format!("Label {}", project.labels.len() + 1), [160, 160, 160]);
905            }
906            LabelOp::Rename(i, name) => {
907                if let Some(l) = project.labels.get_mut(i) {
908                    l.name = name;
909                }
910            }
911            LabelOp::Color(i, c) => {
912                if let Some(l) = project.labels.get_mut(i) {
913                    l.color = c;
914                }
915            }
916            // remove_label() also re-points every clip / asset / marker using it
917            LabelOp::Remove(i) => project.remove_label(i as u8 + 1),
918        }
919    }
920    // after the write-back: applying a path overwrites the X/Y the clone still held
921    match path_op {
922        Some(PathOp::Save) => {
923            let pts = project.path_from_clip(id);
924            project.add_path(clip.name.clone(), pts);
925        }
926        Some(PathOp::Apply(pid)) => {
927            let pts = project.path(pid).map(|p| p.points.clone()).unwrap_or_default();
928            project.apply_path(id, &pts);
929        }
930        None => {}
931    }
932    g.changed || ga.changed || labels_changed || path_op.is_some()
933}
934
935/// Save the clip's outline as a project path, or animate it along one that was saved.
936enum PathOp {
937    Save,
938    Apply(Id),
939}
940
941/// Edits to `Project.labels` collected during the frame (applied after the clip write-back).
942enum LabelOp {
943    Add,
944    Rename(usize, String),
945    Color(usize, [u8; 3]),
946    Remove(usize),
947}
948
949/// How many clips (main timeline, stash, every sequence) use the asset.
950fn asset_use_count(project: &Project, aid: Id) -> usize {
951    let count = |tracks: &[crate::model::Track]| {
952        tracks.iter().flat_map(|t| t.clips.iter()).filter(|c| c.uses_asset() && c.asset == aid).count()
953    };
954    let mut n = count(&project.tracks);
955    if let Some(st) = &project.main_stash {
956        n += count(&st.tracks);
957    }
958    for s in &project.sequences {
959        n += count(&s.tracks);
960    }
961    n
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use crate::model::{Asset, Clip};
968
969    #[test]
970    fn db_gain_roundtrip() {
971        assert_eq!(gain_to_db(1.0), 0.0);
972        assert!((db_to_gain(6.0) - 1.9953).abs() < 1e-3);
973        assert_eq!(db_to_gain(-60.0), 0.0);
974        assert_eq!(gain_to_db(0.0), -60.0);
975        for db in [-59.0, -20.0, -3.0, 0.0, 6.0, 12.0] {
976            assert!((gain_to_db(db_to_gain(db)) - db).abs() < 1e-9, "{db}");
977        }
978        for g in [0.002, 0.1, 0.5, 1.0, 2.0, 3.98] {
979            assert!((db_to_gain(gain_to_db(g)) - g).abs() < 1e-9, "{g}");
980        }
981    }
982
983    #[test]
984    fn use_count_and_labels() {
985        let a = Asset {
986            id: 0,
987            path: r"C:\m\a.mp4".into(),
988            kind: ClipKind::Video,
989            duration: 10.0,
990            width: 1280,
991            height: 720,
992            fps: 30.0,
993            audio_streams: Vec::new(),
994            codec: String::new(),
995            folder: String::new(),
996            tags: Vec::new(),
997            label: 0,
998            description: String::new(),
999        };
1000        let mut p = Project::from_media(a);
1001        let aid = p.assets[0].id;
1002        assert_eq!(asset_use_count(&p, aid), 1);
1003        p.split_at(4.0, None);
1004        assert_eq!(asset_use_count(&p, aid), 2);
1005        assert_eq!(crate::ui::label_name(0), "None");
1006        assert_eq!(crate::ui::label_name(1), "Red");
1007        assert_eq!(crate::ui::label_name(200), "None");
1008    }
1009
1010    /// A keyboard nudge on the Pan slider of an audio clip pushes exactly one undo and moves the pan.
1011    #[test]
1012    fn pan_edit_pushes_one_undo() {
1013        let mut p = Project::new();
1014        let ai = p.tracks.iter().position(|t| t.kind == crate::model::TrackKind::Audio).unwrap();
1015        let c = Clip::new(500, ClipKind::Audio, "a", 0.0, 5.0);
1016        let id = c.id;
1017        p.tracks[ai].clips.push(c);
1018        let palette = Palette::new(true, egui::Color32::WHITE);
1019        let fonts: Vec<String> = Vec::new();
1020        let ctx = egui::Context::default();
1021        let mut undos = 0;
1022        let mut run = |ctx: &egui::Context, input: egui::RawInput, p: &mut Project, undos: &mut usize| {
1023            let _ = ctx.run(input, |ctx| {
1024                egui::CentralPanel::default().show(ctx, |ui| {
1025                    let mut undo = |pre: &Project| {
1026                        *undos += 1;
1027                        assert_eq!(pre.clip(id).unwrap().pan.value, 0.0, "undo sees the pre-edit project");
1028                    };
1029                    show(ui, p, &[id], &[], 1.0, &fonts, &palette, &Settings::default(), &mut undo);
1030                });
1031            });
1032        };
1033        run(&ctx, egui::RawInput::default(), &mut p, &mut undos); // layout, records the pan slider id
1034        let slider = ctx.data_mut(|d| d.get_temp::<egui::Id>(egui::Id::new("test_pan_slider"))).expect("pan id");
1035        ctx.memory_mut(|m| m.request_focus(slider));
1036        run(&ctx, egui::RawInput::default(), &mut p, &mut undos); // focus settles
1037        assert_eq!(undos, 0);
1038        let mut input = egui::RawInput::default();
1039        input.events.push(egui::Event::Key {
1040            key: egui::Key::ArrowRight,
1041            physical_key: None,
1042            pressed: true,
1043            repeat: false,
1044            modifiers: egui::Modifiers::default(),
1045        });
1046        run(&ctx, input, &mut p, &mut undos);
1047        assert_eq!(undos, 1, "exactly one undo per edit gesture");
1048        assert!(p.clip(id).unwrap().pan.value > 0.0, "pan moved right");
1049        run(&ctx, egui::RawInput::default(), &mut p, &mut undos);
1050        assert_eq!(undos, 1, "no undo without an edit");
1051    }
1052
1053    /// The Opacity control is a 0-100% slider that writes straight into `clip.opacity` — the same
1054    /// `Animated` field the renderer reads via `props_mut()` — not a second, disconnected property.
1055    #[test]
1056    fn opacity_slider_drives_the_animated_field() {
1057        let mut p = Project::new();
1058        let vi = p.tracks.iter().position(|t| t.kind == crate::model::TrackKind::Video).unwrap();
1059        let c = Clip::new(500, ClipKind::Video, "v", 0.0, 5.0);
1060        let id = c.id;
1061        p.tracks[vi].clips.push(c);
1062        assert_eq!(p.clip(id).unwrap().opacity.value, 1.0, "clips start fully opaque");
1063        let palette = Palette::new(true, egui::Color32::WHITE);
1064        let fonts: Vec<String> = Vec::new();
1065        let ctx = egui::Context::default();
1066        let mut run = |ctx: &egui::Context, input: egui::RawInput, p: &mut Project| {
1067            let _ = ctx.run(input, |ctx| {
1068                egui::CentralPanel::default().show(ctx, |ui| {
1069                    let mut undo = |_: &Project| {};
1070                    show(ui, p, &[id], &[], 1.0, &fonts, &palette, &Settings::default(), &mut undo);
1071                });
1072            });
1073        };
1074        run(&ctx, egui::RawInput::default(), &mut p); // layout, records the opacity slider id
1075        let slider =
1076            ctx.data_mut(|d| d.get_temp::<egui::Id>(egui::Id::new("test_opacity_slider"))).expect("opacity id");
1077        ctx.memory_mut(|m| m.request_focus(slider));
1078        run(&ctx, egui::RawInput::default(), &mut p); // focus settles
1079        let mut input = egui::RawInput::default();
1080        input.events.push(egui::Event::Key {
1081            key: egui::Key::ArrowLeft,
1082            physical_key: None,
1083            pressed: true,
1084            repeat: false,
1085            modifiers: egui::Modifiers::default(),
1086        });
1087        run(&ctx, input, &mut p);
1088        let o = p.clip(id).unwrap().opacity.value;
1089        assert!(o < 1.0 && o >= 0.0, "opacity moved down off its default 1.0: {o}");
1090    }
1091
1092    /// The project panel's Save / Export… / Export Frame… buttons push the same `Action`s the menus and
1093    /// hotkeys use, through the `take_pending_action` hand-off.
1094    #[test]
1095    fn project_buttons_push_the_menu_actions() {
1096        let cases = [
1097            ("project_save", Action::Save),
1098            ("project_export", Action::ExportVideo),
1099            ("project_export_frame", Action::ExportFrame),
1100        ];
1101        for (button, expect) in cases {
1102            let mut p = Project::new();
1103            let palette = Palette::new(true, egui::Color32::WHITE);
1104            let fonts: Vec<String> = Vec::new();
1105            let settings = Settings::default();
1106            let ctx = egui::Context::default();
1107            let mut frame = |events: Vec<egui::Event>, p: &mut Project| {
1108                let input = egui::RawInput {
1109                    screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(420.0, 900.0))),
1110                    events,
1111                    ..Default::default()
1112                };
1113                let mut undo = |_: &Project| {};
1114                let _ = ctx.run(input, |ctx| {
1115                    egui::CentralPanel::default().show(ctx, |ui| {
1116                        show(ui, p, &[], &[], 1.0, &fonts, &palette, &settings, &mut undo);
1117                    });
1118                });
1119            };
1120            let _ = take_pending_action(); // clear any leftover from a previous case
1121            frame(vec![], &mut p); // layout, records the button rect
1122            let r = ctx
1123                .data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("insp", button.to_string()))))
1124                .unwrap_or_else(|| panic!("no widget rect for {button}"));
1125            let pos = r.center();
1126            frame(vec![egui::Event::PointerMoved(pos)], &mut p);
1127            frame(
1128                vec![egui::Event::PointerButton {
1129                    pos,
1130                    button: egui::PointerButton::Primary,
1131                    pressed: true,
1132                    modifiers: egui::Modifiers::NONE,
1133                }],
1134                &mut p,
1135            );
1136            frame(
1137                vec![egui::Event::PointerButton {
1138                    pos,
1139                    button: egui::PointerButton::Primary,
1140                    pressed: false,
1141                    modifiers: egui::Modifiers::NONE,
1142                }],
1143                &mut p,
1144            );
1145            assert_eq!(take_pending_action(), Some(expect), "{button}");
1146        }
1147    }
1148
1149    /// Typing in a text field snapshots once when the field is entered, not once per character.
1150    #[test]
1151    fn name_edit_pushes_one_undo_per_visit() {
1152        let mut p = Project::new();
1153        let c = Clip::new(0, ClipKind::Text, "a", 0.0, 5.0);
1154        let id = c.id;
1155        p.tracks[0].clips.push(c);
1156        let palette = Palette::new(true, egui::Color32::WHITE);
1157        let ctx = egui::Context::default();
1158        let mut undos = 0;
1159        // focus is requested inside the pass: done between passes it counts as "had focus last frame"
1160        let mut run = |input: egui::RawInput, focus: Option<egui::Id>, p: &mut Project, undos: &mut usize| {
1161            let mut edited = false;
1162            let _ = ctx.run(input, |ctx| {
1163                if let Some(f) = focus {
1164                    ctx.memory_mut(|m| m.request_focus(f));
1165                }
1166                egui::CentralPanel::default().show(ctx, |ui| {
1167                    let mut undo = |_: &Project| *undos += 1;
1168                    edited = show(ui, p, &[id], &[], 1.0, &[], &palette, &Settings::default(), &mut undo);
1169                });
1170            });
1171            edited
1172        };
1173        run(egui::RawInput::default(), None, &mut p, &mut undos); // layout, records the name field id
1174        assert_eq!(undos, 0);
1175        let field = ctx.data_mut(|d| d.get_temp::<egui::Id>(egui::Id::new("test_name_field"))).expect("name id");
1176        assert!(!run(egui::RawInput::default(), Some(field), &mut p, &mut undos), "entering a field is not an edit");
1177        assert_eq!(undos, 1, "one snapshot when the field is entered");
1178        for ch in ["h", "i"] {
1179            let mut input = egui::RawInput::default();
1180            input.events.push(egui::Event::Text(ch.into()));
1181            assert!(run(input, None, &mut p, &mut undos));
1182        }
1183        assert_eq!(undos, 1, "no extra snapshot per keystroke");
1184        assert!(p.clip(id).unwrap().name.contains("hi"), "{}", p.clip(id).unwrap().name);
1185    }
1186
1187    struct H {
1188        ctx: egui::Context,
1189        project: Project,
1190        id: Id,
1191        /// Selected transitions handed to show(); when non-empty the clip selection is left empty.
1192        sel_trans: Vec<Id>,
1193        undos: usize,
1194        time: f64,
1195    }
1196
1197    impl H {
1198        fn shape_clip() -> Self {
1199            let mut project = Project::new();
1200            let id = project.add_shape_clip(crate::model::ShapeKind::Star, 0.0, 3.0);
1201            Self { ctx: egui::Context::default(), project, id, sel_trans: Vec::new(), undos: 0, time: 0.0 }
1202        }
1203        fn audio_clip() -> Self {
1204            let mut project = Project::new();
1205            let id = project.new_id();
1206            project.tracks[1].clips.push(Clip::new(id, ClipKind::Audio, "a", 0.0, 3.0));
1207            Self { ctx: egui::Context::default(), project, id, sel_trans: Vec::new(), undos: 0, time: 0.0 }
1208        }
1209        /// Two abutting video clips with a cut transition and an edge transition, both selected.
1210        fn transitions() -> Self {
1211            use crate::model::TransitionKind;
1212            let mut project = Project::new();
1213            let a = project.new_id();
1214            project.tracks[0].clips.push(Clip::new(a, ClipKind::Video, "a", 0.0, 2.0));
1215            let b = project.new_id();
1216            project.tracks[0].clips.push(Clip::new(b, ClipKind::Video, "b", 2.0, 2.0));
1217            let t1 = project.add_transition(b, TransitionKind::CrossFade, 1.0).unwrap();
1218            let t2 = project.add_edge_transition(a, TransitionKind::CrossFade, 1.0, false).unwrap();
1219            Self { ctx: egui::Context::default(), project, id: a, sel_trans: vec![t1, t2], undos: 0, time: 0.0 }
1220        }
1221        fn frame(&mut self, events: Vec<egui::Event>) -> bool {
1222            self.time += 0.05;
1223            let input = egui::RawInput {
1224                screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(420.0, 1400.0))),
1225                time: Some(self.time),
1226                events,
1227                ..Default::default()
1228            };
1229            let pal = Palette::new(true, egui::Color32::WHITE);
1230            let H { ctx, project, id, sel_trans, undos, .. } = self;
1231            let sel: &[Id] = if sel_trans.is_empty() { &[*id] } else { &[] };
1232            let mut changed = false;
1233            let _ = ctx.run(input, |ctx| {
1234                egui::CentralPanel::default().show(ctx, |ui| {
1235                    let mut undo = |_: &Project| *undos += 1;
1236                    changed |= show(ui, project, sel, sel_trans, 1.0, &[], &pal, &Settings::default(), &mut undo);
1237                });
1238            });
1239            changed
1240        }
1241        fn maybe_rect(&self, name: &str) -> Option<egui::Rect> {
1242            self.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("insp", name.to_string()))))
1243        }
1244        fn rect(&self, name: &str) -> egui::Rect {
1245            self.maybe_rect(name).unwrap_or_else(|| panic!("no widget rect for {name}"))
1246        }
1247        fn click(&mut self, pos: egui::Pos2) -> bool {
1248            let mut e = self.frame(vec![egui::Event::PointerMoved(pos)]);
1249            e |= self.frame(vec![egui::Event::PointerButton {
1250                pos,
1251                button: egui::PointerButton::Primary,
1252                pressed: true,
1253                modifiers: egui::Modifiers::NONE,
1254            }]);
1255            e |= self.frame(vec![egui::Event::PointerButton {
1256                pos,
1257                button: egui::PointerButton::Primary,
1258                pressed: false,
1259                modifiers: egui::Modifiers::NONE,
1260            }]);
1261            e
1262        }
1263    }
1264
1265    /// Selected transitions get their own inspector section, and "Remove" deletes them all in one undo.
1266    #[test]
1267    fn transition_section_shows_and_removes_all_selected() {
1268        let mut h = H::transitions();
1269        h.frame(vec![]);
1270        let r = h.rect("tr_remove");
1271        assert!(h.click(r.center()), "removing transitions edits the project");
1272        assert!(h.project.tracks[0].transitions.is_empty(), "both selected transitions removed");
1273        assert_eq!(h.undos, 1, "one undo for the whole removal");
1274        // redrawing with the stale selection is not an edit (the section shows a hint instead)
1275        assert!(!h.frame(vec![]));
1276        assert_eq!(h.undos, 1);
1277    }
1278
1279    /// The mask section appears, "Add mask" creates one with a single undo, and the follow-up
1280    /// "Edit in viewport" hands the clip to the app.
1281    #[test]
1282    fn mask_section_adds_and_hands_off() {
1283        let mut h = H::shape_clip();
1284        h.frame(vec![]);
1285        let r = h.rect("add_mask");
1286        assert!(h.click(r.center()), "adding a mask edits the project");
1287        assert!(h.project.clip(h.id).unwrap().mask.is_some());
1288        assert_eq!(h.undos, 1, "one undo per gesture");
1289        h.frame(vec![]);
1290        let r = h.rect("edit_mask");
1291        let _ = take_edit_mask();
1292        h.click(r.center());
1293        assert_eq!(take_edit_mask(), Some(h.id), "the app is told which clip to mask");
1294        assert_eq!(h.undos, 1, "asking to edit in the viewport is not an edit");
1295    }
1296
1297    /// A mask shapes pixels, so an audio clip shows no mask section at all — not even a dead button,
1298    /// and not even for a mask a hand-edited project smuggled in.
1299    #[test]
1300    fn audio_clips_get_no_mask_section() {
1301        let mut h = H::audio_clip();
1302        h.frame(vec![]);
1303        assert!(h.maybe_rect("add_mask").is_none(), "no Add mask on an audio clip");
1304        h.project.clip_mut(h.id).unwrap().mask = Some(Mask::default());
1305        h.frame(vec![]);
1306        assert!(h.maybe_rect("edit_mask").is_none(), "and no editor for one that got in anyway");
1307    }
1308
1309    /// Shape clips get the style section, and a Shape clip's markers can be added from the inspector.
1310    #[test]
1311    fn shape_and_marker_sections() {
1312        let mut h = H::shape_clip();
1313        h.frame(vec![]);
1314        assert!(h.project.clip(h.id).unwrap().shape.is_some(), "the shape style exists");
1315        let r = h.rect("add_marker");
1316        assert!(h.click(r.center()), "adding a marker edits the project");
1317        let markers = &h.project.clip(h.id).unwrap().markers;
1318        assert_eq!(markers.len(), 1);
1319        assert!(markers[0].t >= 0.0 && markers[0].t <= 3.0, "inside the clip: {}", markers[0].t);
1320        assert_eq!(h.undos, 1);
1321    }
1322
1323    #[test]
1324    fn node_button_hands_the_clip_off() {
1325        let mut h = H::shape_clip();
1326        h.frame(vec![]);
1327        let r = h.rect("open_nodes");
1328        let _ = take_open_nodes();
1329        h.click(r.center());
1330        assert_eq!(take_open_nodes(), Some(h.id));
1331        assert_eq!(h.undos, 0);
1332    }
1333
1334    /// The label combo lists the project's own labels, and removing one re-points the clips using it.
1335    #[test]
1336    fn labels_come_from_the_project() {
1337        let mut p = Project::new();
1338        assert!(!p.labels.is_empty(), "a new project ships the default labels");
1339        let n = p.labels.len();
1340        let idx = p.add_label("Hero", [10, 20, 30]);
1341        assert_eq!(idx as usize, n + 1);
1342        assert_eq!(p.label_name(idx), "Hero");
1343        assert_eq!(p.label_color(idx), Some([10, 20, 30]));
1344        let cid = p.add_shape_clip(crate::model::ShapeKind::Rect, 0.0, 1.0);
1345        p.clip_mut(cid).unwrap().label = idx;
1346        p.remove_label(idx);
1347        assert_eq!(p.clip(cid).unwrap().label, 0, "the clip falls back to no label");
1348    }
1349
1350    /// Headless: sections for effects / retime / audio fades lay out without panicking.
1351    #[test]
1352    fn show_headless_sections() {
1353        let a = Asset {
1354            id: 0,
1355            path: r"C:\m\a.mp4".into(),
1356            kind: ClipKind::Video,
1357            duration: 10.0,
1358            width: 1280,
1359            height: 720,
1360            fps: 30.0,
1361            audio_streams: vec![Default::default()],
1362            codec: String::new(),
1363            folder: String::new(),
1364            tags: vec!["x".into()],
1365            label: 2,
1366            description: "d".into(),
1367        };
1368        let mut p = Project::from_media(a);
1369        let vid = p.tracks[0].clips[0].id;
1370        p.clip_mut(vid).unwrap().effects.push(crate::model::Effect::new(crate::model::EffectKind::Blur));
1371        p.clip_mut(vid).unwrap().speed = 2.0;
1372        let right = p.split_at(4.0, None)[0];
1373        p.add_transition(right, crate::model::TransitionKind::CrossFade, 0.5);
1374        let aud = p.tracks[1].clips[0].id;
1375        let palette = Palette::new(true, egui::Color32::WHITE);
1376        let fonts: Vec<String> = Vec::new();
1377        let ctx = egui::Context::default();
1378        for sel in [vid, right, aud] {
1379            for _ in 0..2 {
1380                let _ = ctx.run(egui::RawInput::default(), |ctx| {
1381                    egui::CentralPanel::default().show(ctx, |ui| {
1382                        let mut undo = |_: &Project| panic!("no undo without edits");
1383                        assert!(!show(ui, &mut p, &[sel], &[], 1.0, &fonts, &palette, &Settings::default(), &mut undo));
1384                    });
1385                });
1386            }
1387        }
1388    }
1389}