From 1b9125ceea9d42b4e9f155705efcba8e7acb71d5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 20:06:34 +0000 Subject: [PATCH] Layers: drag layers and objects to reorder them - object.reorder {ids, above|below: id | index, layer?}: restacks sibling objects in front of or behind a target, or at an index counted from the top of a layer, moving them to that layer. Group members stay in their group. Bad input is an error and leaves the document unchanged. - layer.move rejects a missing id or an out-of-range index. - Layers panel: drag a layer row to reorder layers, an object row to restack it or move it to another layer, and a filled selection square onto a layer to move the selection there; a line or box shows the drop. Groups expand to list their members. Layers list top (frontmost) first, matching the stacking order. --- crates/engine/src/cmd/layout.rs | 22 +- crates/engine/src/cmd/object.rs | 207 ++++++++++++++ crates/ui-egui/src/i18n.rs | 10 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/layers.rs | 415 +++++++++++++++++++++++----- crates/ui-egui/src/panels/pages.rs | 2 +- docs/target-app-parity.md | 2 +- 11 files changed, 594 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..4a00ce500 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -433,12 +433,12 @@ pub fn specs() -> Vec { } ), cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let to = p.get("to").and_then(Value::as_u64).unwrap_or(0) as usize; + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; s.edit(|d, _| { let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; + let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; let l = d.layers.remove(i); - let to = to.min(d.layers.len()); d.layers.insert(to, l); ok() }) @@ -794,6 +794,22 @@ mod setup_tests { mod layer_tests { use serde_json::json; + #[test] + fn layer_move_reorders_and_rejects_bad_input() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); + assert_eq!(order(&s), [l2, l1]); + for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { + assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); + assert_eq!(order(&s), [l2, l1]); + } + s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); + assert_eq!(order(&s), [l1, l2]); + } + #[test] fn merge_delete_unused_hide_and_lock_others() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..64ed1a827 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -796,6 +796,15 @@ pub fn specs() -> Vec { let l = designcraft_doc::LayerId(p.get("layer").and_then(Value::as_u64).unwrap_or(0)); set_flag(s, p, move |i| i.layer = l, false) }), + cmd!( + "object.reorder", + "Reorder Objects", + [], + None, + "{ids?, above?: id | below?: id | index?: n (0 = top of the layer), layer?} — moves the objects (siblings on one spread) in the stacking order: in front of `above` or behind `below` (onto that object's layer), or to `index` in `layer` counted from the top (default: the first object's layer, index 0). Group members stay in their group.", + has_doc, + reorder + ), ] } @@ -1302,6 +1311,124 @@ fn arrange(s: &mut Session, p: &Value) -> Result { }) } +/// An optional non-negative integer parameter; any other value is an error. +fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("{key} must be a non-negative integer"))), + } +} + +/// The sibling list under the group at `parent` (the spread's top level when empty). +fn siblings_mut<'a>(d: &'a mut Document, r: SpreadRef, parent: &[usize]) -> Option<&'a mut Vec>> { + let mut items = &mut d.spread_mut(r)?.items; + for &i in parent { + items = Arc::make_mut(items.get_mut(i)?).children_mut()?; + } + Some(items) +} + +fn reorder(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.reorder"; + let mut ids = targets(s, p)?; + let mut seen = HashSet::new(); + ids.retain(|i| seen.insert(*i)); + let Some(&first) = ids.first() else { return Err(bad(C, "no objects")) }; + let above = opt_u64(p, C, "above")?.map(ItemId); + let below = opt_u64(p, C, "below")?.map(ItemId); + let index = opt_u64(p, C, "index")?; + let layer = opt_u64(p, C, "layer")?.map(designcraft_doc::LayerId); + if [above.is_some(), below.is_some(), index.is_some()].into_iter().filter(|b| *b).count() > 1 { + return Err(bad(C, "give only one of above, below and index")); + } + let target = above.map(|t| (t, true)).or(below.map(|t| (t, false))); + s.edit(|d, _| { + let no_object = |id: ItemId| bad(C, format!("no object {}", id.0)); + let loc = d.find(first).ok_or_else(|| no_object(first))?; + let Some((_, parent)) = loc.path.split_last() else { return Err(no_object(first)) }; + let parent = parent.to_vec(); + let sibling = |id: ItemId| d.find(id).is_some_and(|l| l.spread == loc.spread && l.path.split_last().is_some_and(|(_, pp)| pp == parent)); + for id in &ids { + if d.find(*id).is_none() { + return Err(no_object(*id)); + } + if !sibling(*id) { + return Err(bad(C, "the objects must be on one spread and in one group")); + } + } + let in_group = !parent.is_empty(); + let group_layer = if in_group { + let g = d.item_at(&designcraft_doc::ItemLoc { spread: loc.spread, path: parent.clone() }).ok_or_else(|| no_object(first))?; + if !g.states.is_empty() { + return Err(bad(C, "the states of a multi-state object are reordered in the Object States panel")); + } + Some(g.layer) + } else { + None + }; + let own_layer = d.item(first).map(|i| i.layer).ok_or_else(|| no_object(first))?; + let target_layer = match target { + Some((t, _)) => { + if ids.contains(&t) { + return Err(bad(C, "an object can't move next to itself")); + } + if d.find(t).is_none() { + return Err(no_object(t)); + } + if !sibling(t) { + return Err(bad(C, "the target must be on the same spread and in the same group")); + } + let tl = d.item(t).map(|i| i.layer).ok_or_else(|| no_object(t))?; + if layer.is_some_and(|l| l != tl) { + return Err(bad(C, "layer differs from the target's layer")); + } + if in_group { own_layer } else { tl } + } + None => layer.unwrap_or(own_layer), + }; + if d.layer(target_layer).is_none() { + return Err(bad(C, "no such layer")); + } + if let Some(gl) = group_layer + && layer.is_some_and(|l| l != gl) + { + return Err(bad(C, "group members stay on their group's layer")); + } + let items = siblings_mut(d, loc.spread, &parent).ok_or_else(|| no_object(first))?; + // Moved objects keep their relative stacking order. + let moved: Vec> = items.iter().filter(|i| ids.contains(&i.id)).cloned().collect(); + let rest: Vec> = items.iter().filter(|i| !ids.contains(&i.id)).cloned().collect(); + let at = match target { + Some((t, front)) => { + let i = rest.iter().position(|x| x.id == t).ok_or_else(|| no_object(t))?; + if front { i + 1 } else { i } + } + None => { + let k = usize::try_from(index.unwrap_or(0)).map_err(|_| bad(C, "index out of range"))?; + // Positions of the layer's objects (all siblings in a group), topmost first. + let stack: Vec = rest.iter().enumerate().filter(|(_, x)| in_group || x.layer == target_layer).map(|(i, _)| i).rev().collect(); + if k > stack.len() { + return Err(bad(C, format!("index {k} out of range (0..={})", stack.len()))); + } + match (stack.get(k), stack.last()) { + (Some(&i), _) => i + 1, + (None, Some(&backmost)) => backmost, + (None, None) => rest.len(), + } + } + }; + let (back, front) = rest.split_at(at.min(rest.len())); + let moved = moved.into_iter().map(|mut it| { + if !in_group { + Arc::make_mut(&mut it).layer = target_layer; + } + it + }); + *items = back.iter().cloned().chain(moved).chain(front.iter().cloned()).collect(); + ok() + }) +} + fn group(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; if ids.len() < 2 { @@ -2714,3 +2841,83 @@ mod blend_mode_tests { assert_eq!(it.opacity, 1.0); } } + +#[cfg(test)] +mod reorder_tests { + use super::*; + + fn frame(s: &mut Session) -> u64 { + s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap() + } + + /// Top-level objects of the first spread on `layer`, topmost first. + fn stack(s: &Session, layer: u64) -> Vec { + let d = &s.doc().unwrap().doc; + d.spreads[0].items.iter().rev().filter(|i| i.layer.0 == layer).map(|i| i.id.0).collect() + } + + #[test] + fn reorder_within_and_across_layers_validates_and_undoes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": l1})).unwrap(); + let (a, b, c) = (frame(&mut s), frame(&mut s), frame(&mut s)); + s.execute("layer.activate", &json!({"id": l2})).unwrap(); + let (x, y) = (frame(&mut s), frame(&mut s)); + assert_eq!(stack(&s, l1), [c, b, a]); + s.execute("object.reorder", &json!({"ids": [a], "above": c})).unwrap(); + assert_eq!(stack(&s, l1), [a, c, b]); + s.execute("object.reorder", &json!({"ids": [b], "index": 0})).unwrap(); + assert_eq!(stack(&s, l1), [b, a, c]); + // To another layer at a position. + s.execute("object.reorder", &json!({"ids": [a], "layer": l2, "index": 1})).unwrap(); + assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c], vec![y, a, x])); + s.execute("object.reorder", &json!({"ids": [x, y], "below": c})).unwrap(); + assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c, y, x], vec![a])); + // Bad input is an error and changes nothing. + let before = s.doc().unwrap().doc.clone(); + for bad in [ + json!({"ids": [999], "index": 0}), + json!({"ids": [b], "index": 4}), + json!({"ids": [b], "above": b}), + json!({"ids": [b], "above": 999}), + json!({"ids": [b], "layer": 999}), + json!({"ids": [b], "layer": l2, "above": c}), + json!({"ids": [b], "above": c, "index": 0}), + json!({"ids": [b], "index": "top"}), + json!({"ids": []}), + ] { + assert!(s.execute("object.reorder", &bad).is_err(), "{bad}"); + assert_eq!(*s.doc().unwrap().doc, *before, "{bad}"); + } + // The bottom of a layer is index = its count. + s.execute("object.reorder", &json!({"ids": [b], "index": 3})).unwrap(); + assert_eq!(stack(&s, l1), [c, y, x, b]); + // One undo step per reorder. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stack(&s, l1), [b, c, y, x]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((stack(&s, l1), stack(&s, l2)), (vec![b, c], vec![y, a, x])); + } + + #[test] + fn group_members_reorder_only_within_their_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, b, c) = (frame(&mut s), frame(&mut s), frame(&mut s)); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let kids = |s: &Session| s.doc().unwrap().doc.item(ItemId(g)).unwrap().children().iter().map(|i| i.id.0).collect::>(); + assert_eq!(kids(&s), [a, b]); + s.execute("object.reorder", &json!({"ids": [a], "above": b})).unwrap(); + assert_eq!(kids(&s), [b, a]); + s.execute("object.reorder", &json!({"ids": [a], "index": 1})).unwrap(); + assert_eq!(kids(&s), [a, b]); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("object.reorder", &json!({"ids": [a], "above": c})).is_err(), "out of the group"); + assert!(s.execute("object.reorder", &json!({"ids": [c], "above": a})).is_err(), "into the group"); + assert!(s.execute("object.reorder", &json!({"ids": [a, c], "index": 0})).is_err(), "mixed parents"); + assert_eq!(*s.doc().unwrap().doc, *before); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..ce8ac9b9e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -404,6 +404,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Mobile", ["Mobile", "Mobile", "Mobile", "Mobile", "移动设备"]), ("Move Selection Here", ["Move Selection Here", "Move Selection Here", "Move Selection Here", "Move Selection Here", "将所选内容移至此处"]), + ( + "Select; drag to move the selection to another layer", + [ + "Auswählen; ziehen, um die Auswahl auf eine andere Ebene zu verschieben", + "Sélectionner ; faire glisser pour déplacer la sélection vers un autre calque", + "Seleccionar; arrastre para mover la selección a otra capa", + "選択(ドラッグで選択範囲を別のレイヤーへ移動)", + "选择;拖动可将所选内容移至其他图层", + ], + ), ("Move to Color Group", ["Move to Color Group", "Move to Color Group", "Move to Color Group", "Move to Color Group", "移动到颜色组"]), ("Move to Group", ["Move to Group", "Move to Group", "Move to Group", "Move to Group", "移动到组"]), ("Multiline", ["Multiline", "Multiline", "Multiline", "Multiline", "多行"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..78c6e91fe 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -897,6 +897,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mobile", "جوال"), ("More ArtCraft apps", "المزيد من تطبيقات ArtCraft"), ("Move Selection Here", "نقل التحديد إلى هنا"), + ("Select; drag to move the selection to another layer", "تحديد؛ اسحب لنقل التحديد إلى طبقة أخرى"), ("Move to Color Group", "نقل إلى مجموعة ألوان"), ("Move to Group", "نقل إلى مجموعة"), ("Multiline", "متعدد الأسطر"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..36682ff92 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -238,6 +238,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Misspelled word → Correction (one per line):", "Parola errata → Correzione (una per riga):"), ("Mobile", "Dispositivi mobili"), ("Move Selection Here", "Sposta qui la selezione"), + ("Select; drag to move the selection to another layer", "Seleziona; trascina per spostare la selezione su un altro livello"), ("Move to Color Group", "Sposta nel gruppo colori"), ("Move to Group", "Sposta nel gruppo"), ("Multiline", "Multiriga"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..4d6c40185 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -240,6 +240,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Misspelled word → Correction (one per line):", "綴りの誤った単語 → 修正後の単語 (1行に1件):"), ("Mobile", "モバイル"), ("Move Selection Here", "選択範囲をここへ移動"), + ("Select; drag to move the selection to another layer", "選択(ドラッグで選択範囲を別のレイヤーへ移動)"), ("Move to Color Group", "カラーグループへ移動"), ("Move to Group", "グループへ移動"), ("Multiline", "複数行"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..fe300cd21 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -899,6 +899,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mobile", "Dispositivo móvel"), ("More ArtCraft apps", "Mais aplicativos ArtCraft"), ("Move Selection Here", "Mover a seleção para cá"), + ("Select; drag to move the selection to another layer", "Selecionar; arraste para mover a seleção para outra camada"), ("Move to Color Group", "Mover para grupo de cores"), ("Move to Group", "Mover para grupo"), ("Multiline", "Multilinha"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..f1a3a598d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -241,6 +241,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Misspelled word → Correction (one per line):", "Помилкове слово → виправлення (по одному в рядку):"), ("Mobile", "Мобільні пристрої"), ("Move Selection Here", "Перемістити виділення сюди"), + ("Select; drag to move the selection to another layer", "Виділити; перетягніть, щоб перемістити виділення на інший шар"), ("Move to Color Group", "Перемістити до колірної групи"), ("Move to Group", "Перемістити до групи"), ("Multiline", "Багаторядкове"), diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index a9c842116..6121bb210 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1,8 +1,11 @@ -//! Layers panel: expandable layer rows listing the current spread's objects (``, -//! `` for text frames), with eye / lock columns per layer and per object and a -//! selection square in the layer colour. +//! Layers panel: expandable layer rows listing the current spread's objects topmost first +//! (``, `` for text frames; groups expand to their members), with eye / +//! lock columns per layer and per object and a selection square in the layer colour. Drag a +//! layer row to reorder layers, an object row to restack it or move it to another layer (group +//! members restack within their group), and a filled selection square onto another layer to move +//! the selection there. -use designcraft_doc::{Content, Document, Item, ItemId}; +use designcraft_doc::{Content, Document, Item, ItemId, LayerId}; use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -80,7 +83,8 @@ fn eye_lock( (e.clicked(), l.clicked()) } -/// The selection square at the right of a row: filled = selected; hollow on hover. +/// The selection square at the right of a row: filled = selected; hollow on hover. Click to +/// select; when filled, drag it to another layer. fn selection_square( ui: &mut Ui, language: &str, @@ -89,19 +93,209 @@ fn selection_square( selected: bool, small: bool, key: impl std::hash::Hash + std::fmt::Debug, -) -> bool { +) -> egui::Response { let s = if small { 7.0 } else { 8.0 }; let r = Rect::from_center_size(pos2(row.max.x - 12.0, row.center().y), vec2(s, s)); - let resp = ui.interact(r.expand(4.0), ui.id().with(("selsq", key)), Sense::click()); + let sense = if selected && !small { Sense::click_and_drag() } else { Sense::click() }; + let resp = ui.interact(r.expand(4.0), ui.id().with(("selsq", key)), sense); if selected { ui.painter().rect_filled(r, 0.0, color); } else if resp.hovered() { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } + let tip = if selected && !small { "Select; drag to move the selection to another layer" } else { "Select" }; resp.on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(language, "Select")); + crate::rtl::label(ui, crate::i18n::tr(language, tip)); }) - .clicked() +} + +/// What a drag in the Layers panel carries. +#[derive(Clone, Debug, PartialEq)] +enum LayersDrag { + Layer(LayerId), + /// Objects that share one parent: the spread (`group: None`) or a group. + Objects { + ids: Vec, + group: Option, + }, + /// The selected objects, from a layer's selection square. + Selection, +} + +/// Where a drag would land. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DropAt { + /// The layer slot at this index of `doc.layers` (0 = top). + LayerSlot(usize), + /// Onto a layer: to the top of its stack. + OntoLayer(LayerId), + /// In front of (`above`) or behind an object. + Object { id: ItemId, above: bool }, +} + +enum RowKind { + Layer, + Object { id: ItemId, group: Option }, +} + +/// A laid-out row, for drop targeting. +struct Row { + rect: Rect, + layer: LayerId, + /// Index of `layer` in `doc.layers`. + layer_index: usize, + kind: RowKind, + /// Bottom of the row's block: the last row of an open layer or group, else the row itself. + end: f32, +} + +enum Indicator { + Line(f32), + Box(Rect), +} + +/// The drop for `drag` with the pointer at `p`, and how to show it. `None` where it can't land. +fn drop_target(drag: &LayersDrag, rows: &[Row], p: egui::Pos2) -> Option<(DropAt, Indicator)> { + let r = rows.iter().find(|r| r.rect.contains(p))?; + let header = rows.iter().find(|h| h.layer == r.layer && matches!(h.kind, RowKind::Layer))?; + match drag { + LayersDrag::Layer(src) => { + let before = p.y < (header.rect.min.y + header.end) / 2.0; + let slot = if before { r.layer_index } else { r.layer_index + 1 }; + let from = rows.iter().find(|h| h.layer == *src && matches!(h.kind, RowKind::Layer))?.layer_index; + if slot == from || slot == from + 1 { + return None; + } + Some((DropAt::LayerSlot(slot), Indicator::Line(if before { header.rect.min.y } else { header.end }))) + } + LayersDrag::Selection => Some((DropAt::OntoLayer(r.layer), Indicator::Box(header.rect))), + LayersDrag::Objects { ids, group } => match r.kind { + RowKind::Layer if group.is_none() => Some((DropAt::OntoLayer(r.layer), Indicator::Box(r.rect))), + RowKind::Object { id, group: g } if g == *group && !ids.contains(&id) => { + let above = p.y < r.rect.center().y; + Some((DropAt::Object { id, above }, Indicator::Line(if above { r.rect.min.y } else { r.end }))) + } + _ => None, + }, + } +} + +/// The command a drop runs. +fn drop_command(drag: &LayersDrag, at: DropAt, doc: &Document, selected: &[ItemId]) -> Option<(&'static str, serde_json::Value)> { + let ids = |ids: &[ItemId]| ids.iter().map(|i| i.0).collect::>(); + match (drag, at) { + (LayersDrag::Layer(id), DropAt::LayerSlot(slot)) => { + let from = doc.layers.iter().position(|l| l.id == *id)?; + let to = if slot > from { slot - 1 } else { slot }; + Some(("layer.move", json!({"id": id.0, "to": to}))) + } + (LayersDrag::Selection, DropAt::OntoLayer(l)) => { + let moves = selected.iter().any(|i| doc.item(*i).is_some_and(|it| it.layer != l)); + moves.then(|| ("object.setLayer", json!({"layer": l.0, "ids": ids(selected)}))) + } + (LayersDrag::Objects { ids: o, .. }, DropAt::OntoLayer(l)) => Some(("object.reorder", json!({"ids": ids(o), "layer": l.0, "index": 0}))), + (LayersDrag::Objects { ids: o, .. }, DropAt::Object { id, above }) => { + Some(("object.reorder", json!({"ids": ids(o), if above { "above" } else { "below" }: id.0}))) + } + _ => None, + } +} + +/// Nesting limit for listing group members. +const MAX_DEPTH: usize = 32; + +/// Per-frame inputs shared by the object rows. +struct Listing<'a> { + doc: &'a Document, + language: &'a str, + selected: &'a [ItemId], + layer: LayerId, + layer_index: usize, + layer_color: Color32, + layer_visible: bool, +} + +/// Rows for `items` (topmost first) under `group` at `depth`; the commands their clicks run go +/// to `cmds`. +fn object_rows( + ui: &mut Ui, + ls: &Listing, + items: &[std::sync::Arc], + group: Option, + depth: usize, + rows: &mut Vec, + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) { + let t = Tokens::get(ui.ctx()); + for it in items.iter().rev() { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + let is_sel = ls.selected.contains(&it.id); + if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let (eye, lock) = eye_lock(ui, ls.language, row, !it.hidden, it.locked, !ls.layer_visible, ("item", it.id.0)); + let indent = 14.0 * depth as f32; + let expandable = !it.children().is_empty() && it.states.is_empty() && depth < MAX_DEPTH; + let open_id = egui::Id::new(("group_open", it.id.0)); + let open = expandable && ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut tri_clicked = false; + if expandable { + let tri = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 14.0 + indent, 0.0), vec2(14.0, ROW_H)); + disclosure(ui, tri, open, t.icon); + tri_clicked = ui.interact(tri, ui.id().with(("gtri", it.id.0)), Sense::click()).clicked(); + } + let name = item_label(ls.doc, it); + let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); + ui.painter().with_clip_rect(clip).text( + row.min + vec2(2.0 * COL_W + 30.0 + indent, ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(11.0), + if it.hidden { t.text_dim } else { t.text }, + ); + let sq = selection_square(ui, ls.language, row, ls.layer_color, is_sel, true, ("item", it.id.0)).clicked(); + // Dragging a selected row drags the selected objects that share its parent. + let dragged: Vec = if is_sel { + let siblings: &[std::sync::Arc] = items; + siblings.iter().map(|i| i.id).filter(|i| ls.selected.contains(i)).collect() + } else { + vec![it.id] + }; + resp.dnd_set_drag_payload(LayersDrag::Objects { ids: dragged, group }); + if resp.dragged() { + super::pages::drag_ghost(ui, &name, &t); + } + if eye { + cmds.push(("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden}))); + } else if lock { + cmds.push(("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked}))); + } else if tri_clicked { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + } else if sq || resp.clicked() { + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + cmds.push(("selection.set", json!({"ids": [it.id.0], "add": add}))); + } + let at = rows.len(); + rows.push(Row { rect: row, layer: ls.layer, layer_index: ls.layer_index, kind: RowKind::Object { id: it.id, group }, end: row.max.y }); + if open { + object_rows(ui, ls, it.children(), Some(it.id), depth + 1, rows, cmds); + let end = rows.last().map_or(row.max.y, |r| r.end.max(r.rect.max.y)); + if let Some(r) = rows.get_mut(at) { + r.end = end; + } + } + } +} + +/// A disclosure triangle in `r`: pointing down when open. +fn disclosure(ui: &Ui, r: Rect, open: bool, color: Color32) { + let c = r.center(); + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, color, Stroke::NONE)); } pub fn show(app: &mut DesignApp, ui: &mut Ui) { @@ -111,31 +305,28 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let active = st.active_layer; let selected: Vec = st.selection.items.clone(); let sel_layers: Vec<_> = selected.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect(); - // Objects on the current spread, topmost first. + let language = app.ui.language.clone(); + // Objects on the current spread, back to front. let cur = crate::canvas::current_page(app).unwrap_or(0); let spread_items: Vec> = - doc.page_loc(cur).and_then(|(si, _)| doc.spreads.get(si)).map(|sp| sp.items.iter().rev().cloned().collect()).unwrap_or_default(); + doc.page_loc(cur).and_then(|(si, _)| doc.spreads.get(si)).map(|sp| sp.items.clone()).unwrap_or_default(); + let mut rows: Vec = Vec::new(); + let mut cmds: Vec<(&'static str, serde_json::Value)> = Vec::new(); ui.spacing_mut().item_spacing.y = 0.0; - for l in doc.layers.iter().rev() { + // `doc.layers` runs top (frontmost) to bottom, as the panel lists them. + for (li, l) in doc.layers.iter().enumerate() { let open_id = egui::Id::new(("layer_open", l.id.0)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); let lc = c32(l.color); - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); if l.id == active { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - let (eye, lock) = eye_lock(ui, &app.ui.language, row, l.visible, l.locked, false, ("layer", l.id.0)); - // Disclosure triangle. + let (eye, lock) = eye_lock(ui, &language, row, l.visible, l.locked, false, ("layer", l.id.0)); let tri = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 2.0, 0.0), vec2(14.0, ROW_H)); - let c = tri.center(); - let pts = if open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - ui.painter().add(egui::Shape::convex_polygon(pts, t.icon, Stroke::NONE)); + disclosure(ui, tri, open, t.icon); let tri_r = ui.interact(tri, ui.id().with(("tri", l.id.0)), Sense::click()); // Layer colour chip and name. let chip = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 18.0, 7.0), vec2(4.0, 10.0)); @@ -150,34 +341,39 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { if l.id == active { icons::paint(ui.painter(), Rect::from_center_size(pos2(row.max.x - 32.0, row.center().y), vec2(13.0, 13.0)), "tool-pen", t.icon); } - let sq = selection_square(ui, &app.ui.language, row, lc, sel_layers.contains(&l.id), false, ("layer", l.id.0)); + let sq = selection_square(ui, &language, row, lc, sel_layers.contains(&l.id), false, ("layer", l.id.0)); + sq.dnd_set_drag_payload(LayersDrag::Selection); + if sq.dragged() { + super::pages::drag_ghost(ui, crate::i18n::tr(&language, "Move Selection Here"), &t); + } + resp.dnd_set_drag_payload(LayersDrag::Layer(l.id)); + if resp.dragged() { + super::pages::drag_ghost(ui, &l.name, &t); + } if eye { - let _ = app.run("layer.set", json!({"id": l.id.0, "visible": !l.visible})); + cmds.push(("layer.set", json!({"id": l.id.0, "visible": !l.visible}))); } else if lock { - let _ = app.run("layer.set", json!({"id": l.id.0, "locked": !l.locked})); + cmds.push(("layer.set", json!({"id": l.id.0, "locked": !l.locked}))); } else if tri_r.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); - } else if sq { + } else if sq.clicked() { let ids: Vec = spread_items.iter().filter(|i| i.layer == l.id).map(|i| i.id.0).collect(); - let _ = app.run("selection.set", json!({"ids": ids})); + cmds.push(("selection.set", json!({"ids": ids}))); } else if resp.clicked() { - let _ = app.run("layer.activate", json!({"id": l.id.0})); + cmds.push(("layer.activate", json!({"id": l.id.0}))); } resp.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Layer"))).clicked() { - let _ = app.run("layer.delete", json!({"id": l.id.0})); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Delete Layer"))).clicked() { + cmds.push(("layer.delete", json!({"id": l.id.0}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move Selection Here"))).clicked() { - let _ = app.run("object.setLayer", json!({"layer": l.id.0})); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Move Selection Here"))).clicked() { + cmds.push(("object.setLayer", json!({"layer": l.id.0}))); ui.close(); } ui.separator(); - let active = app.session.active().map(|d| d.active_layer); - if let Some(a) = active.filter(|a| *a != l.id) - && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Merge into Active Layer"))).clicked() - { - let _ = app.run("layer.merge", json!({"ids": [a.0, l.id.0], "into": a.0})); + if active != l.id && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Merge into Active Layer"))).clicked() { + cmds.push(("layer.merge", json!({"ids": [active.0, l.id.0], "into": active.0}))); ui.close(); } for (label, params) in [ @@ -186,45 +382,55 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ("Show All Layers", json!({"id": l.id.0, "show": true})), ("Unlock All Layers", json!({"id": l.id.0, "unlock": true})), ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("layer.others", params); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + cmds.push(("layer.others", params)); ui.close(); } } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Unused Layers"))).clicked() { - let _ = app.run("layer.deleteUnused", json!({})); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Delete Unused Layers"))).clicked() { + cmds.push(("layer.deleteUnused", json!({}))); ui.close(); } }); + let at = rows.len(); + rows.push(Row { rect: row, layer: l.id, layer_index: li, kind: RowKind::Layer, end: row.max.y }); if !open { continue; } - for it in spread_items.iter().filter(|i| i.layer == l.id) { - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); - let is_sel = selected.contains(&it.id); - if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + // The layer's objects keep their spread order; the panel lists them topmost first. + let on_layer: Vec> = spread_items.iter().filter(|i| i.layer == l.id).cloned().collect(); + let ls = + Listing { doc: &doc, language: &language, selected: &selected, layer: l.id, layer_index: li, layer_color: lc, layer_visible: l.visible }; + object_rows(ui, &ls, &on_layer, None, 0, &mut rows, &mut cmds); + let end = rows.last().map_or(row.max.y, |r| r.end.max(r.rect.max.y)); + if let Some(r) = rows.get_mut(at) { + r.end = end; + } + } + // Drag and drop: an indicator line between rows (a box around a layer row) while dragging; + // one command on release. + if let Some(drag) = egui::DragAndDrop::payload::(ui.ctx()) + && let Some(p) = ui.ctx().pointer_latest_pos() + && let Some((at, indicator)) = drop_target(&drag, &rows, p) + { + let x = ui.max_rect().x_range(); + match indicator { + Indicator::Line(y) => { + ui.painter().line_segment([pos2(x.min, y), pos2(x.max, y)], Stroke::new(2.0, t.accent)); } - let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); - let name = item_label(&doc, it); - let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); - ui.painter().with_clip_rect(clip).text( - row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), - egui::Align2::LEFT_CENTER, - &name, - egui::FontId::proportional(11.0), - if it.hidden { t.text_dim } else { t.text }, - ); - let sq = selection_square(ui, &app.ui.language, row, lc, is_sel, true, ("item", it.id.0)); - if eye { - let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden})); - } else if lock { - let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked})); - } else if sq || resp.clicked() { - let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); - let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); + Indicator::Box(r) => { + ui.painter().rect_stroke(r, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); } } + if ui.input(|i| i.pointer.any_released()) { + egui::DragAndDrop::clear_payload(ui.ctx()); + if let Some(c) = drop_command(&drag, at, &doc, &selected) { + cmds.push(c); + } + } + } + for (id, params) in cmds { + let _ = app.run(id, params); } ui.add_space(6.0); ui.spacing_mut().item_spacing.y = 4.0; @@ -267,4 +473,85 @@ mod tests { let rr = doc.item(ItemId(rect["id"].as_u64().unwrap())).unwrap(); assert_eq!(item_label(doc, rr), ""); } + + /// The panel alone, 304 pt wide; rows start at (8, 8). + fn panel(app: DesignApp) -> egui_kittest::Harness<'static, DesignApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + h + } + + /// The point `y` of the way down row `i`, in the name column (or at the selection square). + fn row(i: usize, y: f32, square: bool) -> egui::Pos2 { + pos2(if square { 300.0 } else { 150.0 }, 8.0 + ROW_H * (i as f32 + y)) + } + + fn drag(h: &mut egui_kittest::Harness<'static, DesignApp>, from: egui::Pos2, to: egui::Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(from.lerp(to, k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + } + + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app + } + + fn layers(app: &DesignApp) -> Vec { + app.session.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect() + } + + #[test] + fn dragging_a_layer_row_below_another_reorders_the_layers() { + let mut app = app(); + app.run("layer.new", json!({"name": "Art"})).unwrap(); + assert_eq!(layers(&app), ["Art", "Layer 1"]); + let mut h = panel(app); + // Art (open, no objects) is row 0, Layer 1 row 1: drop on Layer 1's lower half. + drag(&mut h, row(0, 0.5, false), row(1, 0.8, false)); + assert_eq!(layers(h.state()), ["Layer 1", "Art"]); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(layers(h.state()), ["Art", "Layer 1"], "one undo step"); + } + + #[test] + fn dragging_objects_and_the_selection_square_moves_them_between_layers() { + let mut app = app(); + let l1 = app.session.active().unwrap().active_layer; + let a = app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + let art = LayerId(app.run("layer.new", json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap()); + let x = app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = panel(app); + let layer_of = + |h: &egui_kittest::Harness<'static, DesignApp>, id: u64| h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().layer; + // Rows: Art, x, Layer 1 (closed). Open Layer 1: Art, x, Layer 1, a. + click(&mut h, pos2(8.0 + 2.0 * COL_W + 9.0, row(2, 0.5, false).y)); + // x dropped on the lower half of a goes behind it on Layer 1. + drag(&mut h, row(1, 0.5, false), row(3, 0.8, false)); + assert_eq!(layer_of(&h, x), l1); + let order: Vec = h.state().session.active().unwrap().doc.spreads[0].items.iter().map(|i| i.id.0).collect(); + assert_eq!(order, [x, a], "x is behind a"); + // Rows: Art, Layer 1, a, x. Select x and drag Layer 1's selection square onto Art. + h.state_mut().run("selection.set", json!({"ids": [x]})).unwrap(); + h.run_steps(2); + drag(&mut h, row(1, 0.5, true), row(0, 0.5, false)); + assert_eq!(layer_of(&h, x), art); + assert_eq!(layer_of(&h, a), l1); + } + + fn click(h: &mut egui_kittest::Harness<'static, DesignApp>, p: egui::Pos2) { + h.hover_at(p); + h.drag_at(p); + h.drop_at(p); + h.run_steps(3); + } } diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..fe0350cbf 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -272,7 +272,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } /// A small label following the pointer while dragging. -fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { +pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { let Some(p) = ui.ctx().pointer_interact_pos() else { return }; let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("page_drag"))); let g = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(11.0), t.text_strong); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..bdb6e9189 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -269,7 +269,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).