From 7958dc63fb8143bdb208d96d7d641f392d65e321 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:42:58 +0200 Subject: [PATCH] Layers: Merge Group for a selected group (#3832) (#3888) * Layers: Merge Group for a selected group (#3832) With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the Layers panel's right-click menu offers Merge Group instead of Merge Down, as in Photoshop. The new layer.mergeGroup command flattens the group's visible contents into one pixel layer in its place, named after the group, keeping its opacity, blend mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran Merge Down and merged the whole group into the layer below it. * i18n: Vietnamese row for Merge Group Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/commands.rs | 38 +++++++++++++++++++++++++++ crates/engine/src/layer_multi_cmds.rs | 6 +++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/layer_menu_ui.rs | 28 ++++++++++++++++++++ crates/ui-egui/src/menus.rs | 8 ++++++ 22 files changed, 135 insertions(+) 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.