diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a47bde9fff..5524388c71 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -782,6 +782,13 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) { + return Err(EngineError::Other("Merge Group needs a group".into())); + } + merge_group(s, id) + }), cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| { s.edit("Flatten Image", |doc, active| { let fmt = doc.pixel_format(); @@ -1427,6 +1434,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result { Ok(dup) } +/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they +/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the +/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask +/// and style are applied to the pixels. +fn merge_group(s: &mut Session, id: LayerId) -> Result { + s.edit("Merge Group", |doc, active| { + let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone(); + if !group.visible { + return Err(EngineError::Other("the group is hidden".into())); + } + let fmt = doc.pixel_format(); + let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true); + let mut solo = group.clone(); + solo.opacity = 1.0; + solo.blend = photocraft_doc::BlendMode::Normal; + solo.clipped = false; + let mut tmp = doc.clone(); + tmp.layers = vec![solo]; + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None))); + merged.id = group.id; + merged.opacity = group.opacity; + merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; + merged.clipped = group.clipped; + merged.locks = group.locks; + *doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged; + *active = Some(id); + Ok(()) + })?; + Ok(json!({ "layer": id.0 })) +} + #[cfg(test)] mod registry_tests { use super::*; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2d51dfdf0c..db96c3c1b4 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1217,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { fn merge_layers(s: &mut Session) -> Result { let sel = selected(s); if sel.len() < 2 { + // ⌘E on a lone group is Photoshop's Merge Group (#3832); on a layer, Merge Down. + if let Some(&id) = sel.first() + && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) + { + return s.execute("layer.mergeGroup", json!({ "layer": id.0 })); + } return s.execute("layer.mergeDown", json!({})); } let mid = s.edit("Merge Layers", |doc, active| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a35a19bb43..08e17bd39c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1469,3 +1469,41 @@ fn delete_layer_preserves_an_unaffected_active_layer_and_last_layer_guard() { assert_eq!(s.active().unwrap().active_layer, Some(other)); assert_eq!(s.active().unwrap().history.past_len(), before); } + +#[test] +fn merge_down_on_a_group_merges_the_group() { + // #3832: with a group selected, Merge Down is Photoshop's Merge Group (⌘E): the group's + // contents become one layer in its place, named after it, and the layer below is untouched. + let mut s = session_with_doc(); + let below = s.execute("layer.new.layer", json!({"name": "Below"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 10, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": a, "mode": "add"})).unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "Badge"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": group, "opacity": 0.5})).ok(); + let before = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)]; + let layers = s.active().unwrap().doc.layer_count(); + s.execute("layer.select", json!({"layer": group})).unwrap(); + s.execute("layer.mergeLayers", json!({})).unwrap(); + let st = s.active().unwrap(); + let merged = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + assert_eq!(merged.name, "Badge"); + assert!(matches!(merged.content, photocraft_doc::LayerContent::Raster(_)), "one pixel layer"); + assert!(st.doc.layer(photocraft_doc::LayerId(below)).is_some(), "the layer below stays"); + assert_eq!(st.doc.layer_count(), layers - 2, "group and its two layers became one"); + let after = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)]; + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{before:?} -> {after:?}"); + } + // Undo brings the group back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some()); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0b74504d51..e7de3409af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -398,6 +398,7 @@ Select Linked Layers Vybrat propojené vrstvy Merge Layers Sloučit vrstvy Merge Down Sloučit dolů + Merge Group Sloučit skupinu Merge Visible Sloučit viditelné Stamp Visible Otisknout viditelné Select Layer Above Vybrat vrstvu nad diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 604dcb43f0..ac313154db 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -394,6 +394,7 @@ Select Linked Layers Verknüpfte Ebenen auswählen Merge Layers Ebenen zusammenfügen Merge Down Nach unten zusammenfügen + Merge Group Gruppe zusammenfügen Merge Visible Sichtbare zusammenfügen Stamp Visible Sichtbare Ebenen stempeln Select Layer Above Ebene darüber auswählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17677731dd..e5be105597 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1838,6 +1838,7 @@ New Guide Layout… Νέα διάταξη οδηγών… New Guides From Shape Νέοι οδηγοί από σχήμα Merge Down Συγχώνευση με το υποκείμενο επίπεδο + Merge Group Συγχώνευση ομάδας Stamp Visible Συνένωση ορατών σε νέο επίπεδο Select Layer Above Επιλογή επάνω επιπέδου Select Layer Below Επιλογή κάτω επιπέδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7ad6d980e8..b986956544 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -391,6 +391,7 @@ Select Linked Layers Seleccionar capas vinculadas Merge Layers Combinar capas Merge Down Combinar hacia abajo + Merge Group Combinar grupo Merge Visible Combinar visibles Stamp Visible Sellar visibles Select Layer Above Seleccionar la capa de arriba diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7863936bfc..f426b28c56 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -387,6 +387,7 @@ Select Linked Layers Sélectionner les calques liés Merge Layers Fusionner les calques Merge Down Fusionner avec le calque inférieur + Merge Group Fusionner le groupe Merge Visible Fusionner les calques visibles Stamp Visible Tamponner les calques visibles Stamp Down Tamponner sur le calque inférieur diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 74bd4462f5..420600cf3f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -380,6 +380,7 @@ Select Linked Layers Pilih Layer Tertaut Merge Layers Gabungkan Layer Merge Down Gabung ke Bawah + Merge Group Gabungkan Grup Merge Visible Gabung yang Terlihat Stamp Visible Stamp yang Terlihat Stamp Down Stamp ke Bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3d2a0efb34..410ab8f609 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -390,6 +390,7 @@ Select Linked Layers Seleziona livelli collegati Merge Layers Unisci livelli Merge Down Unisci sotto + Merge Group Unisci gruppo Merge Visible Unisci visibili Stamp Visible Unisci visibili in nuovo Select Layer Above Seleziona livello sopra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2196441a31..7e5e65a639 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -391,6 +391,7 @@ Select Linked Layers リンクしたレイヤーを選択 Merge Layers レイヤーを結合 Merge Down 下のレイヤーと結合 + Merge Group グループを結合 Merge Visible 表示レイヤーを結合 Stamp Visible 表示レイヤーをスタンプ Select Layer Above 上のレイヤーを選択 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9476f0dab4..f48633c2cb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -375,6 +375,7 @@ Select Linked Layers 연결된 레이어 선택 Merge Layers 레이어 병합 Merge Down 아래 레이어와 병합 + Merge Group 그룹 병합 Merge Visible 보이는 레이어 병합 Stamp Visible 보이는 레이어의 병합 사본 만들기 Select Layer Above 위 레이어 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fda481dc97..3778a8a1de 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -383,6 +383,7 @@ Select Linked Layers Gekoppelde lagen selecteren Merge Layers Lagen samenvoegen Merge Down Omlaag samenvoegen + Merge Group Groep samenvoegen Merge Visible Zichtbare samenvoegen Stamp Visible Zichtbare stempelen Select Layer Above Laag erboven selecteren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ed63d43905..ce9387b623 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -389,6 +389,7 @@ Select Linked Layers Zaznacz połączone warstwy Merge Layers Scal warstwy Merge Down Scal w dół + Merge Group Scal grupę Merge Visible Scal widoczne Stamp Visible Odbij widoczne Select Layer Above Zaznacz warstwę powyżej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a4242dc8c8..92e9c238b1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -383,6 +383,7 @@ Select Linked Layers Selecionar camadas vinculadas Merge Layers Mesclar camadas Merge Down Mesclar para baixo + Merge Group Mesclar grupo Merge Visible Mesclar visíveis Stamp Visible Carimbar visíveis Stamp Down Carimbar para baixo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ead84742f9..7e1d12bbb1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1897,6 +1897,7 @@ Lock Layers… Заблокировать слои… Mean Среднее Merge Down Выполнить сведение + Merge Group Объединить группу Merge Layers Объединить слои Merge Shape Components Объединить компоненты фигуры Merge Visible Свести видимые diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7f59884e80..11a4caa9cf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1876,6 +1876,7 @@ Lock Layers… Заблокувати шари… Mean Середнє значення Merge Down Об'єднати з нижнім + Merge Group Об'єднати групу Merge Layers Об'єднати шари Merge Shape Components Об'єднати компоненти фігури Merge Visible Об'єднати видимі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c05376c1ee..ca091c14d8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -936,6 +936,7 @@ Disable Layer Mask Tắt mặt nạ lớp Release Clipping Mask Nhả mặt nạ cắt Merge Down Gộp xuống + Merge Group Gộp nhóm Quick Export As PNG Xuất nhanh dạng PNG Rename Layer… Đổi tên lớp… Frame from Layers… Frame từ các lớp… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 08f00a0fa5..d0309166ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -393,6 +393,7 @@ Select Linked Layers 选择链接的图层 Merge Layers 合并图层 Merge Down 向下合并 + Merge Group 合并组 Merge Visible 合并可见图层 Stamp Visible 盖印可见图层 Select Layer Above 选择上方图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f2bcab8538..8698dfc078 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -398,6 +398,7 @@ Select Linked Layers 選取連結的圖層 Merge Layers 合併圖層 Merge Down 向下合併圖層 + Merge Group 合併群組 Merge Visible 合併可見圖層 Stamp Visible 蓋印可見圖層 Select Layer Above 選取上方圖層 diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index 6c4c0ec99e..5be01c750a 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -111,6 +111,9 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { v.push(None); if multi { v.push(Some((tl!("Merge Layers"), "layer.mergeLayers"))); + } else if matches!(l.content, LayerContent::Group(_)) { + // Photoshop's Merge Group: a group merges itself, not into the layer below (#3832). + v.push(Some((tl!("Merge Group"), "layer.mergeGroup"))); } else { v.push(Some((tl!("Merge Down"), "layer.mergeDown"))); } @@ -654,6 +657,31 @@ mod tests { assert!(ids.contains(&"layer.mergeLayers")); } + /// #3832: a group's right-click menu and the Layer menu offer Photoshop's Merge Group, which + /// merges the group itself (not into the layer below), and it is enabled. + #[test] + fn a_group_offers_merge_group() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 10, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "G"})).unwrap(); + let st = s.active().unwrap(); + let g = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let e: Vec<(&str, &str)> = entries(&g, false, false).into_iter().flatten().collect(); + assert!(e.contains(&("Merge Group", "layer.mergeGroup")), "{e:?}"); + assert!(!e.iter().any(|x| x.0 == "Merge Down")); + let mut app = crate::PhotocraftApp::new(s, Default::default()); + app.sync_views(); + let items = crate::menus::menu_items(&app); + let item = items.iter().find(|i| i.id == "layer.mergeLayers").unwrap(); + assert_eq!(item.label, "Merge Group"); + assert!(item.enabled, "Merge Group runs on a group"); + // A plain layer keeps Merge Layers. + app.run("layer.new.layer", json!({})).unwrap(); + let items = crate::menus::menu_items(&app); + assert_eq!(items.iter().find(|i| i.id == "layer.mergeLayers").unwrap().label, "Merge Layers"); + } + #[test] fn add_mask_uses_the_selection() { assert_eq!(add_mask_command(false, false), "layer.layerMask.revealAll"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4e6c9ba188..18f1d6afb3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -966,6 +966,14 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { { item.label = crate::layer_menu_ui::mask_toggle_label(mask.enabled).into(); } + // Layer › Merge Layers reads Merge Group with just a group selected, as in Photoshop (#3832). + if let Some(st) = app.session.active() + && st.selected_layers().len() <= 1 + && st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Group(_))) + && let Some(item) = items.iter_mut().find(|i| i.id == "layer.mergeLayers") + { + item.label = "Merge Group".into(); + } // The File quick-export command uses the format selected in Export Preferences. // On the web it intentionally downloads PNG until the quick-export service supports // the other formats; the Layer quick-export command also always produces PNG.