diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 8d1f1977..6fb6edbd 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -26,6 +26,7 @@ pub fn specs() -> Vec { has_doc, path_create ), + cmd!("view.drawMode", "Drawing Mode", [], Some("Shift+D"), "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", has_doc, draw_mode), cmd!( "text.create", "Create Text", @@ -46,18 +47,79 @@ pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option) -> Result { let parent = s.doc()?.insertion_parent(); + let mode = s.draw_mode; + let inside = s.draw_inside; + let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); let id = s.edit(label, |d, sel| { let id = d.alloc_id(); let mut n = Node::new(id, kind); n.appearance = appearance; n.name = name; - d.insert(parent, usize::MAX, n)?; + match (mode, inside) { + (crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => { + // Wrap the target once in a clip group [mask copy, target, …new art]. + let group = match d.parent_of(target).and_then(|p| d.node(p).map(|pn| (p, pn.kind.clone()))) { + Some((p, NodeKind::Group { clip: true, children })) if children.get(1).is_some_and(|c| c.id == target) => p, + _ => { + let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?; + let mut mask = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?; + mask.id = d.alloc_id(); + mask.appearance = Appearance::basic(drawcraft_color::Paint::None, drawcraft_color::Paint::None, 0.0); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let gid = d.alloc_id(); + d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?; + d.move_node(target, Some(gid), usize::MAX)?; + gid + } + }; + d.insert(Some(group), usize::MAX, n)?; + } + (crate::DrawMode::Behind, _) => match behind_of.and_then(|b| d.position(b)) { + Some((par, idx, _)) => { + d.insert(par, idx, n)?; + } + None => { + d.insert(parent, 0, n)?; + } + }, + _ => { + d.insert(parent, usize::MAX, n)?; + } + } sel.set([id]); Ok(id) })?; Ok(json!({ "id": id.0 })) } +fn draw_mode(s: &mut Session, p: &Value) -> Result { + let mode = match str_param(p, "mode") { + Some("behind") => crate::DrawMode::Behind, + Some("inside") => crate::DrawMode::Inside, + Some("normal") => crate::DrawMode::Normal, + None => match s.draw_mode { + crate::DrawMode::Normal => crate::DrawMode::Behind, + crate::DrawMode::Behind if s.doc()?.selection.len() == 1 => crate::DrawMode::Inside, + _ => crate::DrawMode::Normal, + }, + Some(o) => return Err(bad("view.drawMode", format!("unknown mode `{o}`"))), + }; + if mode == crate::DrawMode::Inside { + let st = s.doc()?; + let target = st.selection.objects.first().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)))); + match target { + Some(t) if st.selection.len() == 1 => s.draw_inside = Some(t), + _ => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")), + } + } else { + s.draw_inside = None; + } + s.draw_mode = mode; + Ok(json!({ "mode": mode })) +} + fn path_kind(path: PathData, live: Option) -> NodeKind { NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cfd0840f..ff88eb64 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -131,6 +131,16 @@ impl DocState { } } +/// Where new art goes (Illustrator's drawing modes, Shift+D cycles). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DrawMode { + #[default] + Normal, + Behind, + Inside, +} + /// Application preferences that affect commands. #[derive(Clone, Debug)] pub struct Prefs { @@ -163,7 +173,13 @@ pub struct Session { pub journal: Vec<(String, Value)>, pub(crate) tool: Box, pub(crate) last_view: ViewInfo, + /// Draw Normal / Behind / Inside (toolbar drawing modes). + pub draw_mode: DrawMode, + /// The path new art is drawn inside (Draw Inside). + pub draw_inside: Option, untitled_counter: u32, + /// Session-level state of the menu commands (saved selections, guide lock). + pub(crate) menu: cmd::menucmds::MenuState, } impl Default for Session { @@ -184,7 +200,10 @@ impl Session { journal: vec![], tool: drawcraft_tools::create("selection"), last_view: ViewInfo::default(), + draw_mode: DrawMode::Normal, + draw_inside: None, untitled_counter: 0, + menu: Default::default(), } } @@ -358,6 +377,10 @@ mod tests_draw2; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_menucmds; +#[cfg(test)] mod tests_pathops; #[cfg(test)] +mod tests_panelcmds; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 8413d834..6532c67b 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -87,3 +87,25 @@ fn pdf_roundtrip_through_engine() { s.execute("document.open", &json!({"name": "x.pdf", "dataBase64": b64})).unwrap(); assert!(s.doc().unwrap().doc.node_count() >= 2); } + +#[test] +fn draw_behind_and_inside() { + let mut s = session(); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("view.drawMode", &json!({"mode": "behind"})).unwrap(); + let b = s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let order: Vec = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect(); + assert_eq!(order, vec![b, a]); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + let c = s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let g = d.parent_of(NodeId(c)).unwrap(); + assert_eq!(d.node(g).unwrap().kind_label(), "Clip Group"); + assert_eq!(d.parent_of(NodeId(a)), Some(g)); + // A second shape goes into the same clip group. + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(g).unwrap().children().unwrap().len(), 4); + s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); + assert!(s.draw_inside.is_none()); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 885a3b10..fe940064 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -187,6 +187,10 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { let _ = app.run("paint.none", json!({})); continue; } + "Shift+D" => { + let _ = app.run("view.drawMode", json!({})); + continue; + } "F" => { let _ = app.run("view.screenMode", json!({})); continue; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 74695fec..050d5be5 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -224,9 +224,13 @@ fn bottom_controls(app: &mut DrawcraftApp, ui: &mut Ui, t: &Tokens) { ui.vertical_centered(|ui| { let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"]; let names = ["Draw Normal", "Draw Behind", "Draw Inside"]; - let m = app.ui.draw_mode as usize % 3; - if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() { - app.ui.draw_mode = (app.ui.draw_mode + 1) % 3; + let m = match app.session.draw_mode { + drawcraft_engine::DrawMode::Normal => 0, + drawcraft_engine::DrawMode::Behind => 1, + drawcraft_engine::DrawMode::Inside => 2, + }; + if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), m != 0, 26.0).clicked() { + app.run("view.drawMode", json!({})).ok(); } if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() { app.run("view.screenMode", json!({})).ok();