simple_editor\ui/
library.rs

1//! Left panel: the project browser — Premiere's project panel, in two sub-tabs.
2//!
3//! "Imported" is what the project contains, drawn as a real file explorer: nested folders, the assets
4//! under them, a "+ New" button (folder / import / sequence / adjustment layer), rename and delete from
5//! the context menu, and move-by-drag onto a folder row. "Global" browses the disk instead: the recent
6//! files from settings.json plus the folders the user linked, one directory level read per expansion
7//! (never an eager recursive walk — that hangs the UI on a media drive) and only inside a node the user
8//! linked or opened.
9//!
10//! Selection is multi: a click replaces it, Ctrl+click toggles one item, Shift+click takes the range
11//! over the rows in the order they were drawn, and a drag across empty space rubber-bands.
12//! `LibraryState.selected` / `sel_path` are the anchor inside the set — app.rs writes `selected` after
13//! an import, and `show` notices that write and collapses the set onto it. With something selected the
14//! toolbar grows a batch strip (Import, Convert To, Convert…, Compress…, Remove) that routes through the
15//! same `LibraryResponse` fields the per-item menus use, so App needs no new plumbing.
16//!
17//! The bottom of the pane is a dedicated asset preview: a big picture of the anchor, its format line,
18//! and the ONLY place its description, tags, label and folder are edited — never inline in the list.
19//! Every file row and tile carries a picture: the cached thumbnail, or a painted glyph while there is
20//! none — a speaker for audio, a film strip for video, a camera for stills, a sheet stack for the rest.
21//! The toolbar works over both tabs: search (name/tags/description/folder), kind chips (All/Video/Audio/
22//! Image/Seq, Short SFX ≤ 10 s, Music > 10 s), label colours, "Unused only", sort, the List/Gallery
23//! switch (`LibraryState.view`) and the zoom (`LibraryState.zoom`, the +/- buttons and Ctrl+Scroll) that
24//! scales both gallery tiles and row thumbnails. A search or a filter flattens "Imported" into its hits,
25//! the way an explorer shows search results.
26//! Under the tree, the Imported tab also lists what the project can reuse: its sequences (drag
27//! `DragPayload::Sequence`, double-click opens), saved templates, and the effects / node graphs /
28//! adjustment layers already in use or saved in settings.json.
29
30use 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    /// 0 = Imported (what the project contains), 1 = Global (recent files + the linked folders).
42    pub tab: usize,
43    /// Anchor of the selection: the asset a click landed on, and the one the preview box shows.
44    /// app.rs writes it straight after an import — `show` spots that and collapses the set onto it.
45    pub selected: Option<Id>,
46    /// Same, for a selected file that is not a project asset.
47    pub sel_path: Option<String>,
48    /// The whole selection (Ctrl / Shift click, rubber band); the anchor above is one of these.
49    pub sel_ids: Vec<Id>,
50    pub sel_paths: Vec<String>,
51    /// `selected` as of the end of the last frame — the only way to tell app.rs's write from ours.
52    pub seen_selected: Option<Id>,
53    pub search: String,
54    /// 0 All, 1 Video, 2 Audio, 3 Image, 4 Sequences, 5 Short SFX, 6 Music
55    pub kind_filter: u8,
56    /// 0 = any, 1..=8 = LABEL_COLORS index + 1
57    pub label_filter: u8,
58    pub unused_only: bool,
59    /// 0 name, 1 duration, 2 kind, 3 recently added
60    pub sort: u8,
61    /// 0 = list rows, 1 = thumbnail gallery. Applies to both tabs.
62    pub view: u8,
63    /// Thumbnail scale, `ZOOM_MIN..=ZOOM_MAX`. 0 = never set, read as 1.
64    pub zoom: f32,
65    /// The folder clicked in the tree — highlighted, and the subtree a search is limited to.
66    /// None = all folders, Some("") = root only, Some(name) = that folder (+ subfolders)
67    pub folder: Option<String>,
68    /// Tree nodes whose open state differs from the default (project folders start open, Recent and
69    /// folders on disk start closed). Keys: "recent", "f:<folder>", `dir_key(path)`.
70    pub flipped: Vec<String>,
71    /// (folder being renamed, edit buffer = new last segment)
72    pub rename_folder: Option<(String, String)>,
73    /// (parent, edit buffer) — "" parent = top level
74    pub new_folder: Option<(String, String)>,
75    /// Tag edit buffer for the previewed asset (avoids the comma being eaten while typing).
76    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    /// Same, for the previewed file on disk (its tags live on its `RecentAsset`).
81    pub recent_tags_for: Option<String>,
82    pub recent_tags_buf: String,
83    /// One-level directory listings, keyed by folder path: (entry path, is a folder), folders first,
84    /// None = unreadable. Only ever filled for a node the user expanded; dropped on Refresh / unlink.
85    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    /// Files to import into the library (and select).
93    pub open_paths: Vec<PathBuf>,
94    pub remove: Vec<Id>,
95    pub clear_recent: bool,
96    /// The project changed (folders / tags / labels edited) — app pushes undo via `undo` first.
97    pub edited: bool,
98    /// settings.recent_assets changed (tags / labels / pins / removals) — app saves settings.
99    pub settings_changed: bool,
100    /// (asset id, target extension) — start a Convert To… with the default options.
101    pub convert: Vec<(Id, String)>,
102    /// Source paths whose proxy should be rebuilt (the app releases decoders, deletes, rescans).
103    pub regen_proxy: Vec<String>,
104    /// Asset for which the app should open the Convert To… options dialog.
105    pub convert_dialog: Option<Id>,
106    /// Asset for which the app should open the Compress… dialog.
107    pub compress: Option<Id>,
108    /// Sequence to open for editing (double-clicked in the Sequences section).
109    pub open_sequence: Option<Id>,
110    /// Template names to place at the playhead.
111    pub place_template: Vec<String>,
112    /// The user asked to import media from a URL (the app opens the Import URL window).
113    pub import_url: bool,
114    /// "Edit labels…" was picked in a label menu — the app opens the label editor (Inspector).
115    pub edit_labels: bool,
116    /// "New ▸ Adjustment layer" — the app runs `Action::AddAdjustment`.
117    pub new_adjustment: bool,
118    /// "Open…" — the app runs `Action::OpenFile` (its own file dialog / recents handling).
119    pub open_dialog: bool,
120    /// An effect kind picked in the reuse sections — the app adds it to every selected visual clip.
121    pub add_effect: Option<EffectKind>,
122    /// Index into `Settings::effect_presets` to apply to the selection (chain or node graph).
123    pub apply_preset: Option<usize>,
124    /// Clip whose node graph should be copied onto the selection.
125    pub copy_graph: Option<Id>,
126    /// The file the anchor of the selection now points at — the app may show it in the source viewer.
127    /// The library previews it itself either way.
128    pub preview: Option<PathBuf>,
129}
130
131/// (name, colour) of every `Project.labels` entry, snapshotted once per frame.
132type Labels = Vec<(String, [u8; 3])>;
133
134/// What a label menu returned.
135enum LabelPick {
136    Set(u8),
137    Edit,
138}
139
140/// One selectable thing in the browser: a project asset, or a file on disk.
141#[derive(Clone, PartialEq, Debug)]
142enum Pick {
143    Asset(Id),
144    Path(String),
145}
146
147/// Project mutations collected during the frame, applied after all widgets (one undo per gesture).
148enum 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/// The frame the app's preview player produced this update, with its pixel size. The preview box paints
165/// it instead of the still thumbnail, which is what makes a clicked asset play.
166#[derive(Clone, Copy)]
167pub struct PreviewFrame {
168    pub tex: egui::TextureId,
169    pub size: [u32; 2],
170    pub playing: bool,
171}
172
173/// `thumbs`: the app's ThumbCache, so rows and the preview can show a picture. None (no cache /
174/// headless) draws the painted fallback instead.
175/// `ytdlp` = yt-dlp was found at start-up; the "Import URL…" button only exists when it is installed.
176#[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
204/// app.rs sets `selected` on its own after an import or a "reveal in library"; the multi-selection
205/// follows that write (and only that one — our own writes go through `set_anchor`).
206fn 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    /// The item the preview box shows and Shift-click ranges from.
227    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
269/// One click on a row/tile, resolved against the rows as they were drawn this frame.
270/// Plain replaces the selection, Ctrl toggles one item, Shift takes the range from the anchor (which
271/// stays put, so a second Shift+click re-ranges from the same place, like every file explorer).
272fn 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
298/// Rubber band over empty space: press, move, and every row the band touches is selected. Derived from
299/// the pointer each frame — no state to keep. A press that landed on a row is left alone: that gesture
300/// belongs to the drag-and-drop source.
301fn 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
329// ---------- pure filter helpers ----------
330
331fn 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
343/// Case-insensitive match of the search text against name, tags, description and folder.
344/// A search is about the FILE, so it matches the name and what the user wrote about it — never the
345/// containing folder. Matching the folder meant searching "pop" returned every file that happened to sit
346/// in a folder called pop, which is the opposite of narrowing.
347fn 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
357/// Kind chip: 0 All, 1 Video, 2 Audio, 3 Image, 4 Sequences (assets never match), 5 Short SFX (audio
358/// ≤ 10 s), 6 Music (audio > 10 s).
359fn 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
371/// Folder filter: None = all, "" = root only, name = that folder and its subfolders.
372fn 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
394/// Rename a folder (and subfolders); assets follow.
395fn 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
413/// Remove a sequence and every clip that uses it (main timeline, stash, other sequences).
414fn 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
434// ---------- files on disk ----------
435
436// ponytail: duplicated from app.rs's private MEDIA_EXTS split by kind — keep in sync by hand.
437const 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
442/// 1 video, 2 audio, 3 image, 0 unknown — by extension only.
443fn 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
456/// Search + kind chips against a bare path (recents and files on disk have no duration): the SFX/Music
457/// chips can only mean "audio" there.
458fn 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    // the file name only: matching the whole path made every file under C:\pop\ a hit for "pop"
466    class_ok && (q.is_empty() || split_path(path).0.to_lowercase().contains(&q.to_lowercase()))
467}
468
469/// Same, plus the recent entry's own tags and colour label.
470fn 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
492/// One level of a folder on disk: subfolders first, then media files. None = unreadable.
493fn 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    // folders first, then files — a file explorer, not an alphabetical dump
504    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
517/// ("file.mp4", "C:\dir") — pure string split on the last path separator.
518fn split_path(p: &str) -> (&str, &str) {
519    match p.rfind(['\\', '/']) {
520        Some(i) => (&p[i + 1..], &p[..i]),
521        None => (p, ""),
522    }
523}
524
525// ---------- pictures ----------
526
527/// Lay `items` out as a grid of tiles, wrapping every time `tile_w` more would not fit the pane.
528/// `horizontal_wrapped` cannot do this for tiles: a tile is a drag source whose size is not known until
529/// after its contents are drawn, so the wrap test always says the row still has room and a gallery grows
530/// one row deep, sideways, forever. Compute the columns instead.
531fn 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/// What fills a picture box.
548#[derive(PartialEq, Debug)]
549enum Art {
550    /// A cached picture (asset thumbnail, effect catalogue card) fitted into the box.
551    Image(egui::TextureId, [u32; 2]),
552    /// Nothing to photograph (or nothing cached yet): a painted icon.
553    Icon(Glyph),
554}
555
556/// The picture of a media file `h` px tall: its cached thumbnail, or the painted fallback for its kind.
557/// ponytail: asking for a thumbnail is what queues the decode, so expanding a folder of 500 clips queues
558/// 500 of them (LIFO, so what you look at wins). Upgrade: only ask for rows inside the viewport.
559fn 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
570/// The painted stand-in for a file with no thumbnail: a speaker for audio, a film strip for video, a
571/// camera for stills, a stack of sheets for everything else.
572fn 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
581/// Paint `art` inside `rect` on the panel fill. The box is drawn even when empty, so names line up.
582fn 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
600/// The picture in front of a file row, `h` points tall (16:9 box, so every row lines up).
601fn 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
607/// Gallery cell width and row thumbnail height at zoom 1.
608const TILE: f32 = 108.0;
609const ROW_H: f32 = 18.0;
610const ZOOM_MIN: f32 = 0.6;
611const ZOOM_MAX: f32 = 3.0;
612
613// ---------- shared widgets ----------
614
615/// Yes/No dialog for destructive, non-undoable actions.
616pub(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
625/// A filled dot in the label's colour — painted, because ● is tofu in half the shipped fonts.
626fn 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
631/// "Label ▸" submenu over the project's own labels; returns the pick ("Edit labels…" included).
632fn 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
658/// Colour of a label index using the project's palette (falls back to the built-in colours).
659fn 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
666/// Name of a label index in the project's palette.
667fn 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
674/// One list row: a drag source with a click-sensing overlay (select / double-click / context menu) and
675/// an optional action button on the right. Returns (row response, button clicked).
676fn 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        // Width of the trailing button (+ spacing) so the row content stops before it.
686        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            // Hard-cap the row: an over-wide row widens the parent's max_rect, so every later row would
695            // grow too, and long labels must truncate at the pane edge instead of pushing the button out.
696            ui.set_max_width((content_right - ui.max_rect().left()).max(0.0));
697            // Truncated labels still keep an ellipsis-wide minimum, so a row of them can overrun the cap
698            // anyway: clip so the spill is never painted over the button.
699            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                // fill the row without `add_space`, which would add an item spacing past the right edge
705                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        // drag_source senses click_and_drag on one widget, so clicks, double-clicks, right-clicks and
719        // drags all come off this response — no second click-sensing overlay to register.
720        let r = src;
721        // Put the button in the reserved slot rather than after the contents: the contents can be wider
722        // than their cap, and appending would then push the button off the pane edge.
723        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/// Gallery cell: a picture box with the kind tag in its corner and the name under it. Shared by the
740/// asset gallery and the reuse sections; the caller decides what the click means.
741#[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
778/// TextEdit for inline renames; Some(text) once editing finishes with a non-empty name.
779pub(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    // Read the flag before touching focus: request_focus() makes has_focus() true, and lost_focus() is
782    // `had_focus_last_frame && !has_focus` — asking for focus first would make it permanently false.
783    if r.lost_focus() {
784        if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
785            return Some(String::new()); // cancel
786        }
787        return Some(buf.trim().to_string()); // empty = cancel, caller decides
788    }
789    if !r.has_focus() {
790        r.request_focus();
791    }
792    None
793}
794
795/// Duration cell of an asset — "Loading…" while its import probe is still out (see engine::import).
796fn 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// ---------- the browser ----------
805
806#[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; // true when this frame starts an undo-worthy gesture
823    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    // ponytail: two extra walks so the toolbar can show the count before `used`/`planned` are built
835    // below; cache them behind a generation counter if a big project ever shows it.
836    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        // only when yt-dlp is installed — the whole URL import is optional
872        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        // up here with the rest of the controls: below the tree is reserved for files
883        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    // ponytail: both walk every clip / plan item per frame. Upgrade: cache them behind a generation
927    // counter bumped by App::after_edit() if a big project ever shows it.
928    let used = project.used_assets();
929    let planned = project.plan_assets();
930    // a search or a filter chip flattens "Imported" into its hits, the way an explorer shows search results
931    let flat = !state.search.is_empty() || state.kind_filter != 0 || state.label_filter != 0 || state.unused_only;
932
933    // the preview owns the bottom of the pane, so it never scrolls away with the list
934    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    // no drag-to-scroll: a drag across the list is a rubber band or a drag-and-drop, never a scroll
940    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        // filtered + sorted assets; the tree hangs each of them under its own folder
944        let mut order: Vec<usize> = (0..project.assets.len())
945            .filter(|&i| {
946                let a = &project.assets[i];
947                // the folder chosen in the tree only narrows a search — the tree itself shows every folder
948                (!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                    // same predicate as "Remove unused" below: moodboard assets are not unused
953                    && (!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            // the project's own reusable items, listed as more files rather than a section below
989            reuse_ui(ui, state.view, state.kind_filter, state.zoom, project, settings, palette, resp);
990        }
991
992        // clicking the empty space below the tree drops the selection, and the preview with it
993        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    // apply project mutations with a single undo per gesture (text fields snapshot on focus, one frame
1019    // before the first keystroke, so the snapshot is taken even when no op lands this frame)
1020    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
1089/// Ctrl+Scroll (and pinch) over the list scales the thumbnails, like every asset browser.
1090fn 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    // egui folds Ctrl+Scroll into zoom_delta and keeps it out of the scroll delta
1096    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
1102/// What to do with everything selected. Every button routes through the `LibraryResponse` fields the
1103/// per-item menus already use, so App needs no new plumbing.
1104/// ponytail: the Convert and Compress windows each take one file, so those two open on the first asset
1105/// of the selection. Upgrade: queue one job per asset once those windows accept a list.
1106fn 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
1144/// The search + filter toolbar: a framed band in the panel fill with a separator under it, so it never
1145/// reads as content. `head` draws the tab-specific leading row (New / Open / Import / sort).
1146fn 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        // wrapped: a narrow pane must stack the buttons, not push them past the right edge
1155        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            // TextEdit::desired_width is the *text* width; its frame adds a margin on top, so the row
1161            // used to be ~8px wider than the pane and dragged every later widget out with it.
1162            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// ---------- the preview ----------
1211
1212/// The dedicated asset preview: the bottom of the pane, and the only place tags / description / label /
1213/// folder are edited.
1214#[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/// Name / path / format, over the picture — shared by both previews.
1247#[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        // the live frame wins: asking the thumbnail cache as well would queue a decode per frame
1260        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    // Snapshot when the field is entered, not per keystroke: one undo entry per visit instead of one
1358    // per character (which used to evict the whole 200-entry stack while typing a paragraph).
1359    *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/// Preview of a file that is not in the project yet: import it from here, and edit the tags it carries
1376/// as a recent file (the only place a file outside the project can keep any).
1377#[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
1410// ---------- the file tree ----------
1411
1412/// Indent of a row `depth` levels into the tree. Rows that hold no disclosure triangle (files, hints)
1413/// add `ARROW`, so their icon lines up with the folder icons beside them.
1414fn indent(depth: usize) -> f32 {
1415    4.0 + depth as f32 * 14.0
1416}
1417
1418/// Width of the disclosure column.
1419const ARROW: f32 = 14.0;
1420
1421/// The folder that directly contains `path`; "" for a top-level folder.
1422fn parent_of(path: &str) -> &str {
1423    match path.rfind('/') {
1424        Some(i) => &path[..i],
1425        None => "",
1426    }
1427}
1428
1429/// Every project folder plus the parents only an asset implies ("A/B" with no "A" in `folders`), so no
1430/// branch of the tree hangs off a node that is never drawn.
1431fn 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
1447/// Tree key of a folder on disk (one place, so the tests build the same key).
1448fn dir_key(path: &str) -> String {
1449    format!("d:{path}")
1450}
1451
1452/// Shared borrows for the tree rows: they recurse, and would otherwise thread a dozen arguments each.
1453struct 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    /// Every selectable row of this frame, in draw order — Shift+click ranges and the band read it.
1465    rows: &'a mut Vec<(Pick, egui::Rect)>,
1466    /// The click to resolve once every row is known.
1467    click: &'a mut Option<(Pick, bool, bool)>,
1468    /// settings.json edit asked for by a recent file's menu; `settings` is only borrowed shared here,
1469    /// so the browser applies it after the tree.
1470    rec: &'a mut Option<RecOp>,
1471}
1472
1473impl Tree<'_, '_> {
1474    /// Is this node open? Only the flip away from `dflt` is remembered, so a new folder inherits it.
1475    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    /// The disclosure triangle at the head of a tree row; returns the node's state after this frame's
1489    /// click. Painted, not written: ▸ and ▾ are tofu boxes in Segoe UI.
1490    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    /// Move what was dropped onto a folder row into that folder — the whole selection when the dragged
1517    /// asset was part of it, so a marquee of clips moves in one gesture.
1518    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    /// Register a drawn row and route its click (the selection is resolved once every row is known).
1529    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            // right-clicking outside the selection selects that one item first, like every explorer
1539            *self.click = Some((pick, false, false));
1540        }
1541    }
1542
1543    /// Root 1 — "Imported": the folders and files this project contains.
1544    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    /// The subfolders of `path` (folders first, like an explorer), then the assets that live in it.
1554    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        // the "New folder" field appears under the folder it was asked for
1606        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    /// A run of assets, as rows or as gallery tiles (already filtered and sorted by the caller).
1628    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                // label tint: a colour bar on the left and the name in the same colour
1654                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    /// Every id the menu acts on: the whole selection when the item is part of it, else just it.
1703    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    /// Root 2 — "Global": Recent, and the folders the user linked, browsed straight from disk.
1762    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    /// Recent files, from settings.json — everything opened recently, across every project.
1777    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    /// One folder on disk. `root` marks a linked folder — Refresh / Unlink live in its menu.
1811    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    /// One level of a folder, read the first time the node is expanded and cached until Refresh.
1861    /// ponytail: a blocking read_dir on the UI thread, one level per expansion — an eager recursive walk
1862    /// of a media drive would hang the editor. Upgrade: scan on a worker + mpsc, like media/waveform.rs.
1863    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    /// A run of files that are not in the project. `recent` shows their folder and Recent label colour.
1873    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    /// Colour label of a recent file, if it has one.
1885    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    /// One media file on disk: thumbnail, name, kind. Single click previews it, double click imports it.
1891    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    /// Select + preview on a single click, import on a double click, plus the file menu (pins, labels
1940    /// and the recent list live here now that "Global" is where recent files are shown).
1941    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
1984// ---------- reusable things (bottom of the Imported tab) ----------
1985
1986enum 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
2057/// A painted icon the size of a text label, for the rows that are not files.
2058fn 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        // saved templates live in settings.json — no undo, so ask first
2123        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
2130/// One reusable thing the project already holds.
2131enum Reuse {
2132    /// An effect kind already used somewhere in the project.
2133    Effect(EffectKind),
2134    /// Index into `Settings::effect_presets` — a saved chain or a saved node graph.
2135    Preset(usize),
2136    /// A clip that renders from a node graph.
2137    Graph(Id),
2138    /// Index into `Settings::templates` holding a saved adjustment layer.
2139    Adjustment(usize),
2140}
2141
2142/// The reusable sections, read straight off the project and settings.json each frame.
2143/// ponytail: linear scans + a JSON decode per template (`is_adjustment_template`), same as the Presets
2144/// pane — the lists are short. A dedicated presets store plugs in here as extra `Reuse` items.
2145fn 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        // uses_graph, not graph.is_some(): a bare Input->Output pass-through is not worth reusing, and
2155        // since the node editor became opt-in it no longer means the clip renders from a graph
2156        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
2178/// (name, kind tag, icon, drag payload) of one reusable item.
2179/// ponytail: only adjustment layers have a drop target today — the others carry their name as a
2180/// `Template` payload because `row()`/`tile()` are drag sources, and a stray drop just misses.
2181fn 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
2200/// What a click on a reusable item asks the app to do.
2201fn 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
2210/// The reusable sections under the tree, in whichever view the toolbar selected.
2211fn 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    // these are files in the same list now, told apart by name and kind tag rather than by a heading,
2222    // and narrowed with the "Project" chip; every other chip is about media, so it hides them
2223    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        // the containing folder is NOT a match: searching a folder's name used to return everything
2315        // inside it, which is the opposite of narrowing
2316        assert!(!matches_search(&a, "day 1"));
2317        assert!(!matches_search(&a, "nope"));
2318        // and a file on disk matches on its name, not on any folder in its path
2319        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        // Short SFX: audio ≤ 10 s
2330        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        // Music: audio > 10 s
2334        assert!(matches_kind(ClipKind::Audio, 30.0, 6));
2335        assert!(!matches_kind(ClipKind::Audio, 3.0, 6));
2336        // Sequences chip never matches assets
2337        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)); // SFX chip = audio by extension
2371        assert!(!recent_matches(&rec, "", 1, 0));
2372        assert!(!recent_matches(&rec, "", 0, 3)); // label filter
2373    }
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        // an asset can sit in "A/B" without "A" ever being created
2413        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    /// Every file gets a picture: the cached thumbnail when there is one, and a painted object when
2419    /// there is not — never a font character, which Segoe UI draws as a tofu box.
2420    #[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                // no cache at all: every kind still gets its object
2430                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    /// Ctrl adds and removes one item, Shift takes the range from the anchor, a plain click replaces.
2437    #[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        // the anchor is item 3 now, so the range runs 2..=3
2453        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    /// A write to `selected` from app.rs (import / reveal) collapses the multi-selection onto it; our
2461    /// own writes go through `set_anchor` and are left alone.
2462    #[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); // what app.rs does after open_or_import
2469        external_select(&mut s);
2470        assert_eq!(s.sel_ids, vec![9]);
2471    }
2472
2473    /// A row that does not fit used to widen the parent Ui, so every row below it grew and its action
2474    /// button ended up outside the pane.
2475    #[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    /// The inline rename/create field commits when it loses focus (it used to re-grab focus first, so
2499    /// `lost_focus()` could never fire and nothing was ever committed).
2500    #[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        // focus is moved inside the pass: done between passes it counts as "had focus last frame"
2506        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    /// The filter/search strip renders as a toolbar: the search box sits inside it, a clear button
2525    /// empties the query, and the label chips come from the project's own labels.
2526    #[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        // the label chips only offer the labels the project actually has
2552        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        // clearing the search empties the field
2558        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    /// Rows are tinted with the project's label colour, and a recoloured label changes the tint.
2569    #[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        // 0 / unknown fall back to the dim "no label" colour
2577        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    /// Headless with a thumbnail cache attached: rows still lay out (no decode is required).
2582    #[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    /// The "Import URL…" button exists only when yt-dlp was found, and clicking it asks the app to
2615    /// open the Import URL window.
2616    #[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            // frame 1 lays the button out, frame 2 can click it at the position it landed on
2627            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                // find the button by its label among the frame's shapes
2642                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    /// The trailing action button must stay inside the pane at any width (it used to be pushed off the
2656    /// right edge once the path/tag labels were long).
2657    #[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    /// Rect of the painted text `label` in a frame's shapes (None when it was never drawn).
2685    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    /// A press + release at `at`.
2693    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    /// Headless: both sub-tabs lay out with folders, sequences, templates and a selected asset,
2706    /// reporting nothing when nothing was clicked.
2707    #[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            // app.rs's write to `selected` became the whole selection
2744            assert_eq!(state.sel_ids, vec![project.assets[1].id]);
2745            // the linked folder is collapsed: nothing was read from disk
2746            assert!(state.dirs.is_empty(), "collapsed linked folders must not be scanned");
2747        }
2748    }
2749
2750    /// Global browses one level at a time: expanding a node reads that directory and nothing else, and
2751    /// a single click on a file selects it, previews it, and fills the preview box.
2752    #[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        // collapsed: the linked folder is listed but never read
2789        run(&mut state, &mut project, None);
2790        assert!(state.dirs.is_empty(), "a collapsed folder must not be scanned");
2791
2792        // expanding the root reads exactly one level
2793        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        // and expanding the sub-folder reads the next one
2804        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        // a single click on the file selects it and reports it for the preview
2809        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    /// Tags and description are edited in the preview box and nowhere else, and the box only exists
2820    /// while something is selected.
2821    #[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    /// A selection offers the batch operations, and each one reports every selected item.
2852    #[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    /// The zoom scales both views and is clamped; a fresh state reads as 1.
2889    #[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    /// The reusable sections under the tree: effects in use, saved presets, node graphs and saved
2915    /// adjustment layers, each handing the right intent back to the app.
2916    /// Regression: a gallery must drop to a second row instead of running off to the right forever.
2917    /// `horizontal_wrapped` cannot be trusted here on its own — a drag source's size is not known until
2918    /// after its contents are drawn, so the wrap test can pass every time and the row never ends.
2919    #[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        // a real effect first: ensure_graph on an empty stack yields a bare Input->Output pass-through,
2958        // which uses_graph() (rightly) does not count as a graph worth reusing
2959        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                // the section headings are gone on purpose: these are files in the same list now, so
2994                // assert on the ITEMS instead, and that no heading came back
2995                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        // clicking each kind of item
3007        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}