mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
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.
This commit is contained in:
@@ -433,12 +433,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -796,6 +796,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An optional non-negative integer parameter; any other value is an error.
|
||||
fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result<Option<u64>> {
|
||||
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<Arc<Item>>> {
|
||||
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<Value> {
|
||||
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<Arc<Item>> = items.iter().filter(|i| ids.contains(&i.id)).cloned().collect();
|
||||
let rest: Vec<Arc<Item>> = 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<usize> = 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<Value> {
|
||||
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<u64> {
|
||||
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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "多行"]),
|
||||
|
||||
@@ -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", "متعدد الأسطر"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "複数行"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "Багаторядкове"),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Layers panel: expandable layer rows listing the current spread's objects (`<rectangle>`,
|
||||
//! `<first words…>` 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
|
||||
//! (`<rectangle>`, `<first words…>` 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<ItemId>,
|
||||
group: Option<ItemId>,
|
||||
},
|
||||
/// 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<ItemId> },
|
||||
}
|
||||
|
||||
/// 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::<Vec<_>>();
|
||||
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<Item>],
|
||||
group: Option<ItemId>,
|
||||
depth: usize,
|
||||
rows: &mut Vec<Row>,
|
||||
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<ItemId> = if is_sel {
|
||||
let siblings: &[std::sync::Arc<Item>] = 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<ItemId> = 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<std::sync::Arc<Item>> =
|
||||
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<Row> = 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<u64> = 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<std::sync::Arc<Item>> = 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::<LayersDrag>(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), "<rectangle>");
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<u64> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user