1use crate::media::thumbs::ThumbCache;
31use crate::model::{ClipKind, EffectKind, Id, Project};
32use crate::settings::{RecentAsset, Settings};
33use crate::theme::Palette;
34use crate::ui::tools::{draw_glyph, glyph_text_button, icon_button, Glyph};
35use crate::ui::{duration_text, label_color, DragPayload};
36use eframe::egui::{self, RichText};
37use std::path::PathBuf;
38
39#[derive(Default)]
40pub struct LibraryState {
41 pub tab: usize,
43 pub selected: Option<Id>,
46 pub sel_path: Option<String>,
48 pub sel_ids: Vec<Id>,
50 pub sel_paths: Vec<String>,
51 pub seen_selected: Option<Id>,
53 pub search: String,
54 pub kind_filter: u8,
56 pub label_filter: u8,
58 pub unused_only: bool,
59 pub sort: u8,
61 pub view: u8,
63 pub zoom: f32,
65 pub folder: Option<String>,
68 pub flipped: Vec<String>,
71 pub rename_folder: Option<(String, String)>,
73 pub new_folder: Option<(String, String)>,
75 pub tags_for: Option<Id>,
77 pub tags_buf: String,
78 pub rename_seq: Option<(Id, String)>,
79 pub rename_template: Option<(usize, String)>,
80 pub recent_tags_for: Option<String>,
82 pub recent_tags_buf: String,
83 pub dirs: Vec<(String, Option<Vec<(String, bool)>>)>,
86}
87
88#[derive(Default)]
89pub struct LibraryResponse {
90 pub import: bool,
91 pub add_to_timeline: Vec<Id>,
92 pub open_paths: Vec<PathBuf>,
94 pub remove: Vec<Id>,
95 pub clear_recent: bool,
96 pub edited: bool,
98 pub settings_changed: bool,
100 pub convert: Vec<(Id, String)>,
102 pub regen_proxy: Vec<String>,
104 pub convert_dialog: Option<Id>,
106 pub compress: Option<Id>,
108 pub open_sequence: Option<Id>,
110 pub place_template: Vec<String>,
112 pub import_url: bool,
114 pub edit_labels: bool,
116 pub new_adjustment: bool,
118 pub open_dialog: bool,
120 pub add_effect: Option<EffectKind>,
122 pub apply_preset: Option<usize>,
124 pub copy_graph: Option<Id>,
126 pub preview: Option<PathBuf>,
129}
130
131type Labels = Vec<(String, [u8; 3])>;
133
134enum LabelPick {
136 Set(u8),
137 Edit,
138}
139
140#[derive(Clone, PartialEq, Debug)]
142enum Pick {
143 Asset(Id),
144 Path(String),
145}
146
147enum LibOp {
149 AssetLabel(Id, u8),
150 AssetFolder(Id, String),
151 AssetDesc(Id, String),
152 AssetTags(Id, Vec<String>),
153 FolderNew(String),
154 FolderRename(String, String),
155 FolderDelete(String),
156 LinkFolder(String),
157 UnlinkFolder(String),
158 SeqNew,
159 SeqRename(Id, String),
160 SeqDelete(Id),
161 RemoveUnused,
162}
163
164#[derive(Clone, Copy)]
167pub struct PreviewFrame {
168 pub tex: egui::TextureId,
169 pub size: [u32; 2],
170 pub playing: bool,
171}
172
173#[allow(clippy::too_many_arguments)]
177pub fn show(
178 ui: &mut egui::Ui,
179 state: &mut LibraryState,
180 project: &mut Project,
181 settings: &mut Settings,
182 thumbs: Option<&mut ThumbCache>,
183 live: Option<&PreviewFrame>,
184 palette: &Palette,
185 ytdlp: bool,
186 undo: &mut dyn FnMut(&Project),
187) -> LibraryResponse {
188 let mut resp = LibraryResponse::default();
189 let labels: Labels = project.labels.iter().map(|l| (l.name.clone(), l.color)).collect();
190 let mut thumbs = thumbs;
191 state.zoom = if state.zoom > 0.0 { state.zoom.clamp(ZOOM_MIN, ZOOM_MAX) } else { 1.0 };
192 external_select(state);
193 state.sel_ids.retain(|id| project.asset(*id).is_some());
194 ui.horizontal(|ui| {
195 ui.selectable_value(&mut state.tab, 0, "Imported");
196 ui.selectable_value(&mut state.tab, 1, "Global");
197 });
198 ui.separator();
199 browser(ui, state, project, settings, &mut thumbs, live, &labels, palette, ytdlp, &mut resp, undo);
200 state.seen_selected = state.selected;
201 resp
202}
203
204fn external_select(state: &mut LibraryState) {
207 if state.selected == state.seen_selected {
208 return;
209 }
210 state.sel_ids.clear();
211 state.sel_paths.clear();
212 state.sel_path = None;
213 if let Some(id) = state.selected {
214 state.sel_ids.push(id);
215 }
216}
217
218impl LibraryState {
219 fn has(&self, p: &Pick) -> bool {
220 match p {
221 Pick::Asset(id) => self.sel_ids.contains(id),
222 Pick::Path(s) => self.sel_paths.iter().any(|x| x == s),
223 }
224 }
225
226 fn anchor(&self) -> Option<Pick> {
228 match (self.selected, &self.sel_path) {
229 (Some(id), _) => Some(Pick::Asset(id)),
230 (None, Some(p)) => Some(Pick::Path(p.clone())),
231 _ => None,
232 }
233 }
234
235 fn set_anchor(&mut self, p: &Pick) {
236 match p {
237 Pick::Asset(id) => (self.selected, self.sel_path) = (Some(*id), None),
238 Pick::Path(s) => (self.selected, self.sel_path) = (None, Some(s.clone())),
239 }
240 self.seen_selected = self.selected;
241 }
242
243 fn add_sel(&mut self, p: &Pick) {
244 if self.has(p) {
245 return;
246 }
247 match p {
248 Pick::Asset(id) => self.sel_ids.push(*id),
249 Pick::Path(s) => self.sel_paths.push(s.clone()),
250 }
251 }
252
253 fn drop_sel(&mut self, p: &Pick) {
254 match p {
255 Pick::Asset(id) => self.sel_ids.retain(|x| x != id),
256 Pick::Path(s) => self.sel_paths.retain(|x| x != s),
257 }
258 }
259
260 fn clear_sel(&mut self) {
261 self.sel_ids.clear();
262 self.sel_paths.clear();
263 self.selected = None;
264 self.sel_path = None;
265 self.seen_selected = None;
266 }
267}
268
269fn apply_click(state: &mut LibraryState, rows: &[(Pick, egui::Rect)], pick: &Pick, ctrl: bool, shift: bool) {
273 let at = |p: &Pick| rows.iter().position(|(q, _)| q == p);
274 if shift {
275 if let (Some(i), Some(j)) = (state.anchor().as_ref().and_then(&at), at(pick)) {
276 state.sel_ids.clear();
277 state.sel_paths.clear();
278 for (q, _) in &rows[i.min(j)..=i.max(j)] {
279 state.add_sel(q);
280 }
281 return;
282 }
283 }
284 if ctrl {
285 if state.has(pick) {
286 state.drop_sel(pick);
287 } else {
288 state.add_sel(pick);
289 }
290 } else {
291 state.sel_ids.clear();
292 state.sel_paths.clear();
293 state.add_sel(pick);
294 }
295 state.set_anchor(pick);
296}
297
298fn band_select(ui: &egui::Ui, state: &mut LibraryState, rows: &[(Pick, egui::Rect)], palette: &Palette) {
302 let (down, origin, pos) =
303 ui.input(|i| (i.pointer.primary_down(), i.pointer.press_origin(), i.pointer.interact_pos()));
304 let (Some(origin), Some(pos)) = (origin, pos) else { return };
305 let area = ui.clip_rect();
306 if !down || !area.contains(origin) || (pos - origin).length() < 8.0 {
307 return;
308 }
309 if egui::DragAndDrop::has_any_payload(ui.ctx()) || rows.iter().any(|(_, r)| r.contains(origin)) {
310 return;
311 }
312 let band = egui::Rect::from_two_pos(origin, pos);
313 ui.painter().rect(
314 band,
315 0.0,
316 palette.selection.gamma_multiply(0.15),
317 egui::Stroke::new(1.0, palette.selection),
318 egui::StrokeKind::Inside,
319 );
320 state.sel_ids.clear();
321 state.sel_paths.clear();
322 for (p, r) in rows {
323 if band.intersects(*r) {
324 state.add_sel(p);
325 }
326 }
327}
328
329fn kind_tag(k: ClipKind) -> &'static str {
332 match k {
333 ClipKind::Video => "V",
334 ClipKind::Audio => "A",
335 ClipKind::Image => "I",
336 ClipKind::Text => "T",
337 ClipKind::Sequence => "S",
338 ClipKind::Shape => "Sh",
339 ClipKind::Adjustment => "Adj",
340 }
341}
342
343fn matches_search(a: &crate::model::Asset, q: &str) -> bool {
348 if q.is_empty() {
349 return true;
350 }
351 let q = q.to_lowercase();
352 a.name().to_lowercase().contains(&q)
353 || a.description.to_lowercase().contains(&q)
354 || a.tags.iter().any(|t| t.to_lowercase().contains(&q))
355}
356
357fn matches_kind(kind: ClipKind, duration: f64, f: u8) -> bool {
360 match f {
361 1 => kind == ClipKind::Video,
362 2 => kind == ClipKind::Audio,
363 3 => kind == ClipKind::Image,
364 4 => false,
365 5 => kind == ClipKind::Audio && duration <= 10.0,
366 6 => kind == ClipKind::Audio && duration > 10.0,
367 _ => true,
368 }
369}
370
371fn folder_ok(sel: Option<&str>, folder: &str) -> bool {
373 match sel {
374 None => true,
375 Some("") => folder.is_empty(),
376 Some(s) => {
377 folder == s || (folder.len() > s.len() && folder.starts_with(s) && folder.as_bytes()[s.len()] == b'/')
378 }
379 }
380}
381
382fn kind_rank(k: ClipKind) -> u8 {
383 match k {
384 ClipKind::Video => 0,
385 ClipKind::Audio => 1,
386 ClipKind::Image => 2,
387 ClipKind::Text => 3,
388 ClipKind::Sequence => 4,
389 ClipKind::Shape => 5,
390 ClipKind::Adjustment => 6,
391 }
392}
393
394fn rename_folder(project: &mut Project, old: &str, new: &str) {
396 let swap = |s: &mut String| {
397 if s == old {
398 *s = new.to_string();
399 } else if let Some(rest) = s.strip_prefix(&format!("{old}/")) {
400 *s = format!("{new}/{rest}");
401 }
402 };
403 for f in &mut project.folders {
404 swap(f);
405 }
406 for a in &mut project.assets {
407 swap(&mut a.folder);
408 }
409 project.folders.sort();
410 project.folders.dedup();
411}
412
413fn delete_sequence(project: &mut Project, id: Id) {
415 if project.editing == Some(id) {
416 project.close_sequence();
417 }
418 project.sequences.retain(|s| s.id != id);
419 let rm = |tracks: &mut Vec<crate::model::Track>| {
420 for t in tracks {
421 t.clips.retain(|c| !(c.kind == ClipKind::Sequence && c.sequence == id));
422 }
423 };
424 rm(&mut project.tracks);
425 if let Some(st) = &mut project.main_stash {
426 rm(&mut st.tracks);
427 }
428 for s in &mut project.sequences {
429 rm(&mut s.tracks);
430 }
431 project.tidy();
432}
433
434const VIDEO_EXTS: &[&str] =
438 &["mp4", "mov", "mkv", "webm", "avi", "m4v", "wmv", "ts", "m2ts", "mts", "flv", "3gp", "mpg", "mpeg", "gif"];
439const AUDIO_EXTS: &[&str] = &["mp3", "wav", "m4a", "aac", "flac", "ogg", "opus", "wma"];
440const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "bmp", "webp", "tif", "tiff"];
441
442fn ext_class(path: &str) -> u8 {
444 let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
445 if VIDEO_EXTS.contains(&ext.as_str()) {
446 1
447 } else if AUDIO_EXTS.contains(&ext.as_str()) {
448 2
449 } else if IMAGE_EXTS.contains(&ext.as_str()) {
450 3
451 } else {
452 0
453 }
454}
455
456fn path_matches(path: &str, q: &str, kind: u8) -> bool {
459 let class_ok = match kind {
460 1 | 3 => ext_class(path) == kind,
461 2 | 5 | 6 => ext_class(path) == 2,
462 4 => false,
463 _ => true,
464 };
465 class_ok && (q.is_empty() || split_path(path).0.to_lowercase().contains(&q.to_lowercase()))
467}
468
469fn recent_matches(r: &RecentAsset, q: &str, kind: u8, label: u8) -> bool {
471 if label != 0 && r.label != label {
472 return false;
473 }
474 if !path_matches(&r.path, "", kind) {
475 return false;
476 }
477 q.is_empty()
478 || path_matches(&r.path, q, kind)
479 || r.tags.iter().any(|t| t.to_lowercase().contains(&q.to_lowercase()))
480}
481
482fn recent_remove(settings: &mut Settings, resp: &mut LibraryResponse, path: &str) {
483 settings.remove_recent(path);
484 resp.settings_changed = true;
485}
486
487fn recent_clear(settings: &mut Settings, resp: &mut LibraryResponse) {
488 settings.recent_assets.clear();
489 resp.settings_changed = true;
490}
491
492fn scan_dir(dir: &str) -> Option<Vec<(String, bool)>> {
494 let mut v: Vec<(String, bool)> = std::fs::read_dir(dir)
495 .ok()?
496 .flatten()
497 .filter_map(|e| {
498 let p = e.path().to_string_lossy().into_owned();
499 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
500 (is_dir || ext_class(&p) != 0).then_some((p, is_dir))
501 })
502 .collect();
503 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())));
505 Some(v)
506}
507
508fn kind_tag_for_class(c: u8) -> &'static str {
509 match c {
510 1 => "V",
511 2 => "A",
512 3 => "I",
513 _ => "",
514 }
515}
516
517fn split_path(p: &str) -> (&str, &str) {
519 match p.rfind(['\\', '/']) {
520 Some(i) => (&p[i + 1..], &p[..i]),
521 None => (p, ""),
522 }
523}
524
525fn tile_grid<T>(ui: &mut egui::Ui, indent: f32, items: &[T], tile_w: f32, mut draw: impl FnMut(&mut egui::Ui, &T)) {
532 if items.is_empty() {
533 return;
534 }
535 let spacing = ui.spacing().item_spacing.x;
536 let cols = ((ui.available_width() - indent + spacing) / (tile_w + spacing)).floor().max(1.0) as usize;
537 for row in items.chunks(cols) {
538 ui.horizontal(|ui| {
539 ui.add_space(indent);
540 for item in row {
541 draw(ui, item);
542 }
543 });
544 }
545}
546
547#[derive(PartialEq, Debug)]
549enum Art {
550 Image(egui::TextureId, [u32; 2]),
552 Icon(Glyph),
554}
555
556fn file_art(ui: &egui::Ui, thumbs: &mut Option<&mut ThumbCache>, path: &str, h: u32) -> Art {
560 if matches!(ext_class(path), 1 | 3) {
561 if let Some((tex, size)) = thumbs.as_deref_mut().and_then(|c| c.texture(ui.ctx(), path, 0.0, h)) {
562 if size[1] > 0 {
563 return Art::Image(tex, size);
564 }
565 }
566 }
567 Art::Icon(fallback_glyph(ext_class(path)))
568}
569
570fn fallback_glyph(class: u8) -> Glyph {
573 match class {
574 1 => Glyph::FilmStrip,
575 2 => Glyph::SpeakerOn,
576 3 => Glyph::Camera,
577 _ => Glyph::Layers,
578 }
579}
580
581fn paint_art(ui: &egui::Ui, rect: egui::Rect, art: Art, palette: &Palette) {
583 let p = ui.painter();
584 p.rect_filled(rect, 2.0, palette.panel);
585 match art {
586 Art::Image(tex, [w, h]) if h > 0 => {
587 let k = (rect.width() / w as f32).min(rect.height() / h as f32);
588 p.image(
589 tex,
590 egui::Rect::from_center_size(rect.center(), egui::vec2(w as f32 * k, h as f32 * k)),
591 egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
592 egui::Color32::WHITE,
593 );
594 }
595 Art::Icon(g) => draw_glyph(p, rect, g, palette.text_dim),
596 _ => {}
597 }
598}
599
600fn row_art(ui: &mut egui::Ui, thumbs: &mut Option<&mut ThumbCache>, path: &str, palette: &Palette, h: f32) {
602 let art = file_art(ui, thumbs, path, (h * 2.0) as u32);
603 let (rect, _) = ui.allocate_exact_size(egui::vec2(h * 16.0 / 9.0, h), egui::Sense::hover());
604 paint_art(ui, rect, art, palette);
605}
606
607const TILE: f32 = 108.0;
609const ROW_H: f32 = 18.0;
610const ZOOM_MIN: f32 = 0.6;
611const ZOOM_MAX: f32 = 3.0;
612
613pub(crate) fn confirm(title: &str, description: &str) -> bool {
617 rfd::MessageDialog::new()
618 .set_title(title)
619 .set_description(description)
620 .set_buttons(rfd::MessageButtons::YesNo)
621 .show()
622 == rfd::MessageDialogResult::Yes
623}
624
625fn dot(ui: &mut egui::Ui, color: egui::Color32) {
627 let (rect, _) = ui.allocate_exact_size(egui::vec2(10.0, 12.0), egui::Sense::hover());
628 ui.painter().circle_filled(rect.center(), 4.0, color);
629}
630
631fn label_menu(ui: &mut egui::Ui, current: u8, labels: &Labels, palette: &Palette) -> Option<LabelPick> {
633 let mut picked = None;
634 ui.menu_button("Label", |ui| {
635 if ui.selectable_label(current == 0, "None").clicked() {
636 picked = Some(LabelPick::Set(0));
637 ui.close();
638 }
639 for i in 1..=labels.len() as u8 {
640 let r = ui.horizontal(|ui| {
641 dot(ui, lbl_color(labels, i, palette));
642 ui.selectable_label(current == i, lbl_name(labels, i)).clicked()
643 });
644 if r.inner {
645 picked = Some(LabelPick::Set(i));
646 ui.close();
647 }
648 }
649 ui.separator();
650 if ui.button("Edit labels…").clicked() {
651 picked = Some(LabelPick::Edit);
652 ui.close();
653 }
654 });
655 picked
656}
657
658fn lbl_color(labels: &Labels, idx: u8, palette: &Palette) -> egui::Color32 {
660 match labels.get(idx.wrapping_sub(1) as usize) {
661 Some((_, [r, g, b])) if idx > 0 => egui::Color32::from_rgb(*r, *g, *b),
662 _ => label_color(idx, palette),
663 }
664}
665
666fn lbl_name(labels: &Labels, idx: u8) -> &str {
668 match labels.get(idx.wrapping_sub(1) as usize) {
669 Some((n, _)) if idx > 0 => n.as_str(),
670 _ => "None",
671 }
672}
673
674fn row(
677 ui: &mut egui::Ui,
678 id: egui::Id,
679 payload: DragPayload,
680 selected: bool,
681 button: Option<&str>,
682 contents: impl FnOnce(&mut egui::Ui),
683) -> (egui::Response, bool) {
684 ui.horizontal(|ui| {
685 let reserve = button.map_or(0.0, |b| {
687 let font = egui::TextStyle::Button.resolve(ui.style());
688 ui.painter().layout_no_wrap(b.to_owned(), font, egui::Color32::PLACEHOLDER).size().x
689 + ui.spacing().button_padding.x * 2.0
690 + ui.spacing().item_spacing.x
691 });
692 let content_right = ui.max_rect().right() - reserve;
693 let src = crate::ui::drag_source(ui, id, payload, |ui| {
694 ui.set_max_width((content_right - ui.max_rect().left()).max(0.0));
697 ui.set_clip_rect(ui.clip_rect().intersect(egui::Rect::everything_left_of(content_right)));
700 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
701 let bg = ui.painter().add(egui::Shape::Noop);
702 ui.horizontal(|ui| {
703 contents(ui);
704 ui.expand_to_include_x(ui.max_rect().right());
706 });
707 let rect = ui.min_rect();
708 let v = ui.visuals();
709 let fill = if selected {
710 v.selection.bg_fill.gamma_multiply(0.35)
711 } else if ui.rect_contains_pointer(rect) {
712 v.widgets.hovered.weak_bg_fill
713 } else {
714 egui::Color32::TRANSPARENT
715 };
716 ui.painter().set(bg, egui::Shape::rect_filled(rect, 2.0, fill));
717 });
718 let r = src;
721 let clicked = button.is_some_and(|b| {
724 let gap = ui.spacing().item_spacing.x;
725 let slot = egui::Rect::from_min_size(
726 egui::pos2(content_right + gap, r.rect.top()),
727 egui::vec2(reserve - gap, r.rect.height()),
728 );
729 let br = ui.put(slot, egui::Button::new(b).small());
730 #[cfg(test)]
731 ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("row_btn_right"), br.rect.right()));
732 br.clicked()
733 });
734 (r, clicked)
735 })
736 .inner
737}
738
739#[allow(clippy::too_many_arguments)]
742fn tile(
743 ui: &mut egui::Ui,
744 id: egui::Id,
745 payload: DragPayload,
746 selected: bool,
747 tag: &str,
748 name: &str,
749 tint: egui::Color32,
750 palette: &Palette,
751 art: Art,
752 w: f32,
753) -> egui::Response {
754 let src = crate::ui::drag_source(ui, id, payload, |ui| {
755 ui.set_max_width(w);
756 ui.vertical(|ui| {
757 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
758 let (rect, _) = ui.allocate_exact_size(egui::vec2(w, w * 0.56), egui::Sense::hover());
759 paint_art(ui, rect, art, palette);
760 ui.painter().text(
761 rect.left_bottom() + egui::vec2(3.0, -3.0),
762 egui::Align2::LEFT_BOTTOM,
763 tag,
764 egui::TextStyle::Small.resolve(ui.style()),
765 palette.text_dim,
766 );
767 let name = RichText::new(name).color(tint);
768 ui.add(egui::Label::new(if selected { name.strong() } else { name }).truncate());
769 });
770 });
771 let r = src;
772 if selected {
773 ui.painter().rect_stroke(r.rect, 2.0, egui::Stroke::new(1.0, palette.selection), egui::StrokeKind::Inside);
774 }
775 r
776}
777
778pub(crate) fn inline_edit(ui: &mut egui::Ui, buf: &mut String) -> Option<String> {
780 let r = ui.add(egui::TextEdit::singleline(buf).desired_width(120.0));
781 if r.lost_focus() {
784 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
785 return Some(String::new()); }
787 return Some(buf.trim().to_string()); }
789 if !r.has_focus() {
790 r.request_focus();
791 }
792 None
793}
794
795fn dur_cell(a: &crate::model::Asset) -> String {
797 if crate::engine::import::is_probing(&a.path) {
798 "Loading…".to_string()
799 } else {
800 duration_text(a.duration)
801 }
802}
803
804#[allow(clippy::too_many_arguments)]
807#[allow(clippy::too_many_arguments)]
808fn browser(
809 ui: &mut egui::Ui,
810 state: &mut LibraryState,
811 project: &mut Project,
812 settings: &mut Settings,
813 thumbs: &mut Option<&mut ThumbCache>,
814 live: Option<&PreviewFrame>,
815 labels: &Labels,
816 palette: &Palette,
817 ytdlp: bool,
818 resp: &mut LibraryResponse,
819 undo: &mut dyn FnMut(&Project),
820) {
821 let mut ops: Vec<LibOp> = Vec::new();
822 let mut op_start = false; let imported = state.tab == 0;
824
825 let mut import = false;
826 let mut new_folder = false;
827 let mut resp_open = false;
828 let mut sort = state.sort;
829 let mut import_url = false;
830 let mut new_seq = false;
831 let mut new_adj = false;
832 let mut link = false;
833 let mut clear_recent = false;
834 let unused_n = {
837 let (u, pl) = (project.used_assets(), project.plan_assets());
838 project.assets.iter().filter(|a| !u.contains(&a.id) && !pl.contains(&a.id)).count()
839 };
840 let mut remove_unused = false;
841 toolbar(ui, state, labels, palette, |ui, state| {
842 if imported {
843 let r = glyph_text_button(ui, Glyph::Letter('+'), "New");
844 egui::Popup::menu(&r).show(|ui| {
845 if ui.button("Import files…").clicked() {
846 import = true;
847 ui.close();
848 }
849 if ui.button("Folder…").clicked() {
850 new_folder = true;
851 ui.close();
852 }
853 if ui.button("Sequence").clicked() {
854 new_seq = true;
855 ui.close();
856 }
857 if ui.button("Adjustment layer").clicked() {
858 new_adj = true;
859 ui.close();
860 }
861 });
862 } else if ui.button("Link folder…").clicked() {
863 link = true;
864 }
865 if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::Folder, "Open…").clicked() {
866 resp_open = true;
867 }
868 if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::FilmReel, "Import…").clicked() {
869 import = true;
870 }
871 if ytdlp
873 && crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::ImportArrow, "Import URL…")
874 .on_hover_text("Download media from a link with yt-dlp")
875 .clicked()
876 {
877 import_url = true;
878 }
879 if !imported && ui.button("Clear recent").clicked() && confirm("Clear recent", CLEAR_RECENT) {
880 clear_recent = true;
881 }
882 if imported
884 && ui.add_enabled(unused_n > 0, egui::Button::new(format!("Remove unused ({unused_n})"))).clicked()
885 && confirm("Remove unused", &format!("Remove {unused_n} unused assets from the project?"))
886 {
887 remove_unused = true;
888 }
889 egui::ComboBox::from_id_salt("lib_sort")
890 .selected_text(["Name", "Duration", "Kind", "Recent"][state.sort.min(3) as usize])
891 .width(80.0)
892 .show_ui(ui, |ui| {
893 for (i, n) in ["Name", "Duration", "Kind", "Recent"].iter().enumerate() {
894 ui.selectable_value(&mut sort, i as u8, *n);
895 }
896 });
897 });
898 state.sort = sort;
899 if remove_unused {
900 ops.push(LibOp::RemoveUnused);
901 op_start = true;
902 }
903 resp.open_dialog |= resp_open;
904 resp.new_adjustment |= new_adj;
905 resp.import |= import;
906 resp.import_url |= import_url;
907 if new_seq {
908 ops.push(LibOp::SeqNew);
909 op_start = true;
910 }
911 if new_folder {
912 state.new_folder = Some((state.folder.clone().unwrap_or_default(), String::new()));
913 }
914 if link {
915 if let Some(p) = rfd::FileDialog::new().pick_folder() {
916 ops.push(LibOp::LinkFolder(p.to_string_lossy().into_owned()));
917 op_start = true;
918 }
919 }
920 if clear_recent {
921 recent_clear(settings, resp);
922 resp.clear_recent = true;
923 }
924 batch_strip(ui, state, resp);
925
926 let used = project.used_assets();
929 let planned = project.plan_assets();
930 let flat = !state.search.is_empty() || state.kind_filter != 0 || state.label_filter != 0 || state.unused_only;
932
933 preview_panel(ui, state, project, settings, thumbs, live, labels, palette, resp, &mut ops, &mut op_start);
935
936 let mut rows: Vec<(Pick, egui::Rect)> = Vec::new();
937 let mut click: Option<(Pick, bool, bool)> = None;
938 let mut rec: Option<RecOp> = None;
939 let source = egui::scroll_area::ScrollSource { drag: false, ..Default::default() };
941 egui::ScrollArea::vertical().auto_shrink(false).scroll_source(source).show(ui, |ui| {
942 zoom_scroll(ui, state);
943 let mut order: Vec<usize> = (0..project.assets.len())
945 .filter(|&i| {
946 let a = &project.assets[i];
947 (!flat || folder_ok(state.folder.as_deref(), &a.folder))
949 && matches_search(a, &state.search)
950 && matches_kind(a.kind, a.duration, state.kind_filter)
951 && (state.label_filter == 0 || a.label == state.label_filter)
952 && (!state.unused_only || !(used.contains(&a.id) || planned.contains(&a.id)))
954 })
955 .collect();
956 match state.sort {
957 1 => order.sort_by(|&x, &y| project.assets[x].duration.total_cmp(&project.assets[y].duration)),
958 2 => order.sort_by_key(|&i| kind_rank(project.assets[i].kind)),
959 3 => order.sort_by_key(|&i| std::cmp::Reverse(project.assets[i].id)),
960 _ => order.sort_by_cached_key(|&i| project.assets[i].name().to_lowercase()),
961 }
962 {
963 let mut tree = Tree {
964 state: &mut *state,
965 project: &*project,
966 settings: &*settings,
967 used: &used,
968 labels,
969 palette,
970 thumbs: &mut *thumbs,
971 resp: &mut *resp,
972 ops: &mut ops,
973 op_start: &mut op_start,
974 rows: &mut rows,
975 click: &mut click,
976 rec: &mut rec,
977 };
978 if imported {
979 tree.imported(ui, &order, flat);
980 } else {
981 tree.global(ui);
982 }
983 }
984 if let Some((pick, ctrl, shift)) = click.take() {
985 apply_click(state, &rows, &pick, ctrl, shift);
986 }
987 if imported {
988 reuse_ui(ui, state.view, state.kind_filter, state.zoom, project, settings, palette, resp);
990 }
991
992 let rest = ui.available_size();
994 if rest.y > 1.0 && ui.allocate_response(rest, egui::Sense::click()).clicked() {
995 state.clear_sel();
996 state.folder = None;
997 }
998 band_select(ui, state, &rows, palette);
999 });
1000 match rec {
1001 Some(RecOp::Pin(path)) => {
1002 if let Some(r) = settings.recent_assets.iter_mut().find(|r| r.path == path) {
1003 r.pinned = !r.pinned;
1004 }
1005 settings.sort_recent();
1006 resp.settings_changed = true;
1007 }
1008 Some(RecOp::Label(path, l)) => {
1009 if let Some(r) = settings.recent_assets.iter_mut().find(|r| r.path == path) {
1010 r.label = l;
1011 }
1012 resp.settings_changed = true;
1013 }
1014 Some(RecOp::Remove(path)) => recent_remove(settings, resp, &path),
1015 None => {}
1016 }
1017
1018 if op_start {
1021 undo(project);
1022 }
1023 if !ops.is_empty() {
1024 for op in ops {
1025 match op {
1026 LibOp::AssetLabel(id, l) => {
1027 if let Some(a) = project.asset_mut(id) {
1028 a.label = l;
1029 }
1030 }
1031 LibOp::AssetFolder(id, f) => {
1032 if let Some(a) = project.asset_mut(id) {
1033 a.folder = f;
1034 }
1035 }
1036 LibOp::AssetDesc(id, d) => {
1037 if let Some(a) = project.asset_mut(id) {
1038 a.description = d;
1039 }
1040 }
1041 LibOp::AssetTags(id, t) => {
1042 if let Some(a) = project.asset_mut(id) {
1043 a.tags = t;
1044 }
1045 }
1046 LibOp::FolderNew(name) => {
1047 project.add_folder(&name);
1048 }
1049 LibOp::FolderRename(old, new) => rename_folder(project, &old, &new),
1050 LibOp::FolderDelete(name) => {
1051 project.remove_folder(&name);
1052 if state
1053 .folder
1054 .as_deref()
1055 .is_some_and(|f| !folder_ok(Some(""), f) && !project.folder_names().iter().any(|n| n == f))
1056 {
1057 state.folder = None;
1058 }
1059 }
1060 LibOp::LinkFolder(p) => {
1061 if !project.linked_folders.contains(&p) {
1062 project.linked_folders.push(p);
1063 }
1064 }
1065 LibOp::UnlinkFolder(p) => {
1066 project.linked_folders.retain(|f| *f != p);
1067 state.dirs.retain(|(f, _)| !f.starts_with(&p));
1068 }
1069 LibOp::SeqNew => {
1070 let n = project.sequences.len() + 1;
1071 let (w, h, fps) = (project.width, project.height, project.fps);
1072 project.new_sequence(format!("Sequence {n}"), w, h, fps);
1073 }
1074 LibOp::SeqRename(id, name) => {
1075 if let Some(s) = project.sequence_mut(id) {
1076 s.name = name;
1077 }
1078 }
1079 LibOp::SeqDelete(id) => delete_sequence(project, id),
1080 LibOp::RemoveUnused => {
1081 project.remove_unused_assets();
1082 }
1083 }
1084 }
1085 resp.edited = true;
1086 }
1087}
1088
1089fn zoom_scroll(ui: &egui::Ui, state: &mut LibraryState) {
1091 let hovering = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| ui.clip_rect().contains(p));
1092 if !hovering {
1093 return;
1094 }
1095 let z = ui.input(|i| i.zoom_delta());
1097 if (z - 1.0).abs() > 1e-4 {
1098 state.zoom = (state.zoom * z).clamp(ZOOM_MIN, ZOOM_MAX);
1099 }
1100}
1101
1102fn batch_strip(ui: &mut egui::Ui, state: &LibraryState, resp: &mut LibraryResponse) {
1107 let n = state.sel_ids.len() + state.sel_paths.len();
1108 if n == 0 {
1109 return;
1110 }
1111 ui.horizontal_wrapped(|ui| {
1112 ui.spacing_mut().item_spacing.x = 3.0;
1113 ui.weak(RichText::new(format!("{n} selected")).small());
1114 if !state.sel_paths.is_empty() && ui.small_button("Import").clicked() {
1115 resp.open_paths.extend(state.sel_paths.iter().map(PathBuf::from));
1116 }
1117 if state.sel_ids.is_empty() {
1118 return;
1119 }
1120 if !state.sel_ids.is_empty() && ui.small_button("Add to timeline").clicked() {
1121 resp.add_to_timeline.extend(state.sel_ids.iter().copied());
1122 }
1123 ui.menu_button("Convert To", |ui| {
1124 for t in crate::engine::convert::TARGETS {
1125 if ui.button(*t).clicked() {
1126 resp.convert.extend(state.sel_ids.iter().map(|id| (*id, (*t).to_string())));
1127 ui.close();
1128 }
1129 }
1130 });
1131 if ui.small_button("Convert…").clicked() {
1132 resp.convert_dialog = state.sel_ids.first().copied();
1133 }
1134 if ui.small_button("Compress…").clicked() {
1135 resp.compress = state.sel_ids.first().copied();
1136 }
1137 if ui.small_button("Remove from project").clicked() {
1138 resp.remove.extend(state.sel_ids.iter().copied());
1139 }
1140 });
1141 ui.separator();
1142}
1143
1144fn toolbar(
1147 ui: &mut egui::Ui,
1148 state: &mut LibraryState,
1149 labels: &Labels,
1150 palette: &Palette,
1151 head: impl FnOnce(&mut egui::Ui, &mut LibraryState),
1152) {
1153 egui::Frame::new().fill(palette.panel).inner_margin(egui::Margin::symmetric(4, 3)).show(ui, |ui| {
1154 ui.horizontal_wrapped(|ui| head(ui, state));
1156 ui.horizontal(|ui| {
1157 let (mag, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
1158 draw_glyph(ui.painter(), mag, Glyph::Zoom, palette.text_dim);
1159 let clear_w = 26.0;
1160 let margin = ui.spacing().button_padding.x * 2.0;
1163 let w = (ui.available_width() - clear_w - ui.spacing().item_spacing.x - margin).max(24.0);
1164 let r = ui.add(egui::TextEdit::singleline(&mut state.search).hint_text("search").desired_width(w));
1165 #[cfg(test)]
1166 ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("lib_search_rect"), r.rect));
1167 let _ = r;
1168 let id = egui::Id::new("lib_clear_search");
1169 if icon_button(ui, palette, id, Glyph::Letter('X'), "Clear search", false).clicked() {
1170 state.search.clear();
1171 }
1172 });
1173 ui.horizontal_wrapped(|ui| {
1174 ui.spacing_mut().item_spacing.x = 3.0;
1175 ui.weak(RichText::new("View").small());
1176 for (i, n) in ["List", "Gallery"].iter().enumerate() {
1177 if ui.selectable_label(state.view == i as u8, RichText::new(*n).small()).clicked() {
1178 state.view = i as u8;
1179 }
1180 }
1181 if icon_button(ui, palette, egui::Id::new("lib_zoom_out"), Glyph::Letter('-'), "Smaller", false).clicked() {
1182 state.zoom = (state.zoom / 1.25).clamp(ZOOM_MIN, ZOOM_MAX);
1183 }
1184 if icon_button(ui, palette, egui::Id::new("lib_zoom_in"), Glyph::Letter('+'), "Bigger", false).clicked() {
1185 state.zoom = (state.zoom * 1.25).clamp(ZOOM_MIN, ZOOM_MAX);
1186 }
1187 ui.separator();
1188 ui.weak(RichText::new("Filters").small());
1189 for (i, n) in ["All", "Video", "Audio", "Image", "Seq", "Short SFX", "Music"].iter().enumerate() {
1190 if ui.selectable_label(state.kind_filter == i as u8, RichText::new(*n).small()).clicked() {
1191 state.kind_filter = i as u8;
1192 }
1193 }
1194 for i in 1..=labels.len() as u8 {
1195 let id = egui::Id::new(("lib_label_chip", i));
1196 let on = state.label_filter == i;
1197 let (rect, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
1198 let r = ui.interact(rect, id, egui::Sense::click());
1199 ui.painter().circle_filled(rect.center(), if on { 6.0 } else { 4.5 }, lbl_color(labels, i, palette));
1200 if r.on_hover_text(lbl_name(labels, i)).clicked() {
1201 state.label_filter = if on { 0 } else { i };
1202 }
1203 }
1204 ui.toggle_value(&mut state.unused_only, RichText::new("Unused").small());
1205 });
1206 });
1207 ui.separator();
1208}
1209
1210#[allow(clippy::too_many_arguments)]
1215#[allow(clippy::too_many_arguments)]
1216fn preview_panel(
1217 ui: &mut egui::Ui,
1218 state: &mut LibraryState,
1219 project: &Project,
1220 settings: &mut Settings,
1221 thumbs: &mut Option<&mut ThumbCache>,
1222 live: Option<&PreviewFrame>,
1223 labels: &Labels,
1224 palette: &Palette,
1225 resp: &mut LibraryResponse,
1226 ops: &mut Vec<LibOp>,
1227 op_start: &mut bool,
1228) {
1229 egui::TopBottomPanel::bottom("lib_preview")
1230 .resizable(true)
1231 .height_range(48.0..=420.0)
1232 .default_height(178.0)
1233 .show_inside(ui, |ui| {
1234 egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| match state.anchor() {
1235 Some(Pick::Asset(id)) if project.asset(id).is_some() => {
1236 asset_preview(ui, state, project, id, thumbs, live, labels, palette, resp, ops, op_start)
1237 }
1238 Some(Pick::Path(p)) => path_preview(ui, state, settings, thumbs, live, palette, resp, &p),
1239 _ => {
1240 ui.weak("Select a file to preview it");
1241 }
1242 });
1243 });
1244}
1245
1246#[allow(clippy::too_many_arguments)]
1248fn preview_head(
1249 ui: &mut egui::Ui,
1250 thumbs: &mut Option<&mut ThumbCache>,
1251 live: Option<&PreviewFrame>,
1252 palette: &Palette,
1253 path: &str,
1254 meta: &str,
1255 extra: impl FnOnce(&mut egui::Ui),
1256) {
1257 ui.horizontal(|ui| {
1258 let h = 84.0;
1259 let art = match live {
1261 Some(f) => Art::Image(f.tex, f.size),
1262 None => file_art(ui, thumbs, path, (h * 2.0) as u32),
1263 };
1264 let (rect, _) = ui.allocate_exact_size(egui::vec2(h * 16.0 / 9.0, h), egui::Sense::hover());
1265 paint_art(ui, rect, art, palette);
1266 ui.vertical(|ui| {
1267 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
1268 ui.strong(split_path(path).0);
1269 ui.add(egui::Label::new(RichText::new(path).weak().small()).truncate()).on_hover_text(path);
1270 ui.weak(meta);
1271 extra(ui);
1272 });
1273 });
1274}
1275
1276#[allow(clippy::too_many_arguments)]
1277#[allow(clippy::too_many_arguments)]
1278fn asset_preview(
1279 ui: &mut egui::Ui,
1280 state: &mut LibraryState,
1281 project: &Project,
1282 id: Id,
1283 thumbs: &mut Option<&mut ThumbCache>,
1284 live: Option<&PreviewFrame>,
1285 labels: &Labels,
1286 palette: &Palette,
1287 resp: &mut LibraryResponse,
1288 ops: &mut Vec<LibOp>,
1289 op_start: &mut bool,
1290) {
1291 let Some(a) = project.asset(id) else { return };
1292 if state.tags_for != Some(a.id) {
1293 state.tags_for = Some(a.id);
1294 state.tags_buf = a.tags.join(", ");
1295 }
1296 let mut line = format!("{} · {}", kind_tag(a.kind), dur_cell(a));
1297 if a.width > 0 {
1298 line.push_str(&format!(" · {}×{}", a.width, a.height));
1299 }
1300 if a.kind == ClipKind::Video && a.fps > 0.0 {
1301 line.push_str(&format!(" · {:.3} fps", a.fps));
1302 }
1303 if !a.audio_streams.is_empty() {
1304 line.push_str(&format!(" · {} audio", a.audio_streams.len()));
1305 }
1306 preview_head(ui, thumbs, live, palette, &a.path, &line, |ui| {
1307 ui.horizontal(|ui| {
1308 dot(ui, lbl_color(labels, a.label, palette));
1309 egui::ComboBox::from_id_salt("asset_label").selected_text(lbl_name(labels, a.label)).show_ui(ui, |ui| {
1310 let mut pick = |l: u8, ui: &mut egui::Ui| {
1311 ops.push(LibOp::AssetLabel(a.id, l));
1312 *op_start = true;
1313 ui.close();
1314 };
1315 if ui.selectable_label(a.label == 0, "None").clicked() {
1316 pick(0, ui);
1317 }
1318 for i in 1..=labels.len() as u8 {
1319 let hit = ui.horizontal(|ui| {
1320 dot(ui, lbl_color(labels, i, palette));
1321 ui.selectable_label(a.label == i, lbl_name(labels, i)).clicked()
1322 });
1323 if hit.inner {
1324 pick(i, ui);
1325 }
1326 }
1327 ui.separator();
1328 if ui.button("Edit labels…").clicked() {
1329 resp.edit_labels = true;
1330 ui.close();
1331 }
1332 });
1333 let folder_text = if a.folder.is_empty() { "Root" } else { a.folder.as_str() };
1334 egui::ComboBox::from_id_salt("asset_folder").selected_text(folder_text).show_ui(ui, |ui| {
1335 if ui.selectable_label(a.folder.is_empty(), "Root").clicked() {
1336 ops.push(LibOp::AssetFolder(a.id, String::new()));
1337 *op_start = true;
1338 ui.close();
1339 }
1340 for f in project.folder_names() {
1341 if ui.selectable_label(a.folder == f, &f).clicked() {
1342 ops.push(LibOp::AssetFolder(a.id, f.clone()));
1343 *op_start = true;
1344 ui.close();
1345 }
1346 }
1347 });
1348 });
1349 });
1350 let mut desc = a.description.clone();
1351 let r = ui.add(
1352 egui::TextEdit::multiline(&mut desc).desired_rows(2).desired_width(f32::INFINITY).hint_text("description"),
1353 );
1354 if r.changed() {
1355 ops.push(LibOp::AssetDesc(a.id, desc));
1356 }
1357 *op_start |= r.gained_focus();
1360 let r = ui.add(
1361 egui::TextEdit::singleline(&mut state.tags_buf)
1362 .desired_width(f32::INFINITY)
1363 .hint_text("tags, comma, separated"),
1364 );
1365 if r.changed() {
1366 ops.push(LibOp::AssetTags(a.id, split_tags(&state.tags_buf)));
1367 }
1368 *op_start |= r.gained_focus();
1369}
1370
1371fn split_tags(s: &str) -> Vec<String> {
1372 s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
1373}
1374
1375#[allow(clippy::too_many_arguments)]
1378fn path_preview(
1379 ui: &mut egui::Ui,
1380 state: &mut LibraryState,
1381 settings: &mut Settings,
1382 thumbs: &mut Option<&mut ThumbCache>,
1383 live: Option<&PreviewFrame>,
1384 palette: &Palette,
1385 resp: &mut LibraryResponse,
1386 path: &str,
1387) {
1388 let meta = kind_tag_for_class(ext_class(path)).to_string();
1389 preview_head(ui, thumbs, live, palette, path, &meta, |ui| {
1390 if ui.button("Import to project").clicked() {
1391 resp.open_paths.push(PathBuf::from(path));
1392 }
1393 });
1394 let Some(i) = settings.recent_assets.iter().position(|r| r.path.eq_ignore_ascii_case(path)) else { return };
1395 if state.recent_tags_for.as_deref() != Some(path) {
1396 state.recent_tags_for = Some(path.to_string());
1397 state.recent_tags_buf = settings.recent_assets[i].tags.join(", ");
1398 }
1399 let r = ui.add(
1400 egui::TextEdit::singleline(&mut state.recent_tags_buf)
1401 .desired_width(f32::INFINITY)
1402 .hint_text("tags, comma, separated"),
1403 );
1404 if r.changed() {
1405 settings.recent_assets[i].tags = split_tags(&state.recent_tags_buf);
1406 resp.settings_changed = true;
1407 }
1408}
1409
1410fn indent(depth: usize) -> f32 {
1415 4.0 + depth as f32 * 14.0
1416}
1417
1418const ARROW: f32 = 14.0;
1420
1421fn parent_of(path: &str) -> &str {
1423 match path.rfind('/') {
1424 Some(i) => &path[..i],
1425 None => "",
1426 }
1427}
1428
1429fn folder_tree_names(project: &Project) -> Vec<String> {
1432 let mut names = project.folder_names();
1433 let mut implied: Vec<String> = Vec::new();
1434 for n in &names {
1435 let mut p = parent_of(n);
1436 while !p.is_empty() {
1437 implied.push(p.to_string());
1438 p = parent_of(p);
1439 }
1440 }
1441 names.append(&mut implied);
1442 names.sort();
1443 names.dedup();
1444 names
1445}
1446
1447fn dir_key(path: &str) -> String {
1449 format!("d:{path}")
1450}
1451
1452struct Tree<'a, 'b> {
1454 state: &'a mut LibraryState,
1455 project: &'a Project,
1456 settings: &'a Settings,
1457 used: &'a std::collections::HashSet<Id>,
1458 labels: &'a Labels,
1459 palette: &'a Palette,
1460 thumbs: &'a mut Option<&'b mut ThumbCache>,
1461 resp: &'a mut LibraryResponse,
1462 ops: &'a mut Vec<LibOp>,
1463 op_start: &'a mut bool,
1464 rows: &'a mut Vec<(Pick, egui::Rect)>,
1466 click: &'a mut Option<(Pick, bool, bool)>,
1468 rec: &'a mut Option<RecOp>,
1471}
1472
1473impl Tree<'_, '_> {
1474 fn is_open(&self, key: &str, dflt: bool) -> bool {
1476 self.state.flipped.iter().any(|k| k == key) != dflt
1477 }
1478
1479 fn flip(&mut self, key: &str) {
1480 match self.state.flipped.iter().position(|k| k == key) {
1481 Some(i) => {
1482 self.state.flipped.remove(i);
1483 }
1484 None => self.state.flipped.push(key.to_string()),
1485 }
1486 }
1487
1488 fn arrow(&mut self, ui: &mut egui::Ui, key: &str, dflt: bool, children: bool) -> bool {
1491 let open = self.is_open(key, dflt);
1492 let (rect, r) = ui.allocate_exact_size(egui::vec2(ARROW, 14.0), egui::Sense::click());
1493 if !children {
1494 return false;
1495 }
1496 let c = rect.center();
1497 let pts = if open {
1498 vec![c + egui::vec2(-4.0, -2.0), c + egui::vec2(4.0, -2.0), c + egui::vec2(0.0, 3.5)]
1499 } else {
1500 vec![c + egui::vec2(-2.0, -4.0), c + egui::vec2(3.5, 0.0), c + egui::vec2(-2.0, 4.0)]
1501 };
1502 let col = if r.hovered() { self.palette.text } else { self.palette.text_dim };
1503 ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE));
1504 if r.clicked() {
1505 self.flip(key);
1506 return !open;
1507 }
1508 open
1509 }
1510
1511 fn folder_icon(&self, ui: &mut egui::Ui, g: Glyph) {
1512 let (rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 14.0), egui::Sense::hover());
1513 draw_glyph(ui.painter(), rect, g, self.palette.text_dim);
1514 }
1515
1516 fn drop_asset(&mut self, r: &egui::Response, folder: &str) {
1519 let Some(p) = r.dnd_release_payload::<DragPayload>() else { return };
1520 let DragPayload::Asset(id) = *p else { return };
1521 let ids: Vec<Id> = if self.state.sel_ids.contains(&id) { self.state.sel_ids.clone() } else { vec![id] };
1522 for id in ids {
1523 self.ops.push(LibOp::AssetFolder(id, folder.to_string()));
1524 }
1525 *self.op_start = true;
1526 }
1527
1528 fn hit(&mut self, ui: &egui::Ui, r: &egui::Response, pick: Pick, path: &str) {
1530 self.rows.push((pick.clone(), r.rect));
1531 if r.clicked() {
1532 let (ctrl, shift) = ui.input(|i| (i.modifiers.command, i.modifiers.shift));
1533 if !shift {
1534 self.resp.preview = Some(PathBuf::from(path));
1535 }
1536 *self.click = Some((pick, ctrl, shift));
1537 } else if r.secondary_clicked() && !self.state.has(&pick) {
1538 *self.click = Some((pick, false, false));
1540 }
1541 }
1542
1543 fn imported(&mut self, ui: &mut egui::Ui, order: &[usize], flat: bool) {
1545 if flat {
1546 self.assets(ui, 0, order);
1547 return;
1548 }
1549 let names = folder_tree_names(self.project);
1550 self.folder(ui, "", 0, &names, order);
1551 }
1552
1553 fn folder(&mut self, ui: &mut egui::Ui, path: &str, depth: usize, names: &[String], order: &[usize]) {
1555 for child in names.iter().filter(|n| parent_of(n) == path) {
1556 let key = format!("f:{child}");
1557 let last = child.rsplit('/').next().unwrap_or(child).to_string();
1558 let kids = names.iter().any(|n| parent_of(n) == child.as_str())
1559 || order.iter().any(|&i| self.project.assets[i].folder == *child);
1560 let mut open = false;
1561 let mut renamed: Option<String> = None;
1562 ui.horizontal(|ui| {
1563 ui.add_space(indent(depth));
1564 open = self.arrow(ui, &key, true, kids);
1565 self.folder_icon(ui, Glyph::Folder);
1566 if self.state.rename_folder.as_ref().is_some_and(|(o, _)| o == child) {
1567 let (_, buf) = self.state.rename_folder.as_mut().unwrap();
1568 renamed = inline_edit(ui, buf);
1569 return;
1570 }
1571 let r = ui.selectable_label(self.state.folder.as_deref() == Some(child.as_str()), &last);
1572 if r.clicked() {
1573 self.state.folder = Some(child.clone());
1574 }
1575 r.context_menu(|ui| {
1576 if ui.button("New folder").clicked() {
1577 self.state.new_folder = Some((child.clone(), String::new()));
1578 ui.close();
1579 }
1580 if ui.button("Rename").clicked() {
1581 self.state.rename_folder = Some((child.clone(), last.clone()));
1582 ui.close();
1583 }
1584 if ui.button("Delete").clicked() {
1585 self.ops.push(LibOp::FolderDelete(child.clone()));
1586 *self.op_start = true;
1587 ui.close();
1588 }
1589 });
1590 self.drop_asset(&r, child);
1591 });
1592 if let Some(new_last) = renamed {
1593 if !new_last.is_empty() && new_last != last {
1594 let parent = parent_of(child);
1595 let new = if parent.is_empty() { new_last } else { format!("{parent}/{new_last}") };
1596 self.ops.push(LibOp::FolderRename(child.clone(), new));
1597 *self.op_start = true;
1598 }
1599 self.state.rename_folder = None;
1600 }
1601 if open {
1602 self.folder(ui, child, depth + 1, names, order);
1603 }
1604 }
1605 if self.state.new_folder.as_ref().is_some_and(|(p, _)| p == path) {
1607 let mut done = None;
1608 ui.horizontal(|ui| {
1609 ui.add_space(indent(depth) + ARROW);
1610 self.folder_icon(ui, Glyph::Folder);
1611 let (_, buf) = self.state.new_folder.as_mut().unwrap();
1612 done = inline_edit(ui, buf);
1613 });
1614 if let Some(name) = done {
1615 if !name.is_empty() {
1616 let full = if path.is_empty() { name } else { format!("{path}/{name}") };
1617 self.ops.push(LibOp::FolderNew(full));
1618 *self.op_start = true;
1619 }
1620 self.state.new_folder = None;
1621 }
1622 }
1623 let here: Vec<usize> = order.iter().copied().filter(|&i| self.project.assets[i].folder == *path).collect();
1624 self.assets(ui, depth, &here);
1625 }
1626
1627 fn assets(&mut self, ui: &mut egui::Ui, depth: usize, list: &[usize]) {
1629 if self.state.view == 1 {
1630 let w = TILE * self.state.zoom;
1631 tile_grid(ui, indent(depth) + ARROW, list, w, |ui, &i| self.asset_tile(ui, i));
1632 } else {
1633 for &i in list {
1634 self.asset_row(ui, depth, i);
1635 }
1636 }
1637 }
1638
1639 fn asset_row(&mut self, ui: &mut egui::Ui, depth: usize, i: usize) {
1640 let a = &self.project.assets[i];
1641 let selected = self.state.sel_ids.contains(&a.id);
1642 let tint = (a.label != 0).then(|| lbl_color(self.labels, a.label, self.palette));
1643 let used = self.used.contains(&a.id);
1644 let (palette, thumbs, h) = (self.palette, &mut *self.thumbs, ROW_H * self.state.zoom);
1645 let (r, add) = row(
1646 ui,
1647 egui::Id::new(("asset", a.id)),
1648 DragPayload::Asset(a.id),
1649 selected,
1650 selected.then_some("Add"),
1651 |ui| {
1652 ui.add_space(indent(depth) + ARROW);
1653 if let Some(c) = tint {
1655 let (bar, _) = ui.allocate_exact_size(egui::vec2(3.0, h), egui::Sense::hover());
1656 ui.painter().rect_filled(bar, 1.0, c);
1657 }
1658 row_art(ui, thumbs, &a.path, palette, h);
1659 let mut name = RichText::new(a.name());
1660 if let Some(c) = tint {
1661 name = name.color(c);
1662 }
1663 ui.label(if selected { name.strong() } else { name });
1664 ui.weak(kind_tag(a.kind));
1665 ui.weak(dur_cell(a));
1666 if used {
1667 let (d, _) = ui.allocate_exact_size(egui::vec2(8.0, 12.0), egui::Sense::hover());
1668 ui.painter().circle_filled(d.center(), 3.0, palette.accent);
1669 ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()).on_hover_text("Used in the timeline");
1670 }
1671 if !a.tags.is_empty() {
1672 ui.add(egui::Label::new(RichText::new(a.tags.join(" · ")).weak().small()).truncate());
1673 }
1674 },
1675 );
1676 let (id, path) = (a.id, a.path.clone());
1677 self.hit(ui, &r, Pick::Asset(id), &path);
1678 if add || r.double_clicked() {
1679 self.resp.add_to_timeline.push(id);
1680 }
1681 self.asset_menu(&r, i);
1682 }
1683
1684 fn asset_tile(&mut self, ui: &mut egui::Ui, i: usize) {
1685 let a = &self.project.assets[i];
1686 let selected = self.state.sel_ids.contains(&a.id);
1687 let tint = (a.label != 0).then(|| lbl_color(self.labels, a.label, self.palette)).unwrap_or(self.palette.text);
1688 let w = TILE * self.state.zoom;
1689 let art = file_art(ui, self.thumbs, &a.path, (w * 0.56 * 2.0) as u32);
1690 let id = egui::Id::new(("tile", a.id));
1691 let payload = DragPayload::Asset(a.id);
1692 let r = tile(ui, id, payload, selected, kind_tag(a.kind), &a.name(), tint, self.palette, art, w);
1693 let (aid, path) = (a.id, a.path.clone());
1694 self.hit(ui, &r, Pick::Asset(aid), &path);
1695 if r.double_clicked() {
1696 self.resp.add_to_timeline.push(aid);
1697 }
1698 self.asset_menu(&r, i);
1699 r.on_hover_text(&path);
1700 }
1701
1702 fn menu_targets(&self, id: Id) -> Vec<Id> {
1704 if self.state.sel_ids.contains(&id) {
1705 self.state.sel_ids.clone()
1706 } else {
1707 vec![id]
1708 }
1709 }
1710
1711 fn asset_menu(&mut self, r: &egui::Response, i: usize) {
1712 let a = &self.project.assets[i];
1713 let (labels, palette) = (self.labels, self.palette);
1714 let ids = self.menu_targets(a.id);
1715 r.context_menu(|ui| {
1716 if ui.button("Add to timeline at playhead").clicked() {
1717 self.resp.add_to_timeline.extend(ids.iter().copied());
1718 ui.close();
1719 }
1720 if ui.button("Reveal folder").clicked() {
1721 let _ = std::process::Command::new("explorer").arg(format!("/select,{}", a.path)).spawn();
1722 ui.close();
1723 }
1724 if a.kind == ClipKind::Video && ui.button("Regenerate proxy").clicked() {
1725 self.resp.regen_proxy.push(a.path.clone());
1726 ui.close();
1727 }
1728 match label_menu(ui, a.label, labels, palette) {
1729 Some(LabelPick::Set(l)) => {
1730 for id in &ids {
1731 self.ops.push(LibOp::AssetLabel(*id, l));
1732 }
1733 *self.op_start = true;
1734 }
1735 Some(LabelPick::Edit) => self.resp.edit_labels = true,
1736 None => {}
1737 }
1738 ui.menu_button("Convert To", |ui| {
1739 for t in crate::engine::convert::TARGETS {
1740 if ui.button(*t).clicked() {
1741 self.resp.convert.extend(ids.iter().map(|id| (*id, (*t).to_string())));
1742 ui.close();
1743 }
1744 }
1745 });
1746 if ui.button("Convert To… (options)").clicked() {
1747 self.resp.convert_dialog = Some(a.id);
1748 ui.close();
1749 }
1750 if ui.button("Compress…").clicked() {
1751 self.resp.compress = Some(a.id);
1752 ui.close();
1753 }
1754 if ui.button("Remove from project").clicked() {
1755 self.resp.remove.extend(ids.iter().copied());
1756 ui.close();
1757 }
1758 });
1759 }
1760
1761 fn global(&mut self, ui: &mut egui::Ui) {
1763 self.recent(ui, 0);
1764 let linked: &[String] = &self.project.linked_folders;
1765 for folder in linked {
1766 self.dir(ui, folder, 0, true);
1767 }
1768 if linked.is_empty() {
1769 ui.horizontal(|ui| {
1770 ui.add_space(indent(0) + ARROW);
1771 ui.weak("(no linked folders — \"Link folder…\" above)");
1772 });
1773 }
1774 }
1775
1776 fn recent(&mut self, ui: &mut egui::Ui, depth: usize) {
1778 let mut open = false;
1779 ui.horizontal(|ui| {
1780 ui.add_space(indent(depth));
1781 open = self.arrow(ui, "recent", false, true);
1782 self.folder_icon(ui, Glyph::Hourglass);
1783 let r = ui.selectable_label(false, "Recent").on_hover_text("Files opened recently, across every project");
1784 if r.clicked() {
1785 self.flip("recent");
1786 open = !open;
1787 }
1788 });
1789 if !open {
1790 return;
1791 }
1792 let (q, kind, label) = (self.state.search.clone(), self.state.kind_filter, self.state.label_filter);
1793 let paths: Vec<String> = self
1794 .settings
1795 .recent_assets
1796 .iter()
1797 .filter(|r| recent_matches(r, &q, kind, label))
1798 .map(|r| r.path.clone())
1799 .collect();
1800 if paths.is_empty() {
1801 ui.horizontal(|ui| {
1802 ui.add_space(indent(depth + 1) + ARROW);
1803 ui.weak("(none)");
1804 });
1805 return;
1806 }
1807 self.files(ui, depth + 1, &paths, true);
1808 }
1809
1810 fn dir(&mut self, ui: &mut egui::Ui, path: &str, depth: usize, root: bool) {
1812 let key = dir_key(path);
1813 let name = path.rsplit(['\\', '/']).find(|s| !s.is_empty()).unwrap_or(path).to_string();
1814 let mut open = false;
1815 ui.horizontal(|ui| {
1816 ui.add_space(indent(depth));
1817 open = self.arrow(ui, &key, false, true);
1818 self.folder_icon(ui, Glyph::Folder);
1819 let r = ui.selectable_label(false, &name);
1820 if r.clicked() {
1821 self.flip(&key);
1822 open = !open;
1823 }
1824 r.context_menu(|ui| {
1825 if ui.button("Refresh").clicked() {
1826 self.state.dirs.retain(|(p, _)| !p.starts_with(path));
1827 ui.close();
1828 }
1829 if root && ui.button("Unlink folder").clicked() {
1830 self.ops.push(LibOp::UnlinkFolder(path.to_string()));
1831 *self.op_start = true;
1832 ui.close();
1833 }
1834 });
1835 r.on_hover_text(path);
1836 });
1837 if !open {
1838 return;
1839 }
1840 let Some(entries) = self.listing(path) else {
1841 ui.horizontal(|ui| {
1842 ui.add_space(indent(depth + 1) + ARROW);
1843 ui.weak("(folder unavailable)");
1844 });
1845 return;
1846 };
1847 if entries.is_empty() {
1848 ui.horizontal(|ui| {
1849 ui.add_space(indent(depth + 1) + ARROW);
1850 ui.weak("(empty)");
1851 });
1852 }
1853 for (p, _) in entries.iter().filter(|(_, d)| *d) {
1854 self.dir(ui, p, depth + 1, false);
1855 }
1856 let files: Vec<String> = entries.into_iter().filter(|(_, d)| !*d).map(|(p, _)| p).collect();
1857 self.files(ui, depth + 1, &files, false);
1858 }
1859
1860 fn listing(&mut self, path: &str) -> Option<Vec<(String, bool)>> {
1864 if let Some((_, v)) = self.state.dirs.iter().find(|(p, _)| p.as_str() == path) {
1865 return v.clone();
1866 }
1867 let v = scan_dir(path);
1868 self.state.dirs.push((path.to_string(), v.clone()));
1869 v
1870 }
1871
1872 fn files(&mut self, ui: &mut egui::Ui, depth: usize, paths: &[String], recent: bool) {
1874 if self.state.view == 1 {
1875 let w = TILE * self.state.zoom;
1876 tile_grid(ui, indent(depth) + ARROW, paths, w, |ui, p| self.file_tile(ui, p, recent));
1877 } else {
1878 for p in paths {
1879 self.file(ui, p, depth, recent);
1880 }
1881 }
1882 }
1883
1884 fn recent_tint(&self, path: &str) -> Option<egui::Color32> {
1886 let r = self.settings.recent_assets.iter().find(|r| r.path.eq_ignore_ascii_case(path))?;
1887 (r.label != 0).then(|| lbl_color(self.labels, r.label, self.palette))
1888 }
1889
1890 fn file(&mut self, ui: &mut egui::Ui, path: &str, depth: usize, recent: bool) {
1892 if !path_matches(path, &self.state.search, self.state.kind_filter) {
1893 return;
1894 }
1895 let selected = self.state.sel_paths.iter().any(|p| p == path);
1896 let tint = if recent { self.recent_tint(path) } else { None };
1897 let (name, dir) = split_path(path);
1898 let (name, dir) = (name.to_string(), dir.to_string());
1899 let (palette, thumbs, h) = (self.palette, &mut *self.thumbs, ROW_H * self.state.zoom);
1900 let id = egui::Id::new(("file", depth, recent, path));
1901 let (r, _) = row(ui, id, DragPayload::Path(path.to_string()), selected, None, |ui| {
1902 ui.add_space(indent(depth) + ARROW);
1903 if let Some(c) = tint {
1904 let (bar, _) = ui.allocate_exact_size(egui::vec2(3.0, h), egui::Sense::hover());
1905 ui.painter().rect_filled(bar, 1.0, c);
1906 }
1907 row_art(ui, thumbs, path, palette, h);
1908 match tint {
1909 Some(c) => ui.label(RichText::new(&name).color(c)),
1910 None => ui.label(&name),
1911 };
1912 ui.weak(kind_tag_for_class(ext_class(path)));
1913 if recent {
1914 ui.add(egui::Label::new(RichText::new(&dir).weak().small()).truncate());
1915 }
1916 });
1917 self.file_click(ui, &r, path, recent);
1918 r.on_hover_text(path);
1919 }
1920
1921 fn file_tile(&mut self, ui: &mut egui::Ui, path: &str, recent: bool) {
1922 if !path_matches(path, &self.state.search, self.state.kind_filter) {
1923 return;
1924 }
1925 let selected = self.state.sel_paths.iter().any(|p| p == path);
1926 let tint = if recent { self.recent_tint(path) } else { None };
1927 let class = ext_class(path);
1928 let w = TILE * self.state.zoom;
1929 let art = file_art(ui, self.thumbs, path, (w * 0.56 * 2.0) as u32);
1930 let name = split_path(path).0.to_string();
1931 let id = egui::Id::new(("file_tile", recent, path));
1932 let payload = DragPayload::Path(path.to_string());
1933 let tint = tint.unwrap_or(self.palette.text);
1934 let r = tile(ui, id, payload, selected, kind_tag_for_class(class), &name, tint, self.palette, art, w);
1935 self.file_click(ui, &r, path, recent);
1936 r.on_hover_text(path);
1937 }
1938
1939 fn file_click(&mut self, ui: &egui::Ui, r: &egui::Response, path: &str, recent: bool) {
1942 self.hit(ui, r, Pick::Path(path.to_string()), path);
1943 if r.double_clicked() {
1944 self.resp.open_paths.push(PathBuf::from(path));
1945 }
1946 let paths: Vec<String> = if self.state.sel_paths.iter().any(|p| p == path) {
1947 self.state.sel_paths.clone()
1948 } else {
1949 vec![path.to_string()]
1950 };
1951 let pinned = self.settings.recent_assets.iter().any(|r| r.path.eq_ignore_ascii_case(path) && r.pinned);
1952 let label =
1953 self.settings.recent_assets.iter().find(|r| r.path.eq_ignore_ascii_case(path)).map_or(0, |r| r.label);
1954 let (labels, palette) = (self.labels, self.palette);
1955 r.context_menu(|ui| {
1956 if ui.button("Add to the project").clicked() {
1957 self.resp.open_paths.extend(paths.iter().map(PathBuf::from));
1958 ui.close();
1959 }
1960 if ui.button("Reveal folder").clicked() {
1961 let _ = std::process::Command::new("explorer").arg(format!("/select,{path}")).spawn();
1962 ui.close();
1963 }
1964 if !recent {
1965 return;
1966 }
1967 if ui.button(if pinned { "Unpin" } else { "Pin" }).clicked() {
1968 *self.rec = Some(RecOp::Pin(path.to_string()));
1969 ui.close();
1970 }
1971 match label_menu(ui, label, labels, palette) {
1972 Some(LabelPick::Set(l)) => *self.rec = Some(RecOp::Label(path.to_string(), l)),
1973 Some(LabelPick::Edit) => self.resp.edit_labels = true,
1974 None => {}
1975 }
1976 if ui.button("Remove from recent").clicked() {
1977 *self.rec = Some(RecOp::Remove(path.to_string()));
1978 ui.close();
1979 }
1980 });
1981 }
1982}
1983
1984enum RecOp {
1987 Pin(String),
1988 Label(String, u8),
1989 Remove(String),
1990}
1991
1992const CLEAR_RECENT: &str = "Clear the whole recent list? Pins, labels and tags are lost (no undo).";
1993
1994fn sequences_section(
1995 ui: &mut egui::Ui,
1996 state: &mut LibraryState,
1997 project: &Project,
1998 palette: &Palette,
1999 resp: &mut LibraryResponse,
2000 ops: &mut Vec<LibOp>,
2001 op_start: &mut bool,
2002) {
2003 ui.add_space(4.0);
2004 ui.horizontal(|ui| {
2005 ui.strong("Sequences");
2006 if ui.small_button("New sequence…").clicked() {
2007 ops.push(LibOp::SeqNew);
2008 *op_start = true;
2009 }
2010 });
2011 for s in &project.sequences {
2012 if state.rename_seq.as_ref().is_some_and(|(id, _)| *id == s.id) {
2013 let (_, buf) = state.rename_seq.as_mut().unwrap();
2014 let mut done = None;
2015 ui.horizontal(|ui| {
2016 glyph(ui, Glyph::FilmStrip, palette);
2017 done = inline_edit(ui, buf);
2018 });
2019 if let Some(name) = done {
2020 if !name.is_empty() {
2021 ops.push(LibOp::SeqRename(s.id, name));
2022 *op_start = true;
2023 }
2024 state.rename_seq = None;
2025 }
2026 continue;
2027 }
2028 let (r, _) = row(ui, egui::Id::new(("seq", s.id)), DragPayload::Sequence(s.id), false, None, |ui| {
2029 glyph(ui, Glyph::FilmStrip, palette);
2030 ui.label(&s.name);
2031 ui.weak(duration_text(project.sequence_duration(s.id)));
2032 });
2033 if r.double_clicked() {
2034 resp.open_sequence = Some(s.id);
2035 }
2036 r.context_menu(|ui| {
2037 if ui.button("Open").clicked() {
2038 resp.open_sequence = Some(s.id);
2039 ui.close();
2040 }
2041 if ui.button("Rename").clicked() {
2042 state.rename_seq = Some((s.id, s.name.clone()));
2043 ui.close();
2044 }
2045 if ui.button("Delete").clicked() {
2046 ops.push(LibOp::SeqDelete(s.id));
2047 *op_start = true;
2048 ui.close();
2049 }
2050 });
2051 }
2052 if project.sequences.is_empty() {
2053 ui.weak("(none — nest clips or \"New sequence…\")");
2054 }
2055}
2056
2057fn glyph(ui: &mut egui::Ui, g: Glyph, palette: &Palette) {
2059 let (rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 14.0), egui::Sense::hover());
2060 draw_glyph(ui.painter(), rect, g, palette.text_dim);
2061}
2062
2063fn templates_section(
2064 ui: &mut egui::Ui,
2065 state: &mut LibraryState,
2066 settings: &mut Settings,
2067 palette: &Palette,
2068 resp: &mut LibraryResponse,
2069) {
2070 if settings.templates.is_empty() {
2071 return;
2072 }
2073 ui.add_space(4.0);
2074 ui.strong("Templates");
2075 let mut rename: Option<(usize, String)> = None;
2076 let mut delete: Option<usize> = None;
2077 for i in 0..settings.templates.len() {
2078 if state.rename_template.as_ref().is_some_and(|(j, _)| *j == i) {
2079 let (_, buf) = state.rename_template.as_mut().unwrap();
2080 let mut done = None;
2081 ui.horizontal(|ui| {
2082 glyph(ui, Glyph::Layers, palette);
2083 done = inline_edit(ui, buf);
2084 });
2085 if let Some(name) = done {
2086 if !name.is_empty() {
2087 rename = Some((i, name));
2088 }
2089 state.rename_template = None;
2090 }
2091 continue;
2092 }
2093 let name = settings.templates[i].name.clone();
2094 let (r, place) =
2095 row(ui, egui::Id::new(("template", i)), DragPayload::Template(name.clone()), false, Some("Place"), |ui| {
2096 glyph(ui, Glyph::Layers, palette);
2097 ui.label(&name);
2098 });
2099 if place || r.double_clicked() {
2100 resp.place_template.push(name.clone());
2101 }
2102 r.context_menu(|ui| {
2103 if ui.button("Place at playhead").clicked() {
2104 resp.place_template.push(name.clone());
2105 ui.close();
2106 }
2107 if ui.button("Rename").clicked() {
2108 state.rename_template = Some((i, name.clone()));
2109 ui.close();
2110 }
2111 if ui.button("Delete").clicked() {
2112 delete = Some(i);
2113 ui.close();
2114 }
2115 });
2116 }
2117 if let Some((i, name)) = rename {
2118 settings.templates[i].name = name;
2119 resp.settings_changed = true;
2120 }
2121 if let Some(i) = delete {
2122 if confirm("Delete template", &format!("Delete the saved template \"{}\"?", settings.templates[i].name)) {
2124 settings.templates.remove(i);
2125 resp.settings_changed = true;
2126 }
2127 }
2128}
2129
2130enum Reuse {
2132 Effect(EffectKind),
2134 Preset(usize),
2136 Graph(Id),
2138 Adjustment(usize),
2140}
2141
2142fn reuse_sections(project: &Project, settings: &Settings) -> Vec<(&'static str, Vec<Reuse>)> {
2146 let mut kinds: Vec<EffectKind> = Vec::new();
2147 let mut graphs: Vec<Reuse> = Vec::new();
2148 for (_, c) in project.all_clips() {
2149 for e in &c.effects {
2150 if !kinds.contains(&e.kind) {
2151 kinds.push(e.kind);
2152 }
2153 }
2154 if c.uses_graph() {
2157 graphs.push(Reuse::Graph(c.id));
2158 }
2159 }
2160 let mut fx: Vec<Reuse> = kinds.into_iter().map(Reuse::Effect).collect();
2161 for (i, p) in settings.effect_presets.iter().enumerate() {
2162 if p.is_graph() {
2163 graphs.push(Reuse::Preset(i));
2164 } else {
2165 fx.push(Reuse::Preset(i));
2166 }
2167 }
2168 let adj = settings
2169 .templates
2170 .iter()
2171 .enumerate()
2172 .filter(|(_, t)| crate::engine::presets::is_adjustment_template(t))
2173 .map(|(i, _)| Reuse::Adjustment(i))
2174 .collect();
2175 vec![("Effects", fx), ("Node graphs", graphs), ("Adjustment layers", adj)]
2176}
2177
2178fn reuse_face(item: &Reuse, project: &Project, settings: &Settings) -> (String, &'static str, Glyph, DragPayload) {
2182 let by_name = |name: String, tag, icon| (name.clone(), tag, icon, DragPayload::Template(name));
2183 match *item {
2184 Reuse::Effect(k) => by_name(k.name().to_string(), "FX", Glyph::Star),
2185 Reuse::Preset(i) => match settings.effect_presets.get(i) {
2186 Some(p) if p.is_graph() => by_name(p.name.clone(), "Graph", Glyph::Nodes),
2187 Some(p) => by_name(p.name.clone(), "FX", Glyph::Star),
2188 None => by_name(String::new(), "FX", Glyph::Star),
2189 },
2190 Reuse::Graph(id) => {
2191 let name = project.clip(id).map(|c| c.name.clone()).unwrap_or_default();
2192 by_name(name, "Graph", Glyph::Nodes)
2193 }
2194 Reuse::Adjustment(i) => {
2195 by_name(settings.templates.get(i).map(|t| t.name.clone()).unwrap_or_default(), "Adj", Glyph::Layers)
2196 }
2197 }
2198}
2199
2200fn reuse_pick(item: &Reuse, name: &str, resp: &mut LibraryResponse) {
2202 match *item {
2203 Reuse::Effect(k) => resp.add_effect = Some(k),
2204 Reuse::Preset(i) => resp.apply_preset = Some(i),
2205 Reuse::Graph(id) => resp.copy_graph = Some(id),
2206 Reuse::Adjustment(_) => resp.place_template.push(name.to_string()),
2207 }
2208}
2209
2210fn reuse_ui(
2212 ui: &mut egui::Ui,
2213 view: u8,
2214 kind_filter: u8,
2215 zoom: f32,
2216 project: &Project,
2217 settings: &Settings,
2218 palette: &Palette,
2219 resp: &mut LibraryResponse,
2220) {
2221 if !matches!(kind_filter, 0 | 7) {
2224 return;
2225 }
2226 for (title, items) in reuse_sections(project, settings) {
2227 let _ = title;
2228 if items.is_empty() {
2229 continue;
2230 }
2231 let mut draw = |ui: &mut egui::Ui, i: usize, item: &Reuse| {
2232 let (name, tag, icon, payload) = reuse_face(item, project, settings);
2233 let id = egui::Id::new((title, i));
2234 let r = if view == 1 {
2235 let art = match *item {
2236 Reuse::Effect(k) => match crate::ui::effects_ui::thumbnail(k) {
2237 Some((tex, size)) => Art::Image(tex, size),
2238 None => Art::Icon(icon),
2239 },
2240 _ => Art::Icon(icon),
2241 };
2242 tile(ui, id, payload, false, tag, &name, palette.text, palette, art, TILE * zoom)
2243 } else {
2244 row(ui, id, payload, false, None, |ui| {
2245 glyph(ui, icon, palette);
2246 ui.label(&name);
2247 ui.weak(tag);
2248 })
2249 .0
2250 };
2251 if r.clicked() {
2252 reuse_pick(item, &name, resp);
2253 }
2254 };
2255 if view == 1 {
2256 let indexed: Vec<(usize, &Reuse)> = items.iter().enumerate().collect();
2257 tile_grid(ui, 0.0, &indexed, TILE * zoom, |ui, &(i, item)| draw(ui, i, item));
2258 } else {
2259 for (i, item) in items.iter().enumerate() {
2260 draw(ui, i, item);
2261 }
2262 }
2263 }
2264}
2265
2266#[cfg(test)]
2267mod tests {
2268 use super::*;
2269 use crate::model::Asset;
2270
2271 fn asset(id: Id, kind: ClipKind, duration: f64) -> Asset {
2272 Asset {
2273 id,
2274 path: format!(r"C:\media\file{id}.mp4"),
2275 kind,
2276 duration,
2277 width: 320,
2278 height: 240,
2279 fps: 30.0,
2280 audio_streams: Vec::new(),
2281 codec: String::new(),
2282 folder: String::new(),
2283 tags: Vec::new(),
2284 label: 0,
2285 description: String::new(),
2286 }
2287 }
2288
2289 #[test]
2290 fn split_path_cases() {
2291 assert_eq!(split_path(r"C:\Videos\clip.mp4"), ("clip.mp4", r"C:\Videos"));
2292 assert_eq!(split_path("/home/u/a.mov"), ("a.mov", "/home/u"));
2293 assert_eq!(split_path("a.mp4"), ("a.mp4", ""));
2294 assert_eq!(split_path(r"C:\x\"), ("", r"C:\x"));
2295 }
2296
2297 #[test]
2298 fn kind_tags() {
2299 assert_eq!(kind_tag(ClipKind::Video), "V");
2300 assert_eq!(kind_tag(ClipKind::Audio), "A");
2301 assert_eq!(kind_tag(ClipKind::Image), "I");
2302 }
2303
2304 #[test]
2305 fn search_matches_name_tags_and_description_but_not_the_folder() {
2306 let mut a = asset(1, ClipKind::Video, 5.0);
2307 a.tags = vec!["Drone".into()];
2308 a.description = "Sunset flyover".into();
2309 a.folder = "Footage/Day 1".into();
2310 assert!(matches_search(&a, ""));
2311 assert!(matches_search(&a, "FILE1"));
2312 assert!(matches_search(&a, "drone"));
2313 assert!(matches_search(&a, "sunset"));
2314 assert!(!matches_search(&a, "day 1"));
2317 assert!(!matches_search(&a, "nope"));
2318 assert!(path_matches(r"C:\pop\kick.wav", "kick", 0));
2320 assert!(!path_matches(r"C:\pop\kick.wav", "pop", 0));
2321 assert!(path_matches(r"C:\sfx\pop_01.wav", "pop", 0));
2322 }
2323
2324 #[test]
2325 fn kind_chips_incl_sfx_music() {
2326 assert!(matches_kind(ClipKind::Video, 5.0, 0));
2327 assert!(matches_kind(ClipKind::Video, 5.0, 1));
2328 assert!(!matches_kind(ClipKind::Video, 5.0, 2));
2329 assert!(matches_kind(ClipKind::Audio, 3.0, 5));
2331 assert!(!matches_kind(ClipKind::Audio, 30.0, 5));
2332 assert!(!matches_kind(ClipKind::Video, 3.0, 5));
2333 assert!(matches_kind(ClipKind::Audio, 30.0, 6));
2335 assert!(!matches_kind(ClipKind::Audio, 3.0, 6));
2336 assert!(!matches_kind(ClipKind::Video, 5.0, 4));
2338 }
2339
2340 #[test]
2341 fn folder_filter_subtree() {
2342 assert!(folder_ok(None, "anything"));
2343 assert!(folder_ok(Some(""), ""));
2344 assert!(!folder_ok(Some(""), "F"));
2345 assert!(folder_ok(Some("F"), "F"));
2346 assert!(folder_ok(Some("F"), "F/Sub"));
2347 assert!(!folder_ok(Some("F"), "Fx"));
2348 }
2349
2350 #[test]
2351 fn rename_folder_moves_assets() {
2352 let mut p = Project::new();
2353 p.add_folder("A/B");
2354 let id = p.add_asset(asset(0, ClipKind::Video, 5.0));
2355 p.asset_mut(id).unwrap().folder = "A/B".into();
2356 rename_folder(&mut p, "A", "Z");
2357 assert_eq!(p.asset(id).unwrap().folder, "Z/B");
2358 assert!(p.folder_names().iter().any(|f| f == "Z/B"));
2359 }
2360
2361 #[test]
2362 fn recent_ext_class_and_filters() {
2363 assert_eq!(ext_class("a.mp4"), 1);
2364 assert_eq!(ext_class("a.WAV"), 2);
2365 assert_eq!(ext_class("a.png"), 3);
2366 assert_eq!(ext_class("a.xyz"), 0);
2367 let rec = RecentAsset { path: r"C:\m\clip.mp3".into(), tags: vec!["voice".into()], ..Default::default() };
2368 assert!(recent_matches(&rec, "", 0, 0));
2369 assert!(recent_matches(&rec, "voice", 2, 0));
2370 assert!(recent_matches(&rec, "", 5, 0)); assert!(!recent_matches(&rec, "", 1, 0));
2372 assert!(!recent_matches(&rec, "", 0, 3)); }
2374
2375 #[test]
2376 fn recent_remove_and_clear_flag_settings() {
2377 let mut s = Settings::default();
2378 s.touch_recent("a.mp4");
2379 s.touch_recent("b.mp4");
2380 let mut resp = LibraryResponse::default();
2381 recent_remove(&mut s, &mut resp, "a.mp4");
2382 assert!(resp.settings_changed);
2383 assert_eq!(s.recent_assets.len(), 1);
2384 let mut resp = LibraryResponse::default();
2385 recent_clear(&mut s, &mut resp);
2386 assert!(resp.settings_changed);
2387 assert!(s.recent_assets.is_empty());
2388 }
2389
2390 #[test]
2391 fn delete_sequence_removes_clips() {
2392 let mut p = Project::new();
2393 let seq = p.new_sequence("S", 1280, 720, 30.0);
2394 p.insert_sequence_clip(seq, 0.0, None);
2395 assert!(p.all_clips().any(|(_, c)| c.kind == ClipKind::Sequence));
2396 delete_sequence(&mut p, seq);
2397 assert!(p.sequences.is_empty());
2398 assert!(!p.all_clips().any(|(_, c)| c.kind == ClipKind::Sequence));
2399 }
2400
2401 #[test]
2402 fn scan_dir_reports_unreadable_folders() {
2403 assert!(scan_dir(r"C:\does\not\exist\at\all").is_none());
2404 assert!(scan_dir(&std::env::temp_dir().to_string_lossy()).is_some());
2405 }
2406
2407 #[test]
2408 fn folder_parents_are_implied() {
2409 assert_eq!(parent_of("A/B/C"), "A/B");
2410 assert_eq!(parent_of("A"), "");
2411 let mut p = Project::new();
2412 let id = p.add_asset(asset(0, ClipKind::Video, 1.0));
2414 p.asset_mut(id).unwrap().folder = "A/B".into();
2415 assert_eq!(folder_tree_names(&p), vec!["A".to_string(), "A/B".to_string()]);
2416 }
2417
2418 #[test]
2421 fn every_file_kind_has_a_painted_fallback() {
2422 assert_eq!(fallback_glyph(ext_class("a.mp4")), Glyph::FilmStrip);
2423 assert_eq!(fallback_glyph(ext_class("a.wav")), Glyph::SpeakerOn);
2424 assert_eq!(fallback_glyph(ext_class("a.png")), Glyph::Camera);
2425 assert_eq!(fallback_glyph(ext_class("a.sedit")), Glyph::Layers);
2426 let ctx = egui::Context::default();
2427 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2428 egui::CentralPanel::default().show(ctx, |ui| {
2429 assert_eq!(file_art(ui, &mut None, "a.mov", 32), Art::Icon(Glyph::FilmStrip));
2431 assert_eq!(file_art(ui, &mut None, "a.mp3", 32), Art::Icon(Glyph::SpeakerOn));
2432 });
2433 });
2434 }
2435
2436 #[test]
2438 fn click_modifiers_build_the_selection() {
2439 let rows: Vec<(Pick, egui::Rect)> = (1..=4)
2440 .map(|i| {
2441 (Pick::Asset(i), egui::Rect::from_min_size(egui::pos2(0.0, i as f32 * 20.0), egui::vec2(100.0, 18.0)))
2442 })
2443 .collect();
2444 let mut s = LibraryState::default();
2445 apply_click(&mut s, &rows, &Pick::Asset(1), false, false);
2446 assert_eq!(s.sel_ids, vec![1]);
2447 assert_eq!(s.selected, Some(1), "a plain click moves the anchor");
2448 apply_click(&mut s, &rows, &Pick::Asset(3), true, false);
2449 assert_eq!(s.sel_ids, vec![1, 3], "ctrl adds");
2450 apply_click(&mut s, &rows, &Pick::Asset(3), true, false);
2451 assert_eq!(s.sel_ids, vec![1], "ctrl again removes");
2452 apply_click(&mut s, &rows, &Pick::Asset(2), false, true);
2454 assert_eq!(s.sel_ids, vec![2, 3]);
2455 assert_eq!(s.selected, Some(3), "shift leaves the anchor alone");
2456 apply_click(&mut s, &rows, &Pick::Asset(4), false, false);
2457 assert_eq!(s.sel_ids, vec![4], "a plain click replaces the whole set");
2458 }
2459
2460 #[test]
2463 fn app_writing_selected_collapses_the_selection() {
2464 let mut s =
2465 LibraryState { sel_ids: vec![1, 2, 3], selected: Some(1), seen_selected: Some(1), ..Default::default() };
2466 external_select(&mut s);
2467 assert_eq!(s.sel_ids, vec![1, 2, 3], "nothing changed: the set stands");
2468 s.selected = Some(9); external_select(&mut s);
2470 assert_eq!(s.sel_ids, vec![9]);
2471 }
2472
2473 #[test]
2476 fn rows_keep_a_constant_width() {
2477 let ctx = egui::Context::default();
2478 let mut widths = Vec::new();
2479 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2480 egui::CentralPanel::default().show(ctx, |ui| {
2481 let pane = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(300.0, 4000.0));
2482 ui.scope_builder(egui::UiBuilder::new().max_rect(pane), |ui| {
2483 for i in 0..30 {
2484 let id = egui::Id::new(("t", i));
2485 let (r, _) = row(ui, id, DragPayload::Template(String::new()), false, Some("Place"), |ui| {
2486 ui.label("a template name far too long to ever fit into this narrow pane");
2487 });
2488 widths.push(r.rect.width());
2489 }
2490 });
2491 });
2492 });
2493 let first = widths[0];
2494 assert!(first <= 300.0, "row wider than the pane: {first}");
2495 assert!(widths.iter().all(|w| (w - first).abs() < 1.0), "rows grew down the list: {widths:?}");
2496 }
2497
2498 #[test]
2501 fn inline_edit_commits_on_focus_loss() {
2502 let ctx = egui::Context::default();
2503 let mut buf = "Footage".to_string();
2504 let mut out = None;
2505 let run = |steal: bool, buf: &mut String, out: &mut Option<String>| {
2507 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2508 if steal {
2509 ctx.memory_mut(|m| m.request_focus(egui::Id::new("somewhere else")));
2510 }
2511 egui::CentralPanel::default().show(ctx, |ui| {
2512 *out = inline_edit(ui, buf);
2513 });
2514 });
2515 };
2516 run(false, &mut buf, &mut out);
2517 assert!(out.is_none());
2518 run(false, &mut buf, &mut out);
2519 assert!(out.is_none(), "still editing while focused");
2520 run(true, &mut buf, &mut out);
2521 assert_eq!(out.as_deref(), Some("Footage"), "commits when focus moves away");
2522 }
2523
2524 #[test]
2527 fn filter_toolbar_renders_and_clears() {
2528 let mut project = Project::new();
2529 let mut a = asset(0, ClipKind::Video, 5.0);
2530 a.path = r"C:\media\one.mp4".into();
2531 project.add_asset(a);
2532 project.labels.truncate(2);
2533 let mut settings = Settings::default();
2534 let palette = Palette::new(true, egui::Color32::WHITE);
2535 let ctx = egui::Context::default();
2536 let mut state = LibraryState { search: "one".into(), ..Default::default() };
2537 let mut search_rect = egui::Rect::NOTHING;
2538 for _ in 0..2 {
2539 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2540 egui::CentralPanel::default().show(ctx, |ui| {
2541 let mut undo = |_: &Project| {};
2542 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, true, &mut undo);
2543 });
2544 });
2545 search_rect = ctx
2546 .data(|d| d.get_temp::<egui::Rect>(egui::Id::new("lib_search_rect")))
2547 .expect("the toolbar drew its search box");
2548 }
2549 assert!(search_rect.width() > 20.0, "search box has room: {search_rect:?}");
2550 assert!(search_rect.top() < 120.0, "the toolbar sits at the top of the panel: {search_rect:?}");
2551 let labels: Labels = project.labels.iter().map(|l| (l.name.clone(), l.color)).collect();
2553 assert_eq!(labels.len(), 2);
2554 assert_eq!(lbl_name(&labels, 1), project.labels[0].name);
2555 assert_eq!(lbl_name(&labels, 0), "None");
2556 assert_eq!(lbl_name(&labels, 9), "None", "out of range falls back");
2557 state.search.clear();
2559 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2560 egui::CentralPanel::default().show(ctx, |ui| {
2561 let mut undo = |_: &Project| {};
2562 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, true, &mut undo);
2563 });
2564 });
2565 assert!(state.search.is_empty());
2566 }
2567
2568 #[test]
2570 fn rows_use_the_project_label_colour() {
2571 let mut project = Project::new();
2572 let palette = Palette::new(true, egui::Color32::WHITE);
2573 project.labels[0].color = [1, 2, 3];
2574 let labels: Labels = project.labels.iter().map(|l| (l.name.clone(), l.color)).collect();
2575 assert_eq!(lbl_color(&labels, 1, &palette), egui::Color32::from_rgb(1, 2, 3));
2576 assert_eq!(lbl_color(&labels, 0, &palette), label_color(0, &palette));
2578 assert_eq!(lbl_color(&labels, 250, &palette), label_color(250, &palette));
2579 }
2580
2581 #[test]
2583 fn rows_lay_out_with_a_thumb_cache() {
2584 let mut project = Project::new();
2585 let mut a = asset(0, ClipKind::Video, 5.0);
2586 a.path = r"C:\media\one.mp4".into();
2587 project.add_asset(a);
2588 let mut settings = Settings::default();
2589 let palette = Palette::new(true, egui::Color32::WHITE);
2590 let ctx = egui::Context::default();
2591 let mut cache = ThumbCache::new(ctx.clone(), crate::media::Backend::Ffmpeg);
2592 let mut state = LibraryState::default();
2593 for _ in 0..2 {
2594 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2595 egui::CentralPanel::default().show(ctx, |ui| {
2596 let mut undo = |_: &Project| panic!("no undo without edits");
2597 let r = show(
2598 ui,
2599 &mut state,
2600 &mut project,
2601 &mut settings,
2602 Some(&mut cache),
2603 None,
2604 &palette,
2605 true,
2606 &mut undo,
2607 );
2608 assert!(!r.edited && !r.edit_labels);
2609 });
2610 });
2611 }
2612 }
2613
2614 #[test]
2617 fn import_url_button_is_gated_on_ytdlp() {
2618 let run = |ytdlp: bool, click: bool| -> (bool, bool) {
2619 let mut project = Project::new();
2620 let mut settings = Settings::default();
2621 let palette = Palette::new(true, egui::Color32::WHITE);
2622 let ctx = egui::Context::default();
2623 let mut state = LibraryState::default();
2624 let mut seen = false;
2625 let mut asked = false;
2626 let mut at = egui::Pos2::ZERO;
2628 for frame in 0..2 {
2629 let mut input = egui::RawInput::default();
2630 if click && frame == 1 && at != egui::Pos2::ZERO {
2631 click_at(&mut input, at);
2632 }
2633 let out = ctx.run(input, |ctx| {
2634 egui::CentralPanel::default().show(ctx, |ui| {
2635 let mut undo = |_: &Project| {};
2636 let r =
2637 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, ytdlp, &mut undo);
2638 asked |= r.import_url;
2639 });
2640 });
2641 if let Some(rect) = text_rect(&out.shapes, "Import URL…") {
2643 seen = true;
2644 at = rect.center();
2645 }
2646 }
2647 (seen, asked)
2648 };
2649 assert_eq!(run(false, false), (false, false), "button must be hidden without yt-dlp");
2650 let (seen, asked) = run(true, true);
2651 assert!(seen, "button must be shown when yt-dlp is installed");
2652 assert!(asked, "clicking it must ask the app to open the Import URL window");
2653 }
2654
2655 #[test]
2658 fn action_button_stays_inside_the_pane() {
2659 let mut settings = Settings::default();
2660 settings.templates.push(crate::settings::Template {
2661 name: "a template with a name that keeps going and going".into(),
2662 json: "{}".into(),
2663 });
2664 let palette = Palette::new(true, egui::Color32::WHITE);
2665 let ctx = egui::Context::default();
2666 for w in [180.0_f32, 260.0, 420.0] {
2667 let mut state = LibraryState::default();
2668 let (mut right, mut pane_right) = (f32::NAN, f32::NAN);
2669 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2670 egui::CentralPanel::default().show(ctx, |ui| {
2671 let pane = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(w, 600.0));
2672 pane_right = pane.right();
2673 ui.scope_builder(egui::UiBuilder::new().max_rect(pane), |ui| {
2674 let mut resp = LibraryResponse::default();
2675 templates_section(ui, &mut state, &mut settings, &palette, &mut resp);
2676 right = ui.ctx().data(|d| d.get_temp(egui::Id::new("row_btn_right")).unwrap_or(f32::NAN));
2677 });
2678 });
2679 });
2680 assert!(right <= pane_right + 1.0, "the Place button overflows a {w}px pane: right={right}");
2681 }
2682 }
2683
2684 fn text_rect(shapes: &[egui::epaint::ClippedShape], label: &str) -> Option<egui::Rect> {
2686 shapes.iter().find_map(|c| match &c.shape {
2687 egui::epaint::Shape::Text(t) if t.galley.text().contains(label) => Some(t.visual_bounding_rect()),
2688 _ => None,
2689 })
2690 }
2691
2692 fn click_at(input: &mut egui::RawInput, at: egui::Pos2) {
2694 input.events.push(egui::Event::PointerMoved(at));
2695 for pressed in [true, false] {
2696 input.events.push(egui::Event::PointerButton {
2697 pos: at,
2698 button: egui::PointerButton::Primary,
2699 pressed,
2700 modifiers: Default::default(),
2701 });
2702 }
2703 }
2704
2705 #[test]
2708 fn show_headless() {
2709 let mut project = Project::new();
2710 for (i, kind) in [ClipKind::Video, ClipKind::Audio, ClipKind::Image].iter().enumerate() {
2711 let mut a = asset(0, *kind, 12.5 * i as f64);
2712 a.path = format!(r"C:\media\file{i}.mp4");
2713 a.tags = vec!["tag".into()];
2714 a.label = (i % 3) as u8;
2715 project.add_asset(a);
2716 }
2717 project.add_folder("Footage/Day 1");
2718 project.new_sequence("Intro", 1280, 720, 30.0);
2719 project.linked_folders.push(r"C:\does\not\exist".into());
2720 let mut settings = Settings::default();
2721 settings.touch_recent(r"C:\media\old.mp4");
2722 settings.touch_recent(r"D:\clips\new.mov");
2723 settings.recent_assets[0].pinned = true;
2724 settings.recent_assets[0].label = 2;
2725 settings.templates.push(crate::settings::Template { name: "Intro pack".into(), json: "{}".into() });
2726 let palette = Palette::new(true, egui::Color32::WHITE);
2727 let ctx = egui::Context::default();
2728 for tab in [0, 1] {
2729 let mut state = LibraryState { tab, selected: Some(project.assets[1].id), ..Default::default() };
2730 for _ in 0..2 {
2731 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2732 egui::CentralPanel::default().show(ctx, |ui| {
2733 let mut undo = |_: &Project| panic!("no undo without edits");
2734 let r =
2735 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, true, &mut undo);
2736 assert!(!r.import && !r.clear_recent && !r.edited && !r.settings_changed);
2737 assert!(r.add_to_timeline.is_empty() && r.open_paths.is_empty() && r.remove.is_empty());
2738 assert!(r.convert.is_empty() && r.open_sequence.is_none() && r.place_template.is_empty());
2739 });
2740 });
2741 }
2742 assert_eq!(state.tab, tab);
2743 assert_eq!(state.sel_ids, vec![project.assets[1].id]);
2745 assert!(state.dirs.is_empty(), "collapsed linked folders must not be scanned");
2747 }
2748 }
2749
2750 #[test]
2753 fn linked_folders_are_read_one_level_per_expansion() {
2754 let root = std::env::temp_dir().join(format!("se_lib_fs_{}", std::process::id()));
2755 let sub = root.join("sub");
2756 let _ = std::fs::remove_dir_all(&root);
2757 std::fs::create_dir_all(&sub).unwrap();
2758 std::fs::write(root.join("a.mp4"), b"x").unwrap();
2759 std::fs::write(sub.join("b.mp4"), b"x").unwrap();
2760 std::fs::write(root.join("notes.txt"), b"x").unwrap();
2761 let (root, sub) = (root.to_string_lossy().into_owned(), sub.to_string_lossy().into_owned());
2762
2763 let mut project = Project::new();
2764 project.linked_folders.push(root.clone());
2765 let mut settings = Settings::default();
2766 let palette = Palette::new(true, egui::Color32::WHITE);
2767 let ctx = egui::Context::default();
2768 let mut state = LibraryState { tab: 1, ..Default::default() };
2769 let mut run = |state: &mut LibraryState, project: &mut Project, click: Option<egui::Pos2>| {
2770 let mut input = egui::RawInput {
2771 screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 1200.0))),
2772 ..Default::default()
2773 };
2774 if let Some(pos) = click {
2775 click_at(&mut input, pos);
2776 }
2777 let mut got = None;
2778 let out = ctx.run(input, |ctx| {
2779 egui::CentralPanel::default().show(ctx, |ui| {
2780 let mut undo = |_: &Project| {};
2781 let r = show(ui, state, project, &mut settings, None, None, &palette, false, &mut undo);
2782 got = r.preview;
2783 });
2784 });
2785 (out.shapes, got)
2786 };
2787
2788 run(&mut state, &mut project, None);
2790 assert!(state.dirs.is_empty(), "a collapsed folder must not be scanned");
2791
2792 state.flipped.push(dir_key(&root));
2794 run(&mut state, &mut project, None);
2795 let listed = |state: &LibraryState, p: &str| {
2796 state.dirs.iter().find(|(k, _)| k == p).map(|(_, v)| v.clone().unwrap_or_default())
2797 };
2798 let top = listed(&state, &root).expect("the root was read");
2799 assert_eq!(top.len(), 2, "only the sub-folder and the media file: {top:?}");
2800 assert!(top[0].1 && top[0].0 == sub, "folders come first: {top:?}");
2801 assert!(listed(&state, &sub).is_none(), "a collapsed sub-folder must not be scanned");
2802
2803 state.flipped.push(dir_key(&sub));
2805 let (shapes, _) = run(&mut state, &mut project, None);
2806 assert_eq!(listed(&state, &sub).map(|v| v.len()), Some(1));
2807
2808 let at = text_rect(&shapes, "a.mp4").expect("the file row was drawn").center();
2810 let (_, previewed) = run(&mut state, &mut project, Some(at));
2811 let file = std::path::Path::new(&root).join("a.mp4");
2812 assert_eq!(previewed.as_deref(), Some(file.as_path()), "a single click previews the file");
2813 assert_eq!(state.sel_path.as_deref(), file.to_str());
2814 assert_eq!(state.sel_paths.len(), 1);
2815
2816 let _ = std::fs::remove_dir_all(&root);
2817 }
2818
2819 #[test]
2822 fn the_preview_box_follows_the_selection() {
2823 let mut project = Project::new();
2824 let id = project.add_asset(asset(0, ClipKind::Video, 5.0));
2825 let mut settings = Settings::default();
2826 let palette = Palette::new(true, egui::Color32::WHITE);
2827 let ctx = egui::Context::default();
2828 let mut state = LibraryState::default();
2829 let mut drawn = |state: &mut LibraryState, project: &mut Project, label: &str| {
2830 let input = egui::RawInput {
2831 screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 900.0))),
2832 ..Default::default()
2833 };
2834 let out = ctx.run(input, |ctx| {
2835 egui::CentralPanel::default().show(ctx, |ui| {
2836 let mut undo = |_: &Project| {};
2837 show(ui, state, project, &mut settings, None, None, &palette, false, &mut undo);
2838 });
2839 });
2840 text_rect(&out.shapes, label).is_some()
2841 };
2842 assert!(drawn(&mut state, &mut project, "Select a file to preview it"));
2843 assert!(!drawn(&mut state, &mut project, "description"), "nothing selected: no editors");
2844 state.selected = Some(id);
2845 assert!(drawn(&mut state, &mut project, "description"), "selected: the preview box edits it");
2846 assert!(drawn(&mut state, &mut project, "tags, comma, separated"));
2847 state.clear_sel();
2848 assert!(!drawn(&mut state, &mut project, "description"), "clicked away: the box is gone");
2849 }
2850
2851 #[test]
2853 fn batch_strip_acts_on_the_whole_selection() {
2854 let mut project = Project::new();
2855 let a = project.add_asset(asset(0, ClipKind::Video, 5.0));
2856 let b = project.add_asset(asset(0, ClipKind::Video, 5.0));
2857 let mut settings = Settings::default();
2858 let palette = Palette::new(true, egui::Color32::WHITE);
2859 let ctx = egui::Context::default();
2860 let mut state =
2861 LibraryState { sel_ids: vec![a, b], selected: Some(a), seen_selected: Some(a), ..Default::default() };
2862 let mut at = egui::Pos2::ZERO;
2863 let mut removed = Vec::new();
2864 for frame in 0..2 {
2865 let mut input = egui::RawInput {
2866 screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 900.0))),
2867 ..Default::default()
2868 };
2869 if frame == 1 {
2870 assert_ne!(at, egui::Pos2::ZERO, "the batch strip must show a Remove button");
2871 click_at(&mut input, at);
2872 }
2873 let out = ctx.run(input, |ctx| {
2874 egui::CentralPanel::default().show(ctx, |ui| {
2875 let mut undo = |_: &Project| {};
2876 let r = show(ui, &mut state, &mut project, &mut settings, None, None, &palette, false, &mut undo);
2877 removed = r.remove.clone();
2878 });
2879 });
2880 assert!(text_rect(&out.shapes, "2 selected").is_some(), "the strip counts the selection");
2881 if let Some(rect) = text_rect(&out.shapes, "Remove from project") {
2882 at = rect.center();
2883 }
2884 }
2885 assert_eq!(removed, vec![a, b], "Remove takes the whole selection");
2886 }
2887
2888 #[test]
2890 fn zoom_scales_and_clamps() {
2891 let mut project = Project::new();
2892 let mut settings = Settings::default();
2893 let palette = Palette::new(true, egui::Color32::WHITE);
2894 let ctx = egui::Context::default();
2895 let mut state = LibraryState { zoom: 99.0, ..Default::default() };
2896 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2897 egui::CentralPanel::default().show(ctx, |ui| {
2898 let mut undo = |_: &Project| {};
2899 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, false, &mut undo);
2900 });
2901 });
2902 assert_eq!(state.zoom, ZOOM_MAX, "an out-of-range zoom is clamped");
2903 let mut state = LibraryState::default();
2904 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2905 egui::CentralPanel::default().show(ctx, |ui| {
2906 let mut undo = |_: &Project| {};
2907 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, false, &mut undo);
2908 });
2909 });
2910 assert_eq!(state.zoom, 1.0, "a default state reads as 1");
2911 assert!(TILE * ZOOM_MAX > TILE && ROW_H * ZOOM_MAX > ROW_H);
2912 }
2913
2914 #[test]
2920 fn gallery_wraps_into_a_grid() {
2921 let ctx = egui::Context::default();
2922 let mut ys = Vec::new();
2923 let _ = ctx.run(egui::RawInput::default(), |ctx| {
2924 egui::CentralPanel::default().show(ctx, |ui| {
2925 let pane = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(260.0, 4000.0));
2926 ui.scope_builder(egui::UiBuilder::new().max_rect(pane), |ui| {
2927 egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
2928 tile_grid(ui, 0.0, &(0..8).collect::<Vec<usize>>(), TILE, |ui, &i| {
2929 let r = tile(
2930 ui,
2931 egui::Id::new(("g", i)),
2932 DragPayload::Template(String::new()),
2933 false,
2934 "V",
2935 "clip",
2936 egui::Color32::WHITE,
2937 &Palette::new(true, egui::Color32::WHITE),
2938 Art::Icon(Glyph::FilmStrip),
2939 TILE,
2940 );
2941 ys.push(r.rect.top());
2942 });
2943 });
2944 });
2945 });
2946 });
2947 let rows: std::collections::BTreeSet<i32> = ys.iter().map(|y| y.round() as i32).collect();
2948 assert!(rows.len() > 1, "8 tiles in a 260px pane must wrap to more than one row: {ys:?}");
2949 }
2950
2951 #[test]
2952 fn reuse_sections_list_what_the_project_holds() {
2953 let mut project = Project::new();
2954 project.tracks[0].clips.push(crate::model::Clip::new(7, ClipKind::Video, "v", 0.0, 4.0));
2955 project.tracks[0].clips[0].effects.push(crate::model::Effect::new(EffectKind::Blur));
2956 let adj = project.add_adjustment_clip(0.0, 2.0);
2957 if let Some(c) = project.clip_mut(adj) {
2960 c.effects.push(crate::model::Effect::new(EffectKind::Blur));
2961 }
2962 project.ensure_graph(adj);
2963 let mut settings = Settings::default();
2964 settings.touch_recent(r"C:\media\old.mp4");
2965 settings.effect_presets.push(crate::settings::EffectPreset { name: "Look".into(), json: "[]".into() });
2966 settings
2967 .effect_presets
2968 .push(crate::settings::EffectPreset { name: "Graph".into(), json: "{\"nodes\":[]}".into() });
2969 settings.templates.push(crate::engine::presets::capture_template("Grade", &project, &[adj]));
2970
2971 let counts: Vec<(&str, usize)> =
2972 reuse_sections(&project, &settings).iter().map(|(t, v)| (*t, v.len())).collect();
2973 assert_eq!(counts, vec![("Effects", 2), ("Node graphs", 2), ("Adjustment layers", 1)]);
2974
2975 let palette = Palette::new(true, egui::Color32::WHITE);
2976 let ctx = egui::Context::default();
2977 for view in [0, 1] {
2978 let mut state = LibraryState { view, ..Default::default() };
2979 let mut titles = Vec::new();
2980 for _ in 0..2 {
2981 let input = egui::RawInput {
2982 screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 1600.0))),
2983 ..Default::default()
2984 };
2985 let out = ctx.run(input, |ctx| {
2986 egui::CentralPanel::default().show(ctx, |ui| {
2987 let mut undo = |_: &Project| panic!("no undo without edits");
2988 let r =
2989 show(ui, &mut state, &mut project, &mut settings, None, None, &palette, false, &mut undo);
2990 assert!(r.add_effect.is_none() && r.apply_preset.is_none() && r.copy_graph.is_none());
2991 });
2992 });
2993 titles = ["Sequences", "Templates", "Effects", "Node graphs", "Adjustment layers"]
2996 .iter()
2997 .filter(|t| text_rect(&out.shapes, t).is_some())
2998 .copied()
2999 .collect();
3000 assert!(text_rect(&out.shapes, "Blur").is_some(), "view {view} lists the effect as a file");
3001 assert!(text_rect(&out.shapes, "Look").is_some(), "view {view} lists the preset as a file");
3002 }
3003 assert!(titles.is_empty(), "view {view} still draws section headings: {titles:?}");
3004 }
3005
3006 let mut resp = LibraryResponse::default();
3008 reuse_pick(&Reuse::Effect(EffectKind::Blur), "Blur", &mut resp);
3009 reuse_pick(&Reuse::Preset(1), "Graph", &mut resp);
3010 reuse_pick(&Reuse::Graph(adj), "", &mut resp);
3011 reuse_pick(&Reuse::Adjustment(0), "Grade", &mut resp);
3012 assert_eq!(resp.add_effect, Some(EffectKind::Blur));
3013 assert_eq!(resp.apply_preset, Some(1));
3014 assert_eq!(resp.copy_graph, Some(adj));
3015 assert_eq!(resp.place_template, vec!["Grade".to_string()]);
3016 assert_eq!(reuse_face(&Reuse::Effect(EffectKind::Blur), &project, &settings).0, "Blur");
3017 }
3018}