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
@@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.Luis-0508]+sync_github_name = true@@ -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();@@ -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);+}@@ -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())]);