Merge PR #8219: Color Lookup lists the LUTs in a Preferences folder (#6558)

crates/ui-egui/Cargo.toml: kept main's photocraft-psd dev-dependency (testgen feature) and added the PR's tempfile dev-dependency
docs/scorecard.md: ours
This commit is contained in:
aiongg
2026-10-22 15:37:41 +00:00
28 changed files with 225 additions and 6 deletions
Generated
+1
View File
@@ -5076,6 +5076,7 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
"tempfile",
"toml",
"unicode-bidi",
"winsafe",
+7 -1
View File
@@ -383,8 +383,11 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
fn preference_uses_ambient_filesystem(path: &str) -> bool {
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
// judge the first non-empty one; no segment at all is the whole-preferences update.
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
let mut segments = path.split('.').filter(|segment| !segment.is_empty());
let Some(section) = segments.next() else { return true };
// File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not.
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|| (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder"))
}
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
@@ -862,6 +865,8 @@ mod tests {
"historyLog.filePath",
".historyLog.filePath",
"plugIns.additionalPluginsFolder",
"fileHandling.lutFolder",
"fileHandling",
"scratchDisks.disks",
".",
"..historyLog.filePath",
@@ -872,6 +877,7 @@ mod tests {
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
);
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok());
}
#[test]
+38
View File
@@ -168,6 +168,44 @@ fn base_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
/// Most LUT files [`folder_luts`] lists.
const MAX_FOLDER_LUTS: usize = 500;
/// Largest LUT file [`folder_luts`] lists: a 129³ `.cube` (the parser's largest) is about 60 MB.
const MAX_FOLDER_LUT_BYTES: u64 = 64 << 20;
/// The `.cube`, `.3dl` and `.look` files directly in `dir`, as (file name, path) sorted by name,
/// for Color Lookup's dropdown (Preferences › File Handling › LUT Folder, #6558). An empty,
/// missing or unreadable folder lists nothing; hidden files, subfolders and files too large to be
/// a LUT are skipped. A symlink to a LUT file is listed, as a studio's shared library often is.
/// Contents are only parsed when one is picked, which reports a malformed file.
#[cfg(not(target_arch = "wasm32"))]
pub fn folder_luts(dir: &str) -> Vec<(String, String)> {
let dir = dir.trim();
let Some(entries) = (!dir.is_empty()).then(|| std::fs::read_dir(dir).ok()).flatten() else { return Vec::new() };
let mut out: Vec<(String, String)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
if name.starts_with('.') || !matches!(ext.as_str(), "cube" | "3dl" | "look") {
return None;
}
let path = e.path();
let meta = std::fs::metadata(&path).ok()?;
(meta.is_file() && meta.len() <= MAX_FOLDER_LUT_BYTES).then_some((name, path.to_str()?.to_string()))
})
.collect();
out.sort_by_key(|(name, _)| name.to_lowercase());
out.truncate(MAX_FOLDER_LUTS);
out
}
/// No file system on the web: a LUT folder lists nothing there.
#[cfg(target_arch = "wasm32")]
pub fn folder_luts(_: &str) -> Vec<(String, String)> {
Vec::new()
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> Option<Vec<u8>> {
std::fs::read(path).ok()
+20
View File
@@ -731,3 +731,23 @@ fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() {
}
}
}
/// #6558: a LUT folder lists its LUT files by name, case-insensitively, and nothing else.
#[test]
fn folder_luts_lists_lut_files_by_name() {
let dir = tempfile::tempdir().unwrap();
for f in ["b.cube", "A.3DL", "c.look", "notes.txt", ".hidden.cube", "noext"] {
std::fs::write(dir.path().join(f), "LUT_3D_SIZE 2\n").unwrap();
}
std::fs::create_dir(dir.path().join("sub.cube")).unwrap();
// Too large to be a LUT (sparse, so cheap to make).
std::fs::File::create(dir.path().join("huge.cube")).unwrap().set_len(MAX_FOLDER_LUT_BYTES + 1).unwrap();
// A symlink to a LUT is listed: shared LUT libraries are often linked in.
#[cfg(unix)]
std::os::unix::fs::symlink(dir.path().join("b.cube"), dir.path().join("linked.cube")).unwrap();
let want: Vec<_> = ["A.3DL", "b.cube", "c.look"].into_iter().chain(cfg!(unix).then_some("linked.cube")).collect();
let names: Vec<_> = folder_luts(dir.path().to_str().unwrap()).into_iter().map(|(name, _)| name).collect();
assert_eq!(names, want);
assert!(folder_luts("").is_empty());
assert!(folder_luts(dir.path().join("missing").to_str().unwrap()).is_empty());
}
+4
View File
@@ -465,6 +465,9 @@ pub struct FileHandling {
pub recent_file_count: u32,
/// Most recently opened files, newest first (File › Open Recent).
pub recent_files: Vec<String>,
/// A folder whose `.cube`, `.3dl` and `.look` files Color Lookup lists under its built-in
/// looks (native only, #6558). Empty lists none.
pub lut_folder: String,
}
impl Default for FileHandling {
@@ -483,6 +486,7 @@ impl Default for FileHandling {
rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32,
recent_file_count: 20,
recent_files: Vec::new(),
lut_folder: String::new(),
}
}
}
+2
View File
@@ -63,6 +63,8 @@ photocraft-testkit = { workspace = true }
# examples/perf_scenarios.rs (a 500-brush .abr for the #210 import row) and large_file_bench.rs
# (the generated 2 GB PSD and 4.4 GB PSB of P20, `fixtures`).
photocraft-psd = { workspace = true, features = ["testgen"] }
# Isolated, self-cleaning folders for filesystem tests (the LUT folder, #6558).
tempfile = "3"
[build-dependencies]
# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them.
+126 -4
View File
@@ -4,6 +4,8 @@
//! values). Selective Color previews live while a slider drags, via `app.live_adjust` carrying
//! the full parameter set; Color Lookup commits on every change (its table isn't in the params).
use std::sync::{Arc, Mutex, PoisonError};
use photocraft_doc::{Adjustment, LayerId};
use photocraft_engine::adjust_cmds::RANGES;
use serde_json::{Value, json};
@@ -90,14 +92,94 @@ fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result<Value,
})
}
/// Dropdown key prefix of a LUT listed from the LUT folder; the rest of the key is its path.
const FOLDER_LUT: &str = "file:";
type FolderLuts = Vec<(String, String)>;
/// The LUT folder scan kept between frames: the latest finished list of `folder`, and the scan
/// in flight, if any.
#[derive(Clone, Default)]
struct FolderScan {
folder: String,
started: Option<f64>,
luts: FolderLuts,
pending: Option<Arc<Mutex<Option<FolderLuts>>>>,
}
/// The LUT folder's files (Preferences › File Handling › LUT Folder, #6558). The folder is
/// scanned on a worker thread at most every couple of seconds, since a network share can be
/// slow; until a scan finishes the dropdown shows the previous one.
fn folder_luts(app: &PhotocraftApp, ctx: &egui::Context) -> FolderLuts {
let folder = app.session.prefs().file_handling.lut_folder.clone();
let now = ctx.input(|input| input.time);
let key = egui::Id::new("color-lookup-folder");
let mut scan = ctx.data(|data| data.get_temp::<FolderScan>(key)).unwrap_or_default();
if let Some(done) = scan.pending.as_ref().and_then(|p| p.lock().unwrap_or_else(PoisonError::into_inner).take()) {
scan.luts = done;
scan.pending = None;
}
if scan.folder != folder {
// Another folder: never show the old one's files, and drop its scan's result.
scan = FolderScan { folder: folder.clone(), ..FolderScan::default() };
}
if scan.pending.is_none() && scan.started.is_none_or(|at| now - at >= 2.0) {
scan.started = Some(now);
scan.pending = Some(start_folder_scan(folder, ctx.clone()));
}
let luts = scan.luts.clone();
ctx.data_mut(|data| data.insert_temp(key, scan));
luts
}
/// Scan `folder` on a worker thread (on the web, where there are none, right here: it lists
/// nothing there anyway).
fn start_folder_scan(folder: String, ctx: egui::Context) -> Arc<Mutex<Option<FolderLuts>>> {
let result = Arc::new(Mutex::new(None));
let shared = Arc::clone(&result);
let work = move || {
let luts = photocraft_engine::adjust_cmds::folder_luts(&folder);
*shared.lock().unwrap_or_else(PoisonError::into_inner) = Some(luts);
ctx.request_repaint();
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("lut-folder-scan".into()).spawn(work).is_err() {
// No thread: list nothing this time; the next scan retries.
*result.lock().unwrap_or_else(PoisonError::into_inner) = Some(Vec::new());
}
#[cfg(target_arch = "wasm32")]
work();
result
}
/// The dropdown key for the current look: a built-in id, a LUT folder file, `none` or `custom`.
fn current_lookup(name: &str, has_lut: bool, folder: &[(String, String)]) -> String {
if !has_lut {
return "none".into();
}
if let Some((id, _)) = photocraft_engine::adjust_cmds::LOOKS.iter().find(|b| b.1 == name) {
return id.to_string();
}
folder.iter().find(|(file, _)| file == name).map_or_else(|| "custom".into(), |(_, path)| format!("{FOLDER_LUT}{path}"))
}
/// `layer.setAdjustment` params for a dropdown key: a folder file is read and embedded like
/// Load 3D LUT… does; anything else is a built-in id or `none`.
fn lookup_params(key: &str) -> Value {
match key.strip_prefix(FOLDER_LUT) {
Some(path) => json!({"file": path}),
None => json!({"lut": key}),
}
}
pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) {
let t = Tokens::get(ui.ctx());
let Adjustment::ColorLookup { name, lut, size, tetrahedral, dither } = adj else {
return;
};
let builtins = photocraft_engine::adjust_cmds::LOOKS;
let current =
if lut.is_none() { "none".to_string() } else { builtins.iter().find(|b| b.1 == name).map_or_else(|| "custom".to_string(), |b| b.0.to_string()) };
let folder = folder_luts(app, ui.ctx());
let current = current_lookup(name, lut.is_some(), &folder);
let current = app
.live_adjust
.as_ref()
@@ -108,6 +190,7 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let custom_label = format!("{name} ({size}³)");
let mut opts: Vec<(String, &str)> = vec![("load".into(), tl!("Load 3D LUT…")), ("none".into(), tl!("None"))];
opts.extend(builtins.iter().map(|(id, label)| (id.to_string(), *label)));
opts.extend(folder.iter().map(|(file, path)| (format!("{FOLDER_LUT}{path}"), file.as_str())));
if current == "custom" {
opts.push(("custom".into(), custom_label.as_str()));
}
@@ -127,14 +210,16 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0);
if !chosen.is_empty() {
if cycle_input {
if sel != "load" && sel != "custom" {
// ponytail: wheel previews cover the built-ins only; a folder LUT would be re-read
// from disk on every step. Pick it with a click.
if sel != "load" && sel != "custom" && !sel.starts_with(FOLDER_LUT) {
app.live_adjust = Some((id, json!({"lut": sel})));
cycling_preview = true;
}
} else if sel == "load" {
browse = true;
} else if sel != current && sel != "custom" {
params = Some(json!({"lut": sel}));
params = Some(lookup_params(&sel));
}
}
});
@@ -219,6 +304,43 @@ mod lookup_picker_tests {
);
}
/// #6558: a LUT folder file is offered by name, and picking it embeds it.
#[test]
fn lut_folder_files_are_offered_and_embedded() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let path = dir.join("film.cube");
std::fs::write(&path, CUBE).unwrap();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("prefs.set", json!({"path": "fileHandling.lutFolder", "value": dir.to_str().unwrap()})).unwrap();
// The scan runs on a worker thread: ask again until it has finished.
let ctx = egui::Context::default();
let mut folder = folder_luts(&app, &ctx);
for _ in 0..500 {
if !folder.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
folder = folder_luts(&app, &ctx);
}
let key = format!("{FOLDER_LUT}{}", path.to_str().unwrap());
assert_eq!(folder, [("film.cube".to_string(), path.to_str().unwrap().to_string())]);
app.run("file.new", json!({"width": 16, "height": 12})).unwrap();
let layer = app.run("layer.newAdjustmentLayer.colorLookup", json!({})).unwrap()["layer"].as_u64().unwrap();
let mut p = lookup_params(&key);
p["layer"] = json!(layer);
app.run("layer.setAdjustment", p).unwrap();
let photocraft_doc::LayerContent::Adjustment(Adjustment::ColorLookup { name, lut, .. }) =
&app.session.active().unwrap().doc.layer(LayerId(layer)).unwrap().content
else {
panic!("not a Color Lookup layer")
};
assert!(lut.is_some());
assert_eq!(current_lookup(name, true, &folder), key);
assert_eq!(current_lookup(name, true, &[]), "custom");
assert_eq!(lookup_params("none"), json!({"lut": "none"}));
}
#[test]
fn invalid_lut_does_not_modify_the_existing_adjustment() {
let (dialog, _) = file_dialog::fake(vec![Some(FileDialogAnswer::Contents("broken.cube".into(), b"invalid".to_vec()))]);
+1
View File
@@ -2008,6 +2008,7 @@
Quick export format Formát rychlého exportu
Quick export location Umístění rychlého exportu
Recent file count Počet nedávných souborů
LUT folder Složka LUT
Recent files Nedávné soubory
Recent fonts Nedávná písma
Recover on launch Obnovit při spuštění
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1982,6 +1982,7 @@
Quick export format Schnellexport-Format
Quick export location Schnellexport-Ziel
Recent file count Anzahl zuletzt geöffneter Dateien
LUT folder LUT-Ordner
Recent files Zuletzt geöffnete Dateien
Recent fonts Zuletzt verwendete Schriften
Recover on launch Beim Start wiederherstellen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2898,6 +2898,7 @@
Raw Data Ακατέργαστα δεδομένα
Raw Defaults Προεπιλογές RAW
Recent file count Αριθμός πρόσφατων αρχείων
LUT folder Φάκελος LUT
Recent files Πρόσφατα αρχεία
Recent fonts Πρόσφατες γραμματοσειρές
Reconstruct Ανακατασκευή
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+1
View File
@@ -1996,6 +1996,7 @@
Quick export format Formato de exportación rápida
Quick export location Ubicación de exportación rápida
Recent file count Número de archivos recientes
LUT folder Carpeta de LUT
Recent files Archivos recientes
Recent fonts Fuentes recientes
Recover on launch Recuperar al iniciar
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -1964,6 +1964,7 @@
Quick export format Format d'exportation rapide
Quick export location Emplacement d'exportation rapide
Recent file count Nombre de fichiers récents
LUT folder Dossier LUT
Recent files Fichiers récents
Recent fonts Polices récentes
Recover on launch Récupérer au démarrage
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3479,6 +3479,7 @@ cameraRaw Yellow Sárga
Random Véletlenszerű
Rasterize svg groups deeper than SVG-csoportok raszterizálása ennél mélyebb szinten
Recent file count Legutóbbi fájlok száma
LUT folder LUT-mappa
Recent files Legutóbbi fájlok
Recent Files Legutóbbi fájlok
Recent fonts Legutóbbi betűtípusok
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1958,6 +1958,7 @@
Quick export format Format ekspor cepat
Quick export location Lokasi ekspor cepat
Recent file count Jumlah berkas terbaru
LUT folder Folder LUT
Recent files Berkas terbaru
Recent fonts Font terbaru
Recover on launch Pulihkan saat dibuka
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -1990,6 +1990,7 @@
Quick export format Formato esportazione rapida
Quick export location Posizione esportazione rapida
Recent file count Conteggio file recenti
LUT folder Cartella LUT
Recent files File recenti
Recent fonts Font recenti
Recover on launch Recupera all'avvio
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
1990
1991
1992
1993
1994
1995
1996
+1
View File
@@ -1992,6 +1992,7 @@
Quick export format クイック書き出しの形式
Quick export location クイック書き出しの保存先
Recent file count 最近使ったファイルの数
LUT folder LUT フォルダー
Recent files 最近使ったファイル
Recent fonts 最近使ったフォント
Recover on launch 起動時に復元
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1959,6 +1959,7 @@
Quick export format 빠른 내보내기 형식
Quick export location 빠른 내보내기 위치
Recent file count 최근 파일 수
LUT folder LUT 폴더
Recent files 최근 파일
Recent fonts 최근 글꼴
Recover on launch 시작 시 복구
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1959
1960
1961
1962
1963
1964
1965
+1
View File
@@ -1964,6 +1964,7 @@
Quick export format Formaat snel exporteren
Quick export location Locatie snel exporteren
Recent file count Aantal recente bestanden
LUT folder LUT-map
Recent files Recente bestanden
Recent fonts Recente lettertypen
Recover on launch Herstellen bij opstarten
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -1985,6 +1985,7 @@
Quick export format Format szybkiego eksportu
Quick export location Lokalizacja szybkiego eksportu
Recent file count Liczba ostatnich plików
LUT folder Folder LUT
Recent files Ostatnie pliki
Recent fonts Ostatnie czcionki
Recover on launch Odzyskaj przy uruchomieniu
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1958,6 +1958,7 @@
Quick export format Formato da exportação rápida
Quick export location Local da exportação rápida
Recent file count Número de arquivos recentes
LUT folder Pasta de LUTs
Recent files Arquivos recentes
Recent fonts Fontes recentes
Recover on launch Recuperar ao iniciar
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2420,6 +2420,7 @@
Quick export format Формат быстрого экспорта
Quick export location Расположение быстрого экспорта
Recent file count Число недавних файлов
LUT folder Папка LUT
Recent files Недавние файлы
Recent fonts Недавние шрифты
Recover on launch Восстановление при запуске
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1944,6 +1944,7 @@
Quick export format Format brzog izvoza
Quick export location Lokacija brzog izvoza
Recent file count Broj nedavnih datoteka
LUT folder Fascikla LUT
Recent files Nedavne datoteke
Recent fonts Nedavni fontovi
Recover on launch Oporavi pri pokretanju
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1940,6 +1940,7 @@
Quick export format Hızlı dışa aktarma formatı
Quick export location Hızlı dışa aktarma konumu
Recent file count Son dosya sayısı
LUT folder LUT klasörü
Recent files Son dosyalar
Recent fonts En yeni yazı tipleri
Recover on launch Başlatıldığında kurtar
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -2392,6 +2392,7 @@
Quick export format Формат швидкого експорту
Quick export location Розташування швидкого експорту
Recent file count Кількість нещодавніх файлів
LUT folder Папка LUT
Recent files Нещодавні файли
Recent fonts Нещодавні шрифти
Recover on launch Відновлювати під час запуску
Can't render this file because it contains an unexpected character in line 1394 and column 18.
+1
View File
@@ -1502,6 +1502,7 @@ cameraRaw Shadows Vùng tối
Quick export format Định dạng xuất nhanh
Quick export location Vị trí xuất nhanh
Recent file count Số tệp gần đây
LUT folder Thư mục LUT
Recent files Tệp gần đây
Recover on launch Khôi phục khi khởi chạy
Remember workspace changes Ghi nhớ thay đổi không gian làm việc
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2008,6 +2008,7 @@
Quick export format 快速导出格式
Quick export location 快速导出位置
Recent file count 最近使用的文件数量
LUT folder LUT 文件夹
Recent files 最近使用的文件
Recent fonts 最近使用的字体
Recover on launch 启动时恢复
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2008
2009
2010
2011
2012
2013
2014
+1
View File
@@ -2001,6 +2001,7 @@
Quick export format 快速匯出格式
Quick export location 快速匯出位置
Recent file count 最近檔案數量
LUT folder LUT 資料夾
Recent files 最近檔案
Recent fonts 最近使用的字體
Recover on launch 啟動時復原
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -1
View File
@@ -1246,7 +1246,13 @@ fn humanize(key: &str) -> String {
s.push(ch);
}
}
s.replace("Psd", "PSD").replace("Gpu", "GPU").replace("Ui ", "UI ").replace("Mb", "(MB)").replace("Exif", "EXIF").replace("Hud", "HUD")
s.replace("Psd", "PSD")
.replace("Gpu", "GPU")
.replace("Ui ", "UI ")
.replace("Mb", "(MB)")
.replace("Exif", "EXIF")
.replace("Hud", "HUD")
.replace("Lut ", "LUT ")
}
fn choice_label(v: &str) -> String {