diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..b517767b9 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + " in {g} group{}": " in {g} Gruppe(n){:.0}", + " · kept, not applied: {}": " · behalten, nicht angewendet: {}", + " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", + "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", + "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", + "Delete {n} effect preset{}? This can't be undone.": "{n} Effektvorgabe(n) löschen?{:.0} Dies kann nicht rückgängig gemacht werden.", + "Effect: {name}": "Effekt: {name}", + "Exported {} effect preset{}": "Exportierte Effektvorgaben: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} Maskeneffekt(e) importiert{:.0} (Maskieren → Effekt)", + "Update “{name}” with Current Settings": "„{name}“ mit aktuellen Einstellungen aktualisieren", + "{n} preset{}": "{n} Vorgabe(n){:.0}" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..0bdf189c9 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + " in {g} group{}": " en {g} grupo(s){:.0}", + " · kept, not applied: {}": " · conservado, no aplicado: {}", + " — kept but not applied: {}": " — se conservan pero no se aplican: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a «{mask}» en las fotos seleccionadas", + "Delete the effect preset “{name}”?": "¿Eliminar el ajuste preestablecido de efecto «{name}»?", + "Delete these {n} effect presets?": "¿Eliminar estos {n} ajustes preestablecidos de efecto?", + "Delete {n} effect preset{}? This can't be undone.": "¿Eliminar {n} ajuste(s) preestablecido(s) de efecto?{:.0} No se puede deshacer.", + "Effect: {name}": "Efecto: {name}", + "Exported {} effect preset{}": "Ajustes de efecto exportados: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efecto(s) de máscara importado(s){:.0} (Máscaras → Efecto)", + "Update “{name}” with Current Settings": "Actualizar «{name}» con los ajustes actuales", + "{n} preset{}": "{n} ajuste(s){:.0}" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..1012a9eea 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + " in {g} group{}": " dans {g} groupe(s){:.0}", + " · kept, not applied: {}": " · conservé, non appliqué : {}", + " — kept but not applied: {}": " — conservés mais non appliqués : {}", + "Apply to “{mask}” on Selected Photos": "Appliquer à « {mask} » sur les photos sélectionnées", + "Delete the effect preset “{name}”?": "Supprimer le paramètre prédéfini d’effet « {name} » ?", + "Delete these {n} effect presets?": "Supprimer ces {n} paramètres prédéfinis d’effet ?", + "Delete {n} effect preset{}? This can't be undone.": "Supprimer {n} paramètre(s) prédéfini(s) d’effet ?{:.0} Cette action est irréversible.", + "Effect: {name}": "Effet : {name}", + "Exported {} effect preset{}": "Paramètres d’effet exportés : {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} effet(s) de masque importé(s){:.0} (Masquage → Effet)", + "Update “{name}” with Current Settings": "Mettre à jour « {name} » avec les réglages actuels", + "{n} preset{}": "{n} paramètre(s){:.0}" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..137c3775e 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + " in {g} group{}": "({g}グループ){:.0}", + " · kept, not applied: {}": " · 保持(適用なし):{}", + " — kept but not applied: {}": " — 保持(適用なし):{}", + "Apply to “{mask}” on Selected Photos": "選択した写真の「{mask}」に適用", + "Delete the effect preset “{name}”?": "効果プリセット「{name}」を削除しますか?", + "Delete these {n} effect presets?": "これらの効果プリセット{n}件を削除しますか?", + "Delete {n} effect preset{}? This can't be undone.": "効果プリセット{n}件を削除しますか?{:.0}元に戻せません。", + "Effect: {name}": "効果:{name}", + "Exported {} effect preset{}": "効果プリセットを{}件書き出しました{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "マスク効果を{e}件読み込みました{:.0}(マスク → 効果)", + "Update “{name}” with Current Settings": "「{name}」を現在の設定で更新", + "{n} preset{}": "{n}件のプリセット{:.0}" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..65e69e9a8 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + " in {g} group{}": " em {g} grupo(s){:.0}", + " · kept, not applied: {}": " · mantido, não aplicado: {}", + " — kept but not applied: {}": " — mantidos, mas não aplicados: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a “{mask}” nas fotos selecionadas", + "Delete the effect preset “{name}”?": "Excluir a predefinição de efeito “{name}”?", + "Delete these {n} effect presets?": "Excluir estas {n} predefinições de efeito?", + "Delete {n} effect preset{}? This can't be undone.": "Excluir {n} predefinição(ões) de efeito?{:.0} Não é possível desfazer.", + "Effect: {name}": "Efeito: {name}", + "Exported {} effect preset{}": "Predefinições de efeito exportadas: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efeito(s) de máscara importado(s){:.0} (Máscaras → Efeito)", + "Update “{name}” with Current Settings": "Atualizar “{name}” com as configurações atuais", + "{n} preset{}": "{n} predefinição(ões){:.0}" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..1facff2a1 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + " in {g} group{}": " в группах: {g}{:.0}", + " · kept, not applied: {}": " · сохранено, не применяется: {}", + " — kept but not applied: {}": " — сохранено, но не применяется: {}", + "Apply to “{mask}” on Selected Photos": "Применить к «{mask}» на выбранных фото", + "Delete the effect preset “{name}”?": "Удалить пресет эффекта «{name}»?", + "Delete these {n} effect presets?": "Удалить эти пресеты эффектов ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Удалить пресеты эффектов ({n})?{:.0} Это нельзя отменить.", + "Effect: {name}": "Эффект: {name}", + "Exported {} effect preset{}": "Экспортировано пресетов эффектов: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Импортировано эффектов маски: {e}{:.0} (Маски → Эффект)", + "Update “{name}” with Current Settings": "Обновить «{name}» текущими настройками", + "{n} preset{}": "Пресетов: {n}{:.0}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..5eb36d033 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + " in {g} group{}": " у групах: {g}{:.0}", + " · kept, not applied: {}": " · збережено, не застосовується: {}", + " — kept but not applied: {}": " — збережено, але не застосовується: {}", + "Apply to “{mask}” on Selected Photos": "Застосувати до «{mask}» на вибраних фото", + "Delete the effect preset “{name}”?": "Видалити пресет ефекту «{name}»?", + "Delete these {n} effect presets?": "Видалити ці пресети ефектів ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Видалити пресети ефектів ({n})?{:.0} Це не можна скасувати.", + "Effect: {name}": "Ефект: {name}", + "Exported {} effect preset{}": "Експортовано пресетів ефектів: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Імпортовано ефектів маски: {e}{:.0} (Маски → Ефект)", + "Update “{name}” with Current Settings": "Оновити «{name}» поточними налаштуваннями", + "{n} preset{}": "Пресетів: {n}{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..e57dd2a45 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + " in {g} group{}": "({g} 个分组){:.0}", + " · kept, not applied: {}": " · 已保留,未应用:{}", + " — kept but not applied: {}": " — 已保留但未应用:{}", + "Apply to “{mask}” on Selected Photos": "应用到所选照片的“{mask}”", + "Delete the effect preset “{name}”?": "删除效果预设“{name}”?", + "Delete these {n} effect presets?": "删除这 {n} 个效果预设?", + "Delete {n} effect preset{}? This can't be undone.": "删除 {n} 个效果预设?{:.0}此操作无法撤销。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已导出 {} 个效果预设{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已导入 {e} 个蒙版效果{:.0}(蒙版 → 效果)", + "Update “{name}” with Current Settings": "用当前设置更新“{name}”", + "{n} preset{}": "{n} 个预设{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..a30e6d595 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + " in {g} group{}": "({g} 個群組){:.0}", + " · kept, not applied: {}": " · 已保留,未套用:{}", + " — kept but not applied: {}": " — 已保留但未套用:{}", + "Apply to “{mask}” on Selected Photos": "套用到所選照片的「{mask}」", + "Delete the effect preset “{name}”?": "刪除效果預設集「{name}」?", + "Delete these {n} effect presets?": "刪除這 {n} 個效果預設集?", + "Delete {n} effect preset{}? This can't be undone.": "刪除 {n} 個效果預設集?{:.0}此動作無法復原。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已匯出 {} 個效果預設集{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已匯入 {e} 個遮色片效果{:.0}(遮色片 → 效果)", + "Update “{name}” with Current Settings": "以目前設定更新「{name}」", + "{n} preset{}": "{n} 個預設集{:.0}" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..1f68507ab 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -364,7 +364,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest if (r.is_err() && matches!( d, - crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } + crate::state::Dialog::Import { .. } + | crate::state::Dialog::SamModel { .. } + | crate::state::Dialog::SmartRules { .. } + | crate::state::Dialog::SaveEffect { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5df4b66bd..7ee7b7dbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,8 @@ mod tests_library_problem; #[cfg(test)] mod tests_lights_out; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81811fe0e..293527f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -184,6 +184,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), ("file.importKeywords", "Import Keywords…", None, "File"), ("file.exportKeywords", "Export Keywords…", None, "File"), // Edit panel ▸ Curve ▸ Point Curve dropdown @@ -1562,7 +1566,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option 0 { - parts.push(crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if effects == 1 { "" } else { "s" }, e = effects)); + parts.push(crate::panels::mask_effects::import_toast(v)); } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { @@ -1696,6 +1700,49 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(&ctx, e.clone()), + } + return Some(r); + } "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1821,6 +1868,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), + "dialog.saveMaskEffect" => app.session.active().is_some() && app.session.active_mask.is_some(), "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..c14d086d5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -255,6 +255,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::SaveEffect { .. } => "New Effect Preset", + Dialog::ManageEffects { .. } => "Manage Effect Presets", + Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", + Dialog::DeleteEffects { .. } => "Delete Effect Presets", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", Dialog::SynchronizeFolder { .. } => "Synchronize Folder", @@ -291,6 +295,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::AllMetadata { .. } => 620.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) .show(ctx, |ui| { @@ -1088,6 +1093,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { names } => { + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); @@ -1177,6 +1198,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { || matches!( dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + | Dialog::ManageEffects { .. } ); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); @@ -1208,7 +1230,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1282,6 +1305,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::KeywordTag { .. } | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } ) => { app.toast(ctx, e) @@ -1680,9 +1704,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs new file mode 100644 index 000000000..505dde129 --- /dev/null +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -0,0 +1,703 @@ +//! Mask effect presets in the UI: the Effect menu above a mask's sliders (grouped, searchable, +//! keyboard-driven, previews the effect on the photo while the pointer rests on it), the +//! New Effect Preset dialog (which sliders, Amount, brush settings) and the Manage Effect Presets +//! window (search, rename, update, duplicate, stars, groups, delete). Every change runs a +//! `mask.*Effect` command. + +use egui::{Sense, vec2}; +use lightcraft_develop::Mask; +use lightcraft_engine::cmd::mask_effects::{self as fx, FIELDS, MaskEffect}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color"). +pub fn field_label(k: &str) -> &'static str { + match k { + "color_hue" | "color_sat" => "Color", + _ => super::masking::LOCAL.iter().find(|s| s.id == k).map_or("", |s| s.label), + } +} + +/// The choices of the New Effect Preset checklist: each slider once (Color = hue + saturation). +fn checklist() -> Vec<&'static str> { + FIELDS.iter().copied().filter(|k| *k != "color_hue").collect() +} + +/// "Exposure +0.50 · Clarity −20" — what an effect sets, for the lists. +pub fn summary(e: &MaskEffect) -> String { + let fields = e.field_list(); + let mut parts: Vec = fx::changed_fields(&e.adjust) + .iter() + .filter(|k| fields.contains(&k.as_str()) && k.as_str() != "color_hue") + .map(|k| { + let v = fx::get(&e.adjust, k); + match k.as_str() { + "exposure" => format!("{} {:+.2}", crate::i18n::tr(field_label(k)), v), + "color_sat" => format!("{} {:.0}°/{:.0}", crate::i18n::tr("Color"), e.adjust.color_hue, v), + _ => format!("{} {:+.0}", crate::i18n::tr(field_label(k)), v), + } + }) + .collect(); + if e.with_amount { + parts.push(format!("{} {:.0}", crate::i18n::tr("Amount"), e.adjust.amount)); + } + if parts.is_empty() { + parts.push(crate::i18n::tr("No change").to_string()); + } + parts.join(" · ").replace('-', "−") +} + +/// An effect's name as shown: runs of spaces collapsed (Lightroom users pad names to line up +/// parts of them in its menu; the stored name keeps them). +pub fn display_name(name: &str) -> String { + name.split_whitespace().collect::>().join(" ") +} + +/// The effect the selected mask's tool last applied (what Update / Rename in the menu act on). +fn last_applied(app: &LightcraftApp, mask: &Mask) -> Option { + let tool = fx::mask_tool(mask)?; + let name = app.session.mask_tool_effects.get(&tool)?; + app.session.mask_effects.iter().find(|e| e.name.eq_ignore_ascii_case(name)).map(|e| e.name.clone()) +} + +/// Apply an effect to `mask` (and take the brush settings it carries). +pub fn apply(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, params: Value) { + let mut p = params; + p["name"] = json!(name); + match app.run("mask.applyEffect", p) { + Ok(v) => take_brush(app, &v["brush"]), + Err(e) => app.toast_error(ctx, e), + } +} + +fn take_brush(app: &mut LightcraftApp, b: &Value) { + let Some(b) = b.as_object() else { return }; + let f = |k: &str| b.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("size") { + app.ui.brush_size = (v as f32).clamp(0.002, 0.5); + } + if let Some(v) = f("feather") { + app.ui.brush_feather = (v as f32).clamp(0.0, 100.0); + } + if let Some(v) = f("flow") { + app.ui.brush_flow = (v as f32).clamp(1.0, 100.0); + } + if let Some(v) = b.get("auto_mask").and_then(Value::as_bool) { + app.ui.brush_auto_mask = v; + } +} + +/// The brush settings in use, as an effect carries them. +fn brush_now(app: &LightcraftApp) -> Value { + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "auto_mask": app.ui.brush_auto_mask}) +} + +/// Open the New Effect Preset dialog for the selected mask. +pub fn open_save(app: &mut LightcraftApp) { + let brush = app.ui.tool == "brush"; + app.ui.dialog = Some(Dialog::SaveEffect { + name: String::new(), + group: String::new(), + fields: checklist().iter().map(|k| k.to_string()).collect(), + amount: false, + brush, + }); +} + +/// Open the Manage Effect Presets window. +pub fn open_manage(app: &mut LightcraftApp) { + app.ui.dialog = + Some(Dialog::ManageEffects { search: String::new(), selected: Vec::new(), rename: None, confirm_delete: false, new_group: String::new() }); +} + +/// The menu's search text and keyboard highlight. +#[derive(Clone, Default)] +struct MenuState { + search: String, + highlight: Option, + focused: bool, +} + +fn state_id() -> egui::Id { + egui::Id::new("maskEffectMenuState") +} + +/// The effects the menu lists for `query`: (section heading, effect name, its row widget id). +fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_lowercase(); + let hit = |e: &MaskEffect| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q); + let mut rows = Vec::new(); + if q.is_empty() { + let mut favs: Vec<&MaskEffect> = effects.iter().filter(|e| e.favorite).collect(); + favs.sort_by(|a, b| fx::natural_cmp(&a.name, &b.name)); + rows.extend(favs.into_iter().map(|e| ("★ Favorites".to_string(), e.name.clone(), format!("maskEffectFav:{}", e.name)))); + } + for (group, ix) in fx::menu_order(effects) { + for e in ix.iter().filter_map(|i| effects.get(*i)).filter(|e| hit(e)) { + rows.push((group.clone(), e.name.clone(), format!("maskEffect:{}", e.name))); + } + } + rows +} + +/// The Effect menu above the mask's sliders. +pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = fx::matching_effect(&app.session, &mask.adjust); + let label = current.as_deref().map(display_name).unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + if r.clicked() { + ui.data_mut(|d| d.insert_temp(state_id(), MenuState::default())); + } + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| menu(app, ui, mask, current.as_deref())); + }); + }); +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option<&str>) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(260.0); + ui.set_max_width(320.0); + let mut st: MenuState = ui.data(|d| d.get_temp(state_id())).unwrap_or_default(); + let before = st.search.clone(); + let r = crate::text_field::TextField::singleline("maskEffectSearch", &mut st.search) + .hint(crate::i18n::tr("Search effects")) + .width(f32::INFINITY) + .show(ui); + if !st.focused { + r.response.request_focus(); + st.focused = true; + } + if st.search != before { + st.highlight = None; + } + let rows = menu_rows(&app.session.mask_effects, &st.search); + // ↑ ↓ move through the list, Return applies + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter) && !rows.is_empty(), + ) + }); + let moved = down || up; + if !rows.is_empty() { + let last = rows.len() - 1; + st.highlight = match (st.highlight, down, up) { + (None, true, _) => Some(0), + (None, _, true) => Some(last), + (Some(h), true, _) => Some((h + 1).min(last)), + (Some(h), _, true) => Some(h.saturating_sub(1)), + (h, _, _) => h.filter(|h| *h <= last), + }; + } + let mut chosen: Option = None; + if enter { + chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone()); + } + let mut hovered: Option = None; + if app.session.mask_effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect presets yet")).color(t.text_dim)); + } else if rows.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect matches")).color(t.text_dim)); + } + egui::ScrollArea::vertical().id_salt("maskEffectList").max_height(420.0).auto_shrink([false, true]).show(ui, |ui| { + let mut section: Option<&str> = None; + for (i, (group, name, widget)) in rows.iter().enumerate() { + if section != Some(group.as_str()) { + section = Some(group.as_str()); + if !group.is_empty() { + ui.add_space(4.0); + let heading = if group == "★ Favorites" { crate::i18n::tr("★ Favorites").to_string() } else { group.clone() }; + ui.label(egui::RichText::new(heading).font(t.semibold(11.5)).color(t.text_dim)); + } + } + let fav = app.session.mask_effects.iter().any(|e| e.favorite && &e.name == name); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(rect) { + register(ui.ctx(), widget.clone(), rect); + } + let lit = st.highlight == Some(i) || resp.hovered(); + if lit { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + if current == Some(name.as_str()) { + ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent); + } + let room = rect.width() - 22.0 - if fav { 16.0 } else { 0.0 }; + let g = crate::widgets::one_line(ui.painter(), &display_name(name), t.font(12.5), t.text, room); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); + if fav { + ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + } + if moved && st.highlight == Some(i) { + resp.scroll_to_me(None); + } + let resp = if elided { resp.on_hover_text(display_name(name)) } else { resp }; + if resp.hovered() || (st.highlight == Some(i) && moved) { + hovered = Some(name.clone()); + } + if resp.clicked() { + chosen = Some(name.clone()); + } + resp.context_menu(|ui| row_menu(app, ui, mask, name, fav)); + } + }); + // resting on an effect previews it on the photo (no history entry) + if let Some(name) = hovered.or_else(|| st.highlight.and_then(|h| rows.get(h)).map(|(_, n, _)| n.clone())) { + preview(app, mask, &name); + } + ui.data_mut(|d| d.insert_temp(state_id(), st)); + if let Some(name) = chosen { + apply(app, ui.ctx(), &name, json!({"id": mask.id})); + ui.close(); + return; + } + ui.separator(); + let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false)); + register(ui.ctx(), id.to_string(), r.rect); + r.clicked() + }; + if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) { + let zero: serde_json::Map = FIELDS.iter().map(|k| (k.to_string(), json!(0.0))).collect(); + let _ = app.run("mask.adjust", json!({"id": mask.id, "values": zero})); + if let Some(tool) = fx::mask_tool(mask) { + let _ = app.run("mask.toolEffect", json!({"tool": tool})); + } + ui.close(); + } + if item(ui, "maskEffectMenu:save", crate::i18n::tr("Save Current Settings as New Preset…").to_string(), true) { + open_save(app); + ui.close(); + } + if let Some(last) = last_applied(app, mask) { + let text = crate::i18n::tr_format!("Update “{name}” with Current Settings", name = last); + if item(ui, "maskEffectMenu:update", text, current != Some(last.as_str())) { + let _ = app.run("mask.updateEffect", json!({"name": last, "id": mask.id})); + ui.close(); + } + } + if item(ui, "maskEffectMenu:manage", crate::i18n::tr("Manage Presets…").to_string(), true) { + open_manage(app); + ui.close(); + } + if item(ui, "maskEffectMenu:import", crate::i18n::tr("Import Presets…").to_string(), app.services.pick_preset_files.is_some()) { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + let any = !app.session.mask_effects.is_empty(); + if item(ui, "maskEffectMenu:export", crate::i18n::tr("Export Presets…").to_string(), any && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({})); + ui.close(); + } +} + +/// The photo with `name` applied to `mask`, shown while the pointer rests on it. +fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) { + let Some(id) = app.session.active() else { return }; + let Some(e) = app.session.mask_effects.iter().find(|e| e.name == name) else { return }; + let Some(d) = app.session.develop_of(id) else { return }; + let mut d = (*d).clone(); + let Some(m) = d.masks.iter_mut().find(|m| m.id == mask.id) else { return }; + m.adjust = e.apply(&m.adjust); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Effect: {name}", name = display_name(name)), settings: d }); +} + +/// The right-click menu of an effect in the Effect menu. +fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) { + let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| { + let r = ui.button(crate::i18n::tr(text)); + register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect); + if r.clicked() { + if let Err(e) = app.run(cmd, p) { + app.toast_error(ui.ctx(), e); + } + ui.close(); + } + }; + let masks = app.session.active().and_then(|id| app.session.develop_of(id)).map_or(0, |d| d.masks.len()); + if ui.add_enabled(masks > 1, egui::Button::new(crate::i18n::tr("Apply to All Masks"))).clicked() { + apply(app, ui.ctx(), name, json!({"all": true})); + ui.close(); + } + if app.session.selection.ids.len() > 1 { + let text = crate::i18n::tr_format!("Apply to “{mask}” on Selected Photos", mask = mask.name); + if ui.button(text).on_hover_text(crate::i18n::tr("Also the masks of the same name on the other selected photos")).clicked() { + apply(app, ui.ctx(), name, json!({"id": mask.id, "photos": "selected"})); + ui.close(); + } + } + ui.separator(); + run(app, ui, "Update with Current Settings", "mask.updateEffect", json!({"name": name, "id": mask.id})); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Effect Preset", "Preset name", name, "mask.renameEffect", json!({"name": name}), "to"); + ui.close(); + } + run(app, ui, "Duplicate", "mask.duplicateEffect", json!({"name": name})); + run(app, ui, if fav { "Remove from Favorites" } else { "Add to Favorites" }, "mask.favoriteEffect", json!({"name": name, "favorite": !fav})); + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| group_menu(app, ui, &[name.to_string()])); + ui.separator(); + if ui.button(crate::i18n::tr("Delete…")).clicked() { + app.ui.dialog = Some(Dialog::DeleteEffects { names: vec![name.to_string()] }); + ui.close(); + } +} + +/// The groups to move effects `names` to, None, and New Group…. +fn group_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, names: &[String]) { + let groups: Vec = fx::menu_order(&app.session.mask_effects).into_iter().map(|(g, _)| g).filter(|g| !g.is_empty()).collect(); + for g in groups.iter().map(String::as_str).chain(std::iter::once("")) { + let label = if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.to_string() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": g})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("New Group…")).clicked() { + let names = json!({"names": names}); + crate::panels::dialogs::prompt(app, "New Effect Group", "Group name", "", "mask.setEffectGroup", names, "group"); + ui.close(); + } +} + +/// The New Effect Preset dialog's body. +pub fn save_body( + app: &LightcraftApp, + ui: &mut egui::Ui, + name: &mut String, + group: &mut String, + fields: &mut Vec, + amount: &mut bool, + brush: &mut bool, +) -> bool { + let t = Tokens::get(ui.ctx()); + let r = crate::text_field::TextField::singleline("field:effectName", name).hint(crate::i18n::tr("Preset name")).width(f32::INFINITY).show(ui); + if !r.editing && name.is_empty() && ui.memory(|m| m.focused().is_none()) { + r.response.request_focus(); + } + let enter = r.ending == Some(crate::text_field::Ending::Return); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectGroup", group) + .hint(crate::i18n::tr("Group (optional)")) + .width(ui.available_width() - 30.0) + .show(ui); + let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group")); + register(ui.ctx(), "button:effectGroupPick", pick.rect); + egui::Popup::menu(&pick).show(|ui| { + for (g, _) in fx::menu_order(&app.session.mask_effects) { + if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() { + *group = g; + } + } + }); + }); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + let adjust = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .and_then(|d| app.session.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))); + let changed = adjust.as_ref().map(fx::changed_fields).unwrap_or_default(); + let list = checklist(); + ui.columns(2, |cols| { + for (i, k) in list.iter().enumerate() { + let mut on = fields.iter().any(|f| f == k); + let text = egui::RichText::new(crate::i18n::tr(field_label(k))); + // the sliders this mask changes stand out; the others are dimmed + let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) }; + let r = cols[i % 2].checkbox(&mut on, text); + register(&r.ctx, format!("effectInclude:{k}"), r.rect); + if r.changed() { + fields.retain(|f| f != k); + if on { + fields.push(k.to_string()); + } + } + } + }); + ui.horizontal(|ui| { + for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] { + let b = ui.small_button(crate::i18n::tr(text)); + register(ui.ctx(), format!("effectInclude:{id}"), b.rect); + if b.clicked() { + *fields = match id { + "all" => list.iter().map(|k| k.to_string()).collect(), + "none" => Vec::new(), + _ => changed.iter().filter(|k| k.as_str() != "color_hue").cloned().collect(), + }; + } + } + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.")) + .color(t.text_dim) + .size(11.0), + ); + let r = ui.checkbox(amount, crate::i18n::tr("Include Amount")); + register(ui.ctx(), "check:effectAmount", r.rect); + let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow")); + register(ui.ctx(), "check:effectBrush", r.rect); + enter +} + +/// Save from the New Effect Preset dialog. +pub fn save_confirm(app: &mut LightcraftApp, name: &str, group: &str, fields: &[String], amount: bool, brush: bool) -> Result { + if name.trim().is_empty() { + return Err(crate::i18n::tr("Name the preset").to_string()); + } + if fields.is_empty() { + return Err(crate::i18n::tr("Choose at least one setting to include").to_string()); + } + let mut keep: Vec = fields.to_vec(); + if keep.iter().any(|f| f == "color_sat") { + keep.push("color_hue".into()); + } + let all = checklist().iter().all(|k| keep.iter().any(|f| f == k)); + let mut p = json!({"name": name.trim(), "group": group.trim(), "amount": amount}); + p["fields"] = if all { json!("all") } else { json!(keep) }; + if brush { + p["brush"] = brush_now(app); + } + app.run("mask.saveEffect", p) +} + +/// The Manage Effect Presets window's body. +pub fn manage_body( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + search: &mut String, + selected: &mut Vec, + rename: &mut Option<(String, String)>, + confirm_delete: &mut bool, + new_group: &mut String, +) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(500.0); + // effects that went away (deleted, renamed) leave the selection + selected.retain(|n| app.session.mask_effects.iter().any(|e| &e.name == n)); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectSearch", search) + .hint(crate::i18n::tr("Search effects")) + .width(ui.available_width() - 120.0) + .show(ui); + let n = app.session.mask_effects.len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} preset{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + }); + let q = search.trim().to_lowercase(); + let effects = app.session.mask_effects.clone(); + let mut toggle_star: Option<(String, bool)> = None; + let mut commit_rename: Option<(String, String)> = None; + egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + for (group, ix) in fx::menu_order(&effects) { + let list: Vec<&MaskEffect> = ix + .iter() + .filter_map(|i| effects.get(*i)) + .filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q)) + .collect(); + if list.is_empty() { + continue; + } + ui.add_space(4.0); + let heading = if group.is_empty() { crate::i18n::tr("Ungrouped").to_string() } else { group.clone() }; + let h = ui.add( + egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label)) + .sense(Sense::click()), + ); + register(ui.ctx(), format!("effectGroup:{group}"), h.rect); + if h.clicked() { + // a click on the heading selects the group + *selected = list.iter().map(|e| e.name.clone()).collect(); + } + for e in list { + let sel = selected.contains(&e.name); + ui.horizontal(|ui| { + let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); + if ui.is_rect_visible(star.rect) { + register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); + } + if star.clicked() { + toggle_star = Some((e.name.clone(), !e.favorite)); + } + if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) { + let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.cancelled() { + *rename = None; + } else if r.committed() { + commit_rename = Some((old.clone(), text.trim().to_string())); + } + return; + } + let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click()); + if ui.is_rect_visible(rect) { + register(ui.ctx(), format!("effectRow:{}", e.name), rect); + } + if sel { + ui.painter().rect_filled(rect, 3.0, t.tool_active); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(4.0, -g.size().y / 2.0), g, t.text); + let r = if elided { r.on_hover_text(display_name(&e.name)) } else { r }; + if r.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + if add { + if sel { + selected.retain(|n| n != &e.name); + } else { + selected.push(e.name.clone()); + } + } else { + *selected = vec![e.name.clone()]; + } + } + if r.double_clicked() { + *rename = Some((e.name.clone(), e.name.clone())); + } + let mut info = summary(e); + if !e.extra.is_empty() { + info += &crate::i18n::tr_format!(" · kept, not applied: {}", e.extra.keys().cloned().collect::>().join(", ")); + } + ui.add(egui::Label::new(egui::RichText::new(info).color(t.text_dim).size(11.0)).truncate()); + }); + } + } + }); + if let Some((name, on)) = toggle_star { + let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on})); + } + if let Some((old, to)) = commit_rename { + *rename = None; + if !to.is_empty() && to != old { + match app.run("mask.renameEffect", json!({"name": old, "to": to})) { + Ok(_) => *selected = vec![to], + Err(e) => app.toast_error(ui.ctx(), e), + } + } + } + ui.separator(); + let n = selected.len(); + let one = (n == 1).then(|| selected.first().cloned()).flatten(); + let mask = app.session.active_mask.filter(|_| app.session.active().is_some()); + if *confirm_delete && n > 0 { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Delete {n} effect preset{}? This can't be undone.", if n == 1 { "" } else { "s" }, n = n)) + .color(t.text), + ); + ui.horizontal(|ui| { + let d = ui.button(crate::i18n::tr("Delete")); + register(ui.ctx(), "button:effectDeleteConfirm", d.rect); + if d.clicked() { + let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()})); + selected.clear(); + *confirm_delete = false; + } + let c = ui.button(crate::i18n::tr("Cancel")); + register(ui.ctx(), "button:effectDeleteCancel", c.rect); + if c.clicked() { + *confirm_delete = false; + } + }); + return; + } + ui.horizontal_wrapped(|ui| { + let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text))); + register(ui.ctx(), format!("button:effect{id}"), r.rect); + r.clicked() + }; + if button(ui, "Apply", "Apply", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + apply(app, ui.ctx(), name, json!({"id": mid})); + } + if button(ui, "Update", "Update with Mask", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + let _ = app.run("mask.updateEffect", json!({"name": name, "id": mid})); + } + if button(ui, "Rename", "Rename", one.is_some()) + && let Some(name) = &one + { + *rename = Some((name.clone(), name.clone())); + } + if button(ui, "Duplicate", "Duplicate", one.is_some()) + && let Some(name) = &one + && let Ok(v) = app.run("mask.duplicateEffect", json!({"name": name})) + && let Some(copy) = v["name"].as_str() + { + *selected = vec![copy.to_string()]; + } + if button(ui, "Favorite", "★ Favorite", n > 0) { + let all_fav = selected.iter().all(|s| effects.iter().any(|e| &e.name == s && e.favorite)); + let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav})); + } + let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…"))); + register(ui.ctx(), "button:effectGroup", g.rect); + egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let names = selected.clone(); + for (grp, _) in fx::menu_order(&app.session.mask_effects) { + let label = if grp.is_empty() { crate::i18n::tr("None").to_string() } else { grp.clone() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": grp})); + ui.close(); + } + } + ui.separator(); + let r = crate::text_field::TextField::singleline("field:effectNewGroup", new_group) + .hint(crate::i18n::tr("New group — Return")) + .width(180.0) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) && !new_group.trim().is_empty() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": new_group.trim()})); + new_group.clear(); + ui.close(); + } + }); + if button(ui, "Export", "Export…", n > 0 && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({"names": selected.clone()})); + } + if button(ui, "Delete", "Delete…", n > 0) { + *confirm_delete = true; + } + }); + if mask.is_none() { + ui.label(egui::RichText::new(crate::i18n::tr("Select a mask to apply or update presets.")).color(t.text_dim).size(11.0)); + } +} + +/// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, +/// in how many groups, and the settings they carry that LightCraft can't apply. +pub fn import_toast(v: &Value) -> String { + let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); + let n = rows.len(); + let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); + let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); + groups.sort_unstable(); + groups.dedup(); + if !groups.is_empty() { + msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + } + let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + } + msg +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 5e3f8dc6d..1cfe5ab27 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -16,56 +16,6 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track } } -/// The Effect menu above the mask's sliders: apply, save, import and delete effect presets. -fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, adjust: &LocalAdjustments) { - use lightcraft_engine::cmd::mask_effects::matching_effect; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); - let current = matching_effect(&app.session, adjust); - let label = current.clone().unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); - let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); - egui::Popup::menu(&r).show(|ui| { - ui.set_min_width(220.0); - let effects: Vec = app.session.mask_effects.iter().map(|e| e.name.clone()).collect(); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for name in &effects { - let r = ui.selectable_label(current.as_deref() == Some(name.as_str()), name); - register(ui.ctx(), format!("maskEffect:{name}"), r.rect); - if r.clicked() { - let _ = app.run("mask.applyEffect", json!({"name": name})); - ui.close(); - } - r.context_menu(|ui| { - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("mask.deleteEffect", json!({"name": name})); - ui.close(); - } - }); - } - }); - if !effects.is_empty() { - ui.separator(); - } - let r = ui.button(crate::i18n::tr("Save Current Settings as New Preset…")); - register(ui.ctx(), "maskEffectMenu:save", r.rect); - if r.clicked() { - crate::panels::dialogs::prompt(app, "Save Effect Preset", "Preset name", "", "mask.saveEffect", json!({}), "name"); - ui.close(); - } - if ui.add_enabled(app.services.pick_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { - let _ = app.run("file.importPresets", json!({})); - ui.close(); - } - if !effects.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); - } - }); - }); - }); -} - /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), @@ -418,7 +368,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); - effect_row(app, ui, &m.adjust); + super::mask_effects::effect_row(app, ui, &m); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..1fd55c9a6 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -17,6 +17,7 @@ pub mod keyword_list; pub mod keywording; pub mod left; pub mod library_problem; +pub mod mask_effects; pub mod masking; pub mod notices; pub mod people; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..16ddddbf7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -963,6 +963,28 @@ pub enum Dialog { folder: String, catalog: Option, }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec, + }, /// Confirm moving photos to Recently Deleted. ConfirmDelete { count: usize, diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests_mask_effects.rs new file mode 100644 index 000000000..53377c7cd --- /dev/null +++ b/crates/ui-egui/src/tests_mask_effects.rs @@ -0,0 +1,215 @@ +//! Headless tests of mask effect presets: the Effect menu (groups, search, keys, hover preview, +//! right-click actions), the New Effect Preset dialog and the Manage Effect Presets window. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "panel.masking", json!({})); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn req(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{method}: {r}"); +} + +fn click(h: &mut Headless, id: &str) { + req(h, "ui.clickWidget", json!({"id": id})); +} + +fn mask(h: &Headless) -> lightcraft_develop::Mask { + let id = h.app.session.active().expect("active photo"); + let d = h.app.session.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| Some(m.id) == h.app.session.active_mask).cloned().expect("a selected mask") +} + +/// Three effects in two groups, one starred; a radial mask selected. +fn setup() -> Headless { + let mut h = detail(); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + for (name, values, group) in [ + ("01 Dodge", json!({"exposure": 0.5}), ""), + ("12 Sky", json!({"highlights": -60}), "Detail"), + ("21 Skin", json!({"clarity": -40}), "Retouch"), + ] { + exec(&mut h, "mask.adjust", json!({"values": values})); + exec(&mut h, "mask.saveEffect", json!({"name": name, "group": group})); + let zero: serde_json::Map = + lightcraft_engine::cmd::mask_effects::FIELDS.iter().map(|k| (k.to_string(), json!(0))).collect(); + exec(&mut h, "mask.adjust", json!({"values": zero})); + } + exec(&mut h, "mask.favoriteEffect", json!({"name": "21 Skin"})); + h.step(); + h +} + +#[test] +fn effect_menu_groups_searches_and_applies_with_keys() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + for id in ["maskEffect:01 Dodge", "maskEffect:12 Sky", "maskEffect:21 Skin", "maskEffectFav:21 Skin", "maskEffectSearch", "maskEffectMenu:manage"] + { + assert!(ids.iter().any(|x| x == id), "{id} in {ids:?}"); + } + // the pointer resting on an effect previews it on the photo + req(&mut h, "ui.hoverWidget", json!({"id": "maskEffect:12 Sky"})); + h.step(); + let preview = h.app.hover_preview.as_ref().expect("a hover preview"); + assert_eq!(preview.settings.masks.last().map(|m| m.adjust.highlights), Some(-60.0)); + assert_eq!(mask(&h).adjust.highlights, 0.0, "only previewed"); + // typing filters; ↓ and Return apply the first match + req(&mut h, "ui.text", json!({"text": "skin"})); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + assert!(!ids.iter().any(|x| x == "maskEffect:12 Sky"), "filtered out: {ids:?}"); + req(&mut h, "ui.key", json!({"key": "ArrowDown"})); + h.step(); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert_eq!(mask(&h).adjust.clarity, -40.0); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "21 Skin"); + // a new radial mask now starts with it (the menu remembers per tool) + exec(&mut h, "mask.add", json!({"kind": "radial"})); + assert_eq!(mask(&h).adjust.clarity, -40.0); +} + +#[test] +fn effect_menu_right_click_and_delete_asks_first() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + // the row's actions are commands; delete goes through a confirmation + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3, "nothing deleted before confirming"); + click(&mut h, "button:dialogCancel"); + assert_eq!(h.app.session.mask_effects.len(), 3); + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + req(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(h.app.session.mask_effects.len(), 2); + // Reset Sliders zeroes the mask and forgets the tool's effect + exec(&mut h, "mask.applyEffect", json!({"name": "12 Sky"})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:reset"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, 0.0); + assert!(h.app.session.mask_tool_effects.is_empty()); +} + +#[test] +fn save_dialog_picks_sliders_amount_and_brush() { + let mut h = setup(); + exec(&mut h, "mask.adjust", json!({"values": {"shadows": 30, "texture": 15, "amount": 70}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:save"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SaveEffect { .. })), "{:?}", h.app.ui.dialog); + // nothing named: an error, the dialog stays + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + click(&mut h, "effectInclude:changed"); + h.step(); + click(&mut h, "check:effectAmount"); + h.step(); + req(&mut h, "ui.clickWidget", json!({"id": "field:effectName"})); + req(&mut h, "ui.text", json!({"text": "Lift"})); + h.step(); + let Some(Dialog::SaveEffect { fields, amount, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((fields.clone(), *amount), (vec!["shadows".to_string(), "texture".into()], true)); + req(&mut h, "ui.dialog.confirm", json!({})); + let e = h.app.session.mask_effects.iter().find(|e| e.name == "Lift").expect("saved"); + assert_eq!((e.fields.clone(), e.with_amount, e.adjust.amount), (vec!["shadows".to_string(), "texture".into()], true, 70.0)); + assert!(e.brush.is_none(), "not the brush tool"); +} + +#[test] +fn manage_window_renames_groups_stars_duplicates_and_deletes() { + let mut h = setup(); + exec(&mut h, "dialog.manageMaskEffects", json!({})); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. }))); + // select and rename inline + click(&mut h, "effectRow:12 Sky"); + h.step(); + click(&mut h, "button:effectRename"); + h.step(); + req(&mut h, "ui.key", json!({"key": "A", "modifiers": {"command": true}})); + req(&mut h, "ui.text", json!({"text": "12 Sky Darken"})); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert!( + h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken"), + "{:?}", + h.app.session.mask_effects.iter().map(|e| &e.name).collect::>() + ); + // duplicate selects the copy; star it; apply it to the mask + click(&mut h, "button:effectDuplicate"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["12 Sky Darken copy".to_string()]); + click(&mut h, "effectStar:12 Sky Darken copy"); + h.step(); + assert!(h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken copy" && e.favorite)); + click(&mut h, "button:effectApply"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, -60.0); + // search narrows the list; a group heading selects the group + req(&mut h, "ui.clickWidget", json!({"id": "field:effectSearch"})); + req(&mut h, "ui.text", json!({"text": "retouch"})); + h.step(); + click(&mut h, "effectGroup:Retouch"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["21 Skin".to_string()]); + // delete asks first, inside the window + click(&mut h, "button:effectDelete"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 4); + click(&mut h, "button:effectDeleteConfirm"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })), "stays open"); +} + +#[test] +fn imported_effects_toast_reports_groups_and_what_was_kept() { + let v = json!({"effects": [ + {"name": "a", "group": "Detail", "unmapped": ["Grain"]}, + {"name": "b", "group": "Retouch", "unmapped": ["Point Color", "Grain"]}, + {"name": "c", "group": "", "unmapped": []}, + ]}); + let msg = crate::panels::mask_effects::import_toast(&v); + assert!(msg.contains("3 mask effects") && msg.contains("2 groups") && msg.contains("Grain, Point Color"), "{msg}"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index eb2b18a22..179d0e4a5 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -137,9 +137,6 @@ fn new_brush_starts_a_separate_mask_in_paint_mode() { fn mask_adjustments_hide_overlay_without_disabling_the_mask() { use lightcraft_pipeline::Overlay; let mut h = detail("panel.masking"); - // tall enough for the Exposure slider below the Effect menu to be on screen - let r = h.request("ui.resize", json!({"width": 1200, "height": 1000}), T); - assert_eq!(r["ok"], true, "{r}"); exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); assert!(h.app.ui.mask_overlay); @@ -1426,16 +1423,13 @@ fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { exec(&mut h, "mask.add", json!({"kind": "radial"})); exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); h.step(); - // save the mask's sliders from the menu (name prompt) + // save the mask's sliders from the menu (the New Effect Preset dialog) click(&mut h, "dropdown:maskEffect"); click(&mut h, "maskEffectMenu:save"); - let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + let mut dlg = h.app.ui.dialog.take().expect("the save dialog opens"); match &mut dlg { - crate::state::Dialog::TextPrompt { command, value, .. } => { - assert_eq!(command, "mask.saveEffect"); - *value = "Lift".into(); - } - _ => panic!("a name prompt opens"), + crate::state::Dialog::SaveEffect { name, .. } => *name = "Lift".into(), + d => panic!("the save dialog opens: {d:?}"), } let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); assert!(r.is_ok(), "{r:?}"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1c392e9b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; LR-MASK-EFFECTS: local adjustment presets with Lightroom Classic import) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 409 | 51 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.7%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 172. ## Top gaps and shortcut notes @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | @@ -302,7 +302,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | +| LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | @@ -787,6 +788,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-MASK-EFFECTS (new, ✅): local adjustment (effect / brush) presets: Effect menu, New Effect Preset dialog, Manage window, groups and favorites; imports Lightroom Classic local `.lrtemplate`, Camera Raw local `.xmp`, `.lceffect` and `.zip`. LR-IMP-PROFILES and LR-MASK-SLIDERS notes updated | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199..066afe2ea 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -192,13 +192,45 @@ Applying a preset adds its masks to the photo's own (masks the photo already has slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't carry over are listed in `preset.import`'s `unmapped` as `Mask: `. +## Local adjustment presets (mask effects) + +`mask.importEffects {path | paths, group?, dryRun?}` (and `preset.import`, File ▸ Import Profiles & Presets…, drag & drop) +reads these into the Masking panel's Effect menu (`crates/engine/src/cmd/mask_effect_files.rs`): + +| File | How it is read | +|---|---| +| Lightroom Classic `.lrtemplate` (`%APPDATA%\Adobe\Lightroom\Local Adjustment Presets`, `~/Library/Application Support/Adobe/Lightroom/Local Adjustment Presets`) | a Lua table with `type = "LocalizedAdjustmentPreset"`; `title` (else `internalName`, else the file name) → name; `value` holds the `Local*` field names in lower camel case: `exposure2012` → `LocalExposure2012`, mapped as in the table above (`exposure`, `contrast`, `clarity` are the older process version's; the 2012 fields win); `luminanceNoise` → Noise, `toningHue` / `toningSaturation` → Color | +| Camera Raw local-correction `.xmp` (`CameraRaw/LocalCorrections`) | top-level `crs:Local*` properties (or, in a file with `crs:PresetType="Local"` without them, its first correction's); `crs:Name` → name, `crs:Group` → group. Develop presets (no `crs:Local*`) stay develop presets | +| `.lceffect` | ours: `{"format": "lightcraft.maskEffects", "version": 2, "effects": [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}]}` (a bare array or one effect also reads); `mask.exportEffects {path, names?, group?}` writes it | +| `.zip` | the presets above inside it; its folders give groups | + +Groups: a file's folder (relative to the imported folder; names that only say "local adjustment presets" don't count) +names its group, unless the file names one. Lightroom users mark sections of the flat preset menu with **divider +presets**: presets that set nothing, named like `10 ----- DETAIL ENHANCERS ----- 10` (a run of three or more `-`, `=`, +`_`, `*`, `#`, `~`, `—`, `.`). These become groups instead of effects: a preset whose name starts with a number goes to +the divider with the largest number not above it (`12 Sky` under `10 ----- DETAIL ENHANCERS ----- 10` → *Detail +Enhancers*); presets without a number keep their folder's group. An earlier import's divider effects are removed. +Same-named effects are replaced (a star is kept). + +Settings with no local slider here are listed per preset in `unmapped` (and the toast) and **kept** with the effect in +`extra` (an `.lceffect` export carries them), but not applied: `grain` (Grain; LightCraft has no local grain), +`pointColors` (Point Color: `SrcHue/Sat/Lum`, `HueShift`, `SatScale`, `LumScale`, `RangeAmount`, `Hue/Sat/LumRange` +`{LowerNone, LowerFull, UpperFull, UpperNone}`; there is global Point Color only), `toningLuminance`. Zero values aren't +listed (`refineSaturation = 0` in most files). Lightroom keeps brush size, feather, flow and density per brush, not in +presets, so there is nothing to import for them; an effect saved here can carry them (`brush`). + +Applying (`mask.applyEffect {name, id? | ids? | all?, photos?: "selected"}`) sets the sliders the effect carries — +all of them for an imported preset (unnamed ones to 0, as Lightroom does), or only the ones chosen when it was saved +(`mask.saveEffect {fields: "changed" | [..]}`) — and Amount only if it carries it. The effect is remembered for the +mask's kind: the next brush / linear / radial / … mask starts with it (`mask.toolEffect`). + ## Preset files | | | |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`).