Draw Normal/Behind/Inside modes (Shift+D)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 07:22:47 -04:00
1 parent a2fd35cd32
commit 213b0927ee
5 files changed
+119 -4

No files matched your search

+63 -1
View File
@@ -26,6 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<String>) -> Result<Value> {
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<Value> {
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<LiveShape>) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false }
}
+23
View File
@@ -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<dyn Tool>,
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<NodeId>,
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;
+22
View File
@@ -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<u64> = 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());
}
+4
View File
@@ -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;
+7 -3
View File
@@ -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();