mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
LR-MASK-EFFECTS: Effect menu, New Effect Preset dialog and Manage window
The Effect menu above the mask sliders is grouped (★ Favorites first, then groups in menu order), searchable, scrolls, works with ↑ ↓ Return, marks the matching effect (else Custom) and previews an effect on the photo while the pointer rests on it. Right-click: apply to all masks or to the same-named mask on the selected photos, update, rename, duplicate, star, move to group, delete (asks first). Reset Sliders; Update "<last effect>" with Current Settings. New Effect Preset dialog: name, group, which sliders (All / None / Changed Only), Amount, brush size/feather/flow. Manage Effect Presets window: search, inline rename, update with mask, duplicate, stars, groups, export, delete with confirmation. Names padded with runs of spaces (as LrC users do) are shown collapsed. Import toasts name the groups and the settings kept but not applied. New messages translated in every catalog. Docs: parity row and xmp-interop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0f40a2d2f5
commit
bb925f6d2e
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Re
|
||||
// Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast
|
||||
let effects = v["effects"].as_array().map_or(0, Vec::len);
|
||||
if effects > 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<Re
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"dialog.saveMaskEffect" => {
|
||||
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,
|
||||
|
||||
@@ -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<serde_jso
|
||||
Dialog::LightroomCatalog { catalog, .. } => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = 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::<Vec<_>>().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<String> {
|
||||
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<usize>,
|
||||
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<String> = None;
|
||||
if enter {
|
||||
chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone());
|
||||
}
|
||||
let mut hovered: Option<String> = 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<String, Value> = 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<String> = 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<String>,
|
||||
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<Value, String> {
|
||||
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<String> = 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<String>,
|
||||
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::<Vec<_>>().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
|
||||
}
|
||||
@@ -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<String> = 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -963,6 +963,28 @@ pub enum Dialog {
|
||||
folder: String,
|
||||
catalog: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
rename: Option<(String, String)>,
|
||||
confirm_delete: bool,
|
||||
new_group: String,
|
||||
},
|
||||
/// Confirm deleting mask effect presets.
|
||||
DeleteEffects {
|
||||
names: Vec<String>,
|
||||
},
|
||||
/// Confirm moving photos to Recently Deleted.
|
||||
ConfirmDelete {
|
||||
count: usize,
|
||||
|
||||
@@ -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<String, serde_json::Value> =
|
||||
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<String> =
|
||||
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<String> =
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
// 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}");
|
||||
}
|
||||
@@ -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:?}");
|
||||
|
||||
File diff suppressed because one or more lines are too long
+33
-1
@@ -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: <kind>`.
|
||||
|
||||
## 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`).
|
||||
|
||||
Reference in New Issue
Block a user