From b63efeb0d3719f7cf21f9d641a5eb011f989c728 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 13 Oct 2026 15:12:32 +0530 Subject: [PATCH] perf(project): read file sizes once per sort --- crates/ui-egui/src/i18n/ui.rs | 14 +++ crates/ui-egui/src/panels/project.rs | 158 +++++++++++++++++++++++- crates/ui-egui/tests/ui_project_rows.rs | 102 +++++++++++++++ scripts/bench-project-size-sort.sh | 4 + 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100755 scripts/bench-project-size-sort.sh diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4af..d61eff44 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -252,6 +252,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Focus Distance", "フォーカス距離"), ("Follow Feature Rotation and Scale", "特徴の回転とスケールに追従"), ("Footage: {}", "フッテージ:{}"), + ("File size: {} bytes", "ファイルサイズ: {} バイト"), + ("File size unavailable", "ファイルサイズを取得できません"), ("Format: {}", "形式:{}"), ("Frame Aspect Ratio: {}", "フレームアスペクト比:{}"), ("Frame Blending: {}", "フレームブレンド:{}"), @@ -796,6 +798,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Focus Distance", "焦距"), ("Follow Feature Rotation and Scale", "跟随特征的旋转和缩放"), ("Footage: {}", "素材:{}"), + ("File size: {} bytes", "文件大小:{} 字节"), + ("File size unavailable", "无法获取文件大小"), ("Format: {}", "格式:{}"), ("Frame Aspect Ratio: {}", "帧长宽比:{}"), ("Frame Blending: {}", "帧混合:{}"), @@ -1528,6 +1532,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Focus Distance", "焦距"), ("Follow Feature Rotation and Scale", "跟隨特徵的旋轉和縮放"), ("Footage: {}", "素材:{}"), + ("File size: {} bytes", "檔案大小:{} 位元組"), + ("File size unavailable", "無法取得檔案大小"), ("Format: {}", "格式:{}"), ("Frame Aspect Ratio: {}", "影格長寬比:{}"), ("Frame Blending: {}", "影格混合:{}"), @@ -2263,6 +2269,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Focus Distance", "Відстань фокусування"), ("Follow Feature Rotation and Scale", "Враховувати обертання та масштаб ознаки"), ("Footage: {}", "Матеріал: {}"), + ("File size: {} bytes", "Розмір файлу: {} байт"), + ("File size unavailable", "Розмір файлу недоступний"), ("Format: {}", "Формат: {}"), ("Frame Aspect Ratio: {}", "Пропорції кадру: {}"), ("Frame Blending: {}", "Змішування кадрів: {}"), @@ -2815,6 +2823,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Focus Distance", "Distância de foco"), ("Follow Feature Rotation and Scale", "Acompanhar rotação e escala do recurso"), ("Footage: {}", "Mídia: {}"), + ("File size: {} bytes", "Tamanho do arquivo: {} bytes"), + ("File size unavailable", "Tamanho do arquivo indisponível"), ("Format: {}", "Formato: {}"), ("Frame Aspect Ratio: {}", "Proporção do quadro: {}"), ("Frame Blending: {}", "Mesclagem de quadros: {}"), @@ -3265,6 +3275,8 @@ const CZECH: &[(&str, &str)] = &[ ("Focus Distance", "Vzdálenost zaostření"), ("Follow Feature Rotation and Scale", "Sledovat otočení a měřítko prvku"), ("Footage: {}", "Záznam: {}"), + ("File size: {} bytes", "Velikost souboru: {} bajtů"), + ("File size unavailable", "Velikost souboru není k dispozici"), ("Format: {}", "Formát: {}"), ("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"), ("Frame Blending: {}", "Prolnutí snímků: {}"), @@ -3820,6 +3832,8 @@ const SPANISH: &[(&str, &str)] = &[ ("Focus Distance", "Distancia de enfoque"), ("Follow Feature Rotation and Scale", "Seguir la rotación y la escala de la característica"), ("Footage: {}", "Material de archivo: {}"), + ("File size: {} bytes", "Tamaño del archivo: {} bytes"), + ("File size unavailable", "Tamaño del archivo no disponible"), ("Format: {}", "Formato: {}"), ("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"), ("Frame Blending: {}", "Fusión de fotogramas: {}"), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8..2cb6b8c7 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [ /// Sort siblings by the Project panel's sort column; ties fall back to the name. pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { + if col == "size" { + sort_by_size(items, desc, file_size); + return; + } let name = |i: &Item| i.name.to_lowercase(); items.sort_by(|a, b| { let o = match col { "type" => a.type_name().cmp(b.type_name()), - "size" => file_size(a).cmp(&file_size(b)), "fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal), "duration" => a.duration().cmp(&b.duration()), "path" => file_path(a).cmp(file_path(b)), @@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) { }); } +fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option) { + let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase()); + if desc { + items.sort_by_cached_key(|it| std::cmp::Reverse(key(it))); + } else { + items.sort_by_cached_key(key); + } +} + /// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root. pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option) -> Option { let it = project.item(row?)?; @@ -754,7 +766,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cp = lp.with_clip_rect(cell); let txt = match *key { "type" => it.type_name().to_string(), - "size" => file_size(it).map(fmt_size).unwrap_or_default(), + "size" => { + let size = file_size(it); + let text = size.map(fmt_size).unwrap_or_else(|| if matches!(it.kind, ItemKind::Footage(_)) { "—".into() } else { String::new() }); + if matches!(it.kind, ItemKind::Footage(_)) { + let detail = match size { + Some(bytes) => crate::i18n::tr_args("File size: {} bytes", &[&bytes]), + None => crate::i18n::tr("File size unavailable").to_string(), + }; + let resp = ui.interact(cell, egui::Id::new(("psize", id.0)), Sense::hover()).on_hover_text(detail.as_str()); + app.auto.add_response(&format!("project.item.{}.size", id.0), &resp, &detail); + } + text + } "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(), "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(), "path" => file_path(it).to_string(), @@ -1113,6 +1137,136 @@ pub fn set_column(cols: &mut Vec, key: &str, on: bool) { #[cfg(test)] mod tests { use super::*; + use effectcraft_engine::project::{Footage, Project}; + + struct SizeFiles(std::path::PathBuf); + + impl SizeFiles { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn add(&self, project: &mut Project, name: &str, bytes: Option) { + let path = self.0.join(name); + if let Some(bytes) = bytes { + std::fs::File::create(&path).unwrap().set_len(bytes).unwrap(); + } + project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() })); + } + } + + impl Drop for SizeFiles { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn size_keys_are_sampled_once_with_stable_ties() { + let mut project = Project::default(); + for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] { + project.add_item(name, Label::Aqua, None, ItemKind::Folder); + } + for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] { + let mut reads = std::collections::BTreeMap::new(); + let mut rows = project.children(None); + sort_by_size(&mut rows, desc, |item| { + *reads.entry(item.id).or_insert(0) += 1; + match item.name.as_str() { + "Zeta" => Some(30), + "alpha" | "ALPHA" => Some(10), + "Zero" => Some(0), + _ => None, + } + }); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), expected); + assert_eq!(reads.len(), 5); + assert!(reads.values().all(|&count| count == 1)); + } + } + + #[test] + fn size_sort_reads_files_again_after_they_change() { + let files = SizeFiles::new(); + let mut project = Project::default(); + project.add_item("Folder", Label::Aqua, None, ItemKind::Folder); + files.add(&mut project, "Missing", None); + files.add(&mut project, "Zero", Some(0)); + files.add(&mut project, "Small", Some(10)); + files.add(&mut project, "Large", Some(20)); + let mut rows = project.children(None); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Missing", "Zero", "Small", "Large"]); + sort_items(&mut rows, "size", true); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Large", "Small", "Zero", "Missing", "Folder"]); + std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap(); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Missing", "Zero", "Large", "Small"]); + std::fs::remove_file(files.0.join("Large")).unwrap(); + sort_items(&mut rows, "size", false); + assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::>(), ["Folder", "Large", "Missing", "Zero", "Small"]); + } + + #[test] + fn size_sort_accepts_empty_and_singleton_lists() { + let mut project = Project::default(); + let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder); + for desc in [false, true] { + sort_items(&mut [], "size", desc); + let mut rows = project.children(None); + sort_items(&mut rows, "size", desc); + assert_eq!(rows.iter().map(|item| item.id).collect::>(), [id]); + } + } + + #[test] + #[ignore = "filesystem benchmark; run scripts/bench-project-size-sort.sh"] + fn size_sort_benchmark() { + let files = SizeFiles::new(); + let mut project = Project::default(); + for i in 0..1000 { + files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1)); + } + let original = project.children(None); + for item in &original { + assert!(file_size(item).is_some()); + } + let mut before_times = Vec::new(); + let mut after_times = Vec::new(); + let mut before_reads = 0; + let mut after_reads = 0; + for _ in 0..5 { + let mut before = original.clone(); + before_reads = 0; + let start = std::time::Instant::now(); + before.sort_by(|a, b| { + before_reads += 2; + file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + before_times.push(start.elapsed()); + let mut after = original.clone(); + after_reads = 0; + let start = std::time::Instant::now(); + sort_by_size(&mut after, false, |item| { + after_reads += 1; + file_size(item) + }); + after_times.push(start.elapsed()); + assert_eq!(before.iter().map(|item| item.id).collect::>(), after.iter().map(|item| item.id).collect::>()); + assert_eq!(after_reads, 1000); + assert!(before_reads > after_reads); + } + before_times.sort(); + after_times.sort(); + println!( + "1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}", + before_times[2], after_times[2] + ); + } #[test] fn ae_duration_and_size_formats() { diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 27ce5e69..949310f5 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -84,6 +84,108 @@ fn command_clicking_a_folder_twirl_opens_the_folders_inside_it() { assert!(h.state().ui.project_open_folders.is_empty(), "and close again together"); } +#[test] +fn size_column_sorts_and_explains_sizes_without_swallowing_row_clicks() { + use effectcraft_ui_egui::theme::ThemeKind; + for (name, size, theme) in [ + ("wide-dark", egui::vec2(1600.0, 1000.0), ThemeKind::Dark), + ("compact-dark", egui::vec2(1024.0, 768.0), ThemeKind::Dark), + ("compact-light", egui::vec2(1024.0, 768.0), ThemeKind::Light), + ] { + check_size_column(name, size, theme); + } +} + +fn check_size_column(name: &str, size: egui::Vec2, theme: effectcraft_ui_egui::theme::ThemeKind) { + struct Files(std::path::PathBuf); + impl Drop for Files { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let files = Files(std::env::temp_dir().join(format!( + "effectcraft-size-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + ))); + std::fs::create_dir(&files.0).unwrap(); + let mut s = Session::default(); + s.prefs.general.language = "en".into(); + let project = std::sync::Arc::make_mut(&mut s.project); + let folder = project.add_item("Folder", Label::Aqua, None, ItemKind::Folder); + let mut ids = Vec::new(); + for (name, bytes) in [("Small", Some(10)), ("Large", Some(20)), ("Unavailable", None), ("Empty", Some(0))] { + let path = files.0.join(name); + if let Some(bytes) = bytes { + std::fs::File::create(&path).unwrap().set_len(bytes).unwrap(); + } + ids.push(project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }))); + } + let [small, large, unavailable, empty] = ids.as_slice() else { panic!("fixture items") }; + let (small, large, unavailable, empty) = (*small, *large, *unavailable, *empty); + let mut app = EffectcraftApp::new(s); + app.ui.start_screen = false; + app.ui.project_columns = vec!["size".into()]; + let mut h = Harness::builder().with_size(size).build_eframe(|_| app); + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); + let size_id = |id: ItemId| format!("project.item.{}.size", id.0); + let assert_order = |h: &Harness<'_, EffectcraftApp>, expected: &[ItemId]| { + let mut visible = expected.to_vec(); + visible.sort_by(|a, b| center(h, &format!("project.item.{}", a.0)).y.total_cmp(¢er(h, &format!("project.item.{}", b.0)).y)); + assert_eq!(visible, expected); + }; + let undo = h.state().session.history.undo.len(); + click(&mut h, "project.sort.size"); + assert_order(&h, &[folder, unavailable, empty, small, large]); + click(&mut h, "project.sort.size"); + assert_order(&h, &[large, small, empty, unavailable, folder]); + assert_eq!(h.state().auto.find(&size_id(empty)).unwrap().label, "File size: 0 bytes"); + assert!(h.state().auto.find(&size_id(folder)).is_none()); + let at = center(&h, &size_id(small)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(5); + assert!(h.query_by_label("File size: 10 bytes").is_some(), "hover exposes exact bytes when both files display 1 KB"); + if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/project-size-{name}-hover.png")).unwrap(); + } + click_at(&mut h, at); + assert_eq!(h.state().session.state.project_selection, [small]); + let at = center(&h, &size_id(unavailable)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(5); + assert!(h.query_by_label("File size unavailable").is_some()); + let has_dash = |shape: &egui::Shape| matches!(shape, egui::Shape::Text(text) if text.galley.text() == "—"); + assert!(h.output().shapes.iter().any(|shape| has_dash(&shape.shape)), "unavailable footage has a visible marker"); + if let Ok(dir) = std::env::var("PROJECT_SIZE_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/project-size-{name}-unavailable.png")).unwrap(); + } + std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap(); + h.run_steps(3); + assert_order(&h, &[small, large, empty, unavailable, folder]); + assert_eq!(h.state().auto.find(&size_id(small)).unwrap().label, "File size: 30 bytes"); + assert_eq!(h.state().session.history.undo.len(), undo, "sorting and size details don't edit the project"); + let from = center(&h, &size_id(small)); + let to = center(&h, &format!("project.item.{}.name", folder.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + button(&mut h, from, true); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + button(&mut h, to, false); + h.run_steps(3); + assert_eq!(h.state().session.project.item(small).unwrap().parent, Some(folder), "dragging from Size still moves the item"); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.project.item(small).unwrap().parent, None); + assert_order(&h, &[small, large, empty, unavailable, folder]); +} + #[test] fn folder_twirl_opens_and_closes_the_folder_on_one_click() { let (mut h, folder, comp, _) = harness(); diff --git a/scripts/bench-project-size-sort.sh b/scripts/bench-project-size-sort.sh new file mode 100755 index 00000000..b85e495d --- /dev/null +++ b/scripts/bench-project-size-sort.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +CARGO_PROFILE_RELEASE_LTO=false cargo test -p effectcraft-ui-egui --release --lib panels::project::tests::size_sort_benchmark -- --ignored --nocapture