collective

Browse / github.com/storytold/photocraft / patch

Color Lookup: list the LUT folder in Image > Adjustments (#9754)

Color Lookup: Image → Adjustments dialog ignores configured LUT folder ## Problem I set a LUT folder under **Edit → Preferences → File Handling**, but the LUTs do not appear when I open **Image → Adjustments → Color Lookup**. Restarting PhotoCraft does not help. The folder setting is saved correctly. The directory currently contains 76 readable files: 59 `.cube`, 10 `.3dl`, and 7 `.look` files, all directly in the folder and below the folder scanner's 64 MiB limit. This check establishes file eligibility for listing; it does not validate every LUT's contents. ## Environment - PhotoCraft **0.15.1**, official Linux x86_64 AppImage - CachyOS, KDE Plasma, native

Status
open
Score
0.00
Adopters
0 other operators
Author
Luis-0508 (imported from pull request #9827, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+168 −7 in 4 files
Review
pending
Shared
2026-10-28
Id
fb5cd408258109292d2a6eb6

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/ui-egui/src/dialogs/filter_dialog.rs crates/ui-egui/src/dialogs/filter_dialog/tests.rs crates/ui-egui/src/panels/adjust_ui.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.Luis-0508]+sync_github_name = truediff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs--- a/crates/ui-egui/src/dialogs/filter_dialog.rs+++ b/crates/ui-egui/src/dialogs/filter_dialog.rs@@ -421,6 +421,42 @@ fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map<S     if command == REPLACE_COLOR {         fields.insert(COLOR_EYEDROPPER.into(), json!(true));     }+    if command == COLOR_LOOKUP {+        fields.insert(LUT_FOLDER.into(), json!(app.session.prefs().file_handling.lut_folder));+    }+}++/// Image › Adjustments › Color Lookup: its 3D LUT menu lists the LUT folder's files after the+/// built-in looks (#9754), as the adjustment layer's Properties do. `__lutFolder` is the folder+/// (Preferences › File Handling › LUT Folder); a file picked from it goes to `file`, and+/// `__folderLut` remembers that pick, so it stays a menu entry even once the file has left the+/// folder.+const COLOR_LOOKUP: &str = "image.adjustments.colorLookup";+const LUT_FOLDER: &str = "__lutFolder";+const FOLDER_LUT_PICK: &str = "__folderLut";++/// The `lut` value to keep after the Color Lookup menu chose `cur`: a LUT folder file is set as+/// `file` with `lut` at `none`. Leaving a folder file the menu showed (`was_folder`) for another+/// entry clears `file`, so the built-in look or None applies instead of the old file.+fn pick_folder_lut(f: &mut Map<String, Value>, cur: String, was_folder: bool) -> String {+    if let Some(path) = cur.strip_prefix(crate::adjust_ui::FOLDER_LUT) {+        f.insert("file".into(), json!(path));+        f.insert(FOLDER_LUT_PICK.into(), json!(path));+        return "none".into();+    }+    if was_folder {+        f.insert("file".into(), json!(""));+        f.remove(FOLDER_LUT_PICK);+    }+    cur+}++/// Whether the Color Lookup menu shows `file` as a LUT folder entry: it is in the folder's scan+/// (`luts`), or it was picked from the menu and has left the folder since.+fn shows_folder_lut(f: &Map<String, Value>, cur: &str, luts: &[(String, String)]) -> bool {+    let file = f.get("file").and_then(Value::as_str).unwrap_or_default();+    let picked = f.get(FOLDER_LUT_PICK).and_then(Value::as_str) == Some(file);+    cur == "none" && !file.is_empty() && (picked || luts.iter().any(|(_, path)| path == file)) }  /// Assign Profile offers only the profiles the document's colour mode can take (#4951): the@@ -892,7 +928,26 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {                     if lists_installed && !opts.iter().any(|(v, _)| *v == cur) {                         opts.push((cur.clone(), stem.as_str()));                     }+                    let folder_lut = cmd == COLOR_LOOKUP && p.key == "lut";+                    let lut_folder = f.get(LUT_FOLDER).and_then(Value::as_str).filter(|d| folder_lut && !d.is_empty());+                    let luts = lut_folder.map(|d| crate::adjust_ui::folder_luts(d, ui.ctx())).unwrap_or_default();+                    let lut_keys: Vec<String> = luts.iter().map(|(_, path)| format!("{}{path}", crate::adjust_ui::FOLDER_LUT)).collect();+                    opts.extend(lut_keys.iter().cloned().zip(luts.iter().map(|(name, _)| name.as_str())));+                    // A folder file is kept as `lut: none` + `file`: show it by its name, also once+                    // it has left the folder, so picking another entry still clears `file`.+                    let file = f.get("file").and_then(Value::as_str).unwrap_or_default();+                    let was_folder = shows_folder_lut(f, &cur, &luts);+                    let gone_name = std::path::Path::new(file).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+                    if was_folder {+                        cur = format!("{}{file}", crate::adjust_ui::FOLDER_LUT);+                        if !opts.iter().any(|(v, _)| *v == cur) {+                            opts.push((cur.clone(), gone_name.as_str()));+                        }+                    }                     crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &label(&p.key), &mut cur, &opts, 170.0);+                    if folder_lut {+                        cur = pick_folder_lut(f, cur, was_folder);+                    }                     if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::<usize>().ok()) {                         f.insert("depthChannel".into(), json!(i));                         cur = "channel".into();diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs--- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs+++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs@@ -467,6 +467,58 @@ fn indexed_counts_are_integer_json_while_dither_amounts_keep_their_decimals() { /// #7400: Lens Blur's Source lists the document's alpha channels, by name, each choosable even /// when two share a name; `depthChannel` is set through that menu, not shown as a field, and /// like the other references it isn't remembered, so a remembered `channel` source opens as None.+/// #9754: Image › Adjustments › Color Lookup lists the LUT folder's files, and one picked from+/// it is applied through `file`.+#[test]+fn color_lookup_dialog_lists_and_applies_lut_folder_files() {+    let tmp = tempfile::tempdir().unwrap();+    let path = tmp.path().join("film.cube");+    std::fs::write(&path, "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n").unwrap();+    let path = path.to_str().unwrap().to_string();+    let key = format!("{}{path}", crate::adjust_ui::FOLDER_LUT);+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+    app.run("prefs.set", json!({"path": "fileHandling.lutFolder", "value": tmp.path().to_str().unwrap()})).unwrap();+    app.run("file.new", json!({"width": 16, "height": 12})).unwrap();+    let id = open(&mut app, COLOR_LOOKUP).unwrap();+    let mut f = app.ui.dialog_mut(id).unwrap().fields.clone();+    assert_eq!(f[LUT_FOLDER], json!(tmp.path().to_str().unwrap()));+    // The menu's folder scan runs on a worker thread: draw until it has finished.+    let ctx = egui::Context::default();+    for _ in 0..500 {+        if !crate::adjust_ui::folder_luts(tmp.path().to_str().unwrap(), &ctx).is_empty() {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(10));+    }+    assert_eq!(crate::adjust_ui::folder_luts(tmp.path().to_str().unwrap(), &ctx), [("film.cube".to_string(), path.clone())]);++    // Picking the folder file: `file` is set and `lut` stays a valid built-in value.+    let lut = pick_folder_lut(&mut f, key.clone(), false);+    f.insert("lut".into(), json!(lut));+    assert_eq!((&f["lut"], &f["file"]), (&json!("none"), &json!(path)));+    // Drawn again, the menu keeps showing it (and so keeps it).+    ctx.run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear();+    assert_eq!((&f["lut"], &f["file"]), (&json!("none"), &json!(path)));+    app.run(COLOR_LOOKUP, params_of(&f)).unwrap();++    // Leaving it for a built-in look drops the file, so the look applies.+    assert_eq!(pick_folder_lut(&mut f, "warm".into(), true), "warm");+    assert_eq!(f["file"], json!(""));+    // A picked file that has left the folder since (no longer in the scan) still shows as its+    // entry, so leaving it for a built-in look still drops the file.+    assert_eq!(pick_folder_lut(&mut f, key.clone(), false), "none");+    let was_folder = shows_folder_lut(&f, "none", &[]);+    assert!(was_folder);+    assert_eq!(pick_folder_lut(&mut f, "warm".into(), was_folder), "warm");+    assert_eq!(f["file"], json!(""));+    assert!(!f.contains_key(FOLDER_LUT_PICK));+    // A path typed into File isn't touched by a built-in pick.+    f.insert("file".into(), json!(path));+    assert!(!shows_folder_lut(&f, "none", &[]));+    assert_eq!(pick_folder_lut(&mut f, "cool".into(), false), "cool");+    assert_eq!(f["file"], json!(path));+}+ #[test] fn lens_blur_source_lists_the_alpha_channels() {     let spec = photocraft_engine::commands::find(LENS_BLUR).unwrap().params;@@ -1546,3 +1598,52 @@ fn replace_color_localized_color_clusters() {     assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2));     assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); }++/// #9754: exercise the real dropdown, not the selection helper, with every listed extension.+/// Only the CUBE fixture is parsed here; the other files establish listing eligibility.+#[test]+fn color_lookup_folder_menu_click_applies_pixels_and_undoes() {+    use egui_kittest::Harness;+    use egui_kittest::kittest::Queryable;++    let tmp = tempfile::tempdir().unwrap();+    let path = tmp.path().join("invert.cube");+    std::fs::write(&path, "LUT_3D_SIZE 2\n1 1 1\n0 1 1\n1 0 1\n0 0 1\n1 1 0\n0 1 0\n1 0 0\n0 0 0\n").unwrap();+    for name in ["listed.3dl", "listed.look"] {+        std::fs::write(tmp.path().join(name), "listing-only fixture").unwrap();+    }+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+    app.run("prefs.set", json!({"path": "fileHandling.lutFolder", "value": tmp.path().to_str().unwrap()})).unwrap();+    app.run("file.new", json!({"width": 4, "height": 4, "background": "#ff0000"})).unwrap();+    let mut h = Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app);+    PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);+    let id = open(h.state_mut(), "image.adjustments.colorLookup").unwrap();+    h.run_steps(3);+    for _ in 0..500 {+        if crate::adjust_ui::folder_luts(tmp.path().to_str().unwrap(), &h.ctx).len() == 3 {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(10));+        h.run_steps(1);+    }+    h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Lut").click();+    h.run_steps(2);+    let _ = h.get_by_label("listed.3dl");+    let _ = h.get_by_label("listed.look");+    h.get_by_label("invert.cube").click();+    h.run_steps(3);+    let f = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone();+    assert_eq!(f["file"], json!(path.to_str().unwrap()));+    assert_eq!(f["lut"], json!("none"));+    let submitted = params_of(&f);+    assert!(!submitted.as_object().unwrap().keys().any(|key| key.starts_with("__")));+    crate::dialogs::confirm(h.state_mut(), id).unwrap();+    let pixel = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 1, 1));+    let cyan = pixel(h.state());+    assert!(cyan[0] < 0.01 && cyan[1] > 0.99 && cyan[2] > 0.99, "selected nonidentity LUT applied: {cyan:?}");+    h.state_mut().run("edit.undo", json!({})).unwrap();+    let red = pixel(h.state());+    assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "Undo restores pixels: {red:?}");+    h.state_mut().run("edit.redo", json!({})).unwrap();+    assert_eq!(pixel(h.state()), cyan);+}diff --git a/crates/ui-egui/src/panels/adjust_ui.rs b/crates/ui-egui/src/panels/adjust_ui.rs--- a/crates/ui-egui/src/panels/adjust_ui.rs+++ b/crates/ui-egui/src/panels/adjust_ui.rs@@ -93,7 +93,7 @@ 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:";+pub(crate) const FOLDER_LUT: &str = "file:";  type FolderLuts = Vec<(String, String)>; @@ -109,9 +109,10 @@ struct FolderScan {  /// 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();+/// slow; until a scan finishes the dropdown shows the previous one. Image › Adjustments › Color+/// Lookup lists them too (#9754).+pub(crate) fn folder_luts(folder: &str, ctx: &egui::Context) -> FolderLuts {+    let folder = folder.to_string();     let now = ctx.input(|input| input.time);     let key = egui::Id::unique("color-lookup-folder");     let mut scan = ctx.data(|data| data.get_temp::<FolderScan>(key)).unwrap_or_default();@@ -178,7 +179,7 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer         return;     };     let builtins = photocraft_engine::adjust_cmds::LOOKS;-    let folder = folder_luts(app, ui.ctx());+    let folder = folder_luts(&app.session.prefs().file_handling.lut_folder, ui.ctx());     let current = current_lookup(name, lut.is_some(), &folder);     let current = app         .live_adjust@@ -315,13 +316,14 @@ mod lookup_picker_tests {         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);+        let lut_folder = app.session.prefs().file_handling.lut_folder.clone();+        let mut folder = folder_luts(&lut_folder, &ctx);         for _ in 0..500 {             if !folder.is_empty() {                 break;             }             std::thread::sleep(std::time::Duration::from_millis(10));-            folder = folder_luts(&app, &ctx);+            folder = folder_luts(&lut_folder, &ctx);         }         let key = format!("{FOLDER_LUT}{}", path.to_str().unwrap());         assert_eq!(folder, [("film.cube".to_string(), path.to_str().unwrap().to_string())]);