1use 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#[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
57thread_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 static PENDING_ACTION: RefCell<Option<Action>> = const { RefCell::new(None) };
67}
68
69pub fn take_unlink_nodes() -> Option<Id> {
71 UNLINK_NODES.with(|p| p.borrow_mut().take())
72}
73
74pub fn ask_unlink_nodes(id: Id) {
76 UNLINK_NODES.with(|p| *p.borrow_mut() = Some(id));
77}
78
79pub fn take_edit_mask() -> Option<Id> {
82 EDIT_MASK.with(|p| p.borrow_mut().take())
83}
84
85pub fn take_open_nodes() -> Option<Id> {
87 OPEN_NODES.with(|p| p.borrow_mut().take())
88}
89
90pub fn take_pending_font_import() -> Option<String> {
93 PENDING_FONT.with(|p| p.borrow_mut().take())
94}
95
96pub fn take_open_sequence() -> Option<Id> {
98 OPEN_SEQUENCE.with(|p| p.borrow_mut().take())
99}
100
101pub 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
126fn 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 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(); let r = ui.text_edit_singleline(&mut name);
235 if r.gained_focus() {
236 undo(project); }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 LabelOp::Remove(i) => project.remove_label(i as u8 + 1),
918 }
919 }
920 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
935enum PathOp {
937 Save,
938 Apply(Id),
939}
940
941enum LabelOp {
943 Add,
944 Rename(usize, String),
945 Color(usize, [u8; 3]),
946 Remove(usize),
947}
948
949fn 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 #[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); 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); 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 #[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); 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); 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 #[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(); frame(vec![], &mut p); 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 #[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 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); 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 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 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 #[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 assert!(!h.frame(vec![]));
1276 assert_eq!(h.undos, 1);
1277 }
1278
1279 #[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 #[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 #[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 #[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 #[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}