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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 09:42:58 -04:00
1 parent 4010630c98
commit 7958dc63fb
22 files changed
+135

No files matched your search

+38
View File
@@ -782,6 +782,13 @@ fn build() -> Vec<CommandSpec> {
})?;
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<Layer> {
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<Value> {
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::*;
+6
View File
@@ -1217,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
fn merge_layers(s: &mut Session) -> Result<Value> {
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| {
+38
View File
@@ -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());
}
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1838,6 +1838,7 @@
New Guide Layout… Νέα διάταξη οδηγών…
New Guides From Shape Νέοι οδηγοί από σχήμα
Merge Down Συγχώνευση με το υποκείμενο επίπεδο
Merge Group Συγχώνευση ομάδας
Stamp Visible Συνένωση ορατών σε νέο επίπεδο
Select Layer Above Επιλογή επάνω επιπέδου
Select Layer Below Επιλογή κάτω επιπέδου
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
390
391
392
393
394
395
396
+1
View File
@@ -391,6 +391,7 @@
Select Linked Layers リンクしたレイヤーを選択
Merge Layers レイヤーを結合
Merge Down 下のレイヤーと結合
Merge Group グループを結合
Merge Visible 表示レイヤーを結合
Stamp Visible 表示レイヤーをスタンプ
Select Layer Above 上のレイヤーを選択
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -375,6 +375,7 @@
Select Linked Layers 연결된 레이어 선택
Merge Layers 레이어 병합
Merge Down 아래 레이어와 병합
Merge Group 그룹 병합
Merge Visible 보이는 레이어 병합
Stamp Visible 보이는 레이어의 병합 사본 만들기
Select Layer Above 위 레이어 선택
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
375
376
377
378
379
380
381
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1897,6 +1897,7 @@
Lock Layers… Заблокировать слои…
Mean Среднее
Merge Down Выполнить сведение
Merge Group Объединить группу
Merge Layers Объединить слои
Merge Shape Components Объединить компоненты фигуры
Merge Visible Свести видимые
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1876,6 +1876,7 @@
Lock Layers… Заблокувати шари…
Mean Середнє значення
Merge Down Об'єднати з нижнім
Merge Group Об'єднати групу
Merge Layers Об'єднати шари
Merge Shape Components Об'єднати компоненти фігури
Merge Visible Об'єднати видимі
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -393,6 +393,7 @@
Select Linked Layers 选择链接的图层
Merge Layers 合并图层
Merge Down 向下合并
Merge Group 合并组
Merge Visible 合并可见图层
Stamp Visible 盖印可见图层
Select Layer Above 选择上方图层
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
393
394
395
396
397
398
399
+1
View File
@@ -398,6 +398,7 @@
Select Linked Layers 選取連結的圖層
Merge Layers 合併圖層
Merge Down 向下合併圖層
Merge Group 合併群組
Merge Visible 合併可見圖層
Stamp Visible 蓋印可見圖層
Select Layer Above 選取上方圖層
Can't render this file because it contains an unexpected character in line 6 and column 47.
+28
View File
@@ -111,6 +111,9 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec<Entry> {
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");
+8
View File
@@ -966,6 +966,14 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
{
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.