From 4ac6e9a54060e52c264ab1c6f21900a04479977b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 14:28:58 +0000 Subject: [PATCH] Engine: swap and default fill and stroke in one undo step object.swapFillStroke and object.defaultFillStroke change every unlocked target in one edit, so one undo restores them all. Swap works per object and keeps each object's tints; a stroke that gets a colour at zero weight gets the default 1 pt. Objects that are locked, or on a locked layer, stay as they are. color.swapFillStroke and color.defaultFillStroke run these commands. --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 110 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++- crates/ui-egui/src/toolbar.rs | 11 +--- 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29984b2..4dd17e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap, tints included) and / (Apply None), as `color.*` commands; the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be1..6992472 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -185,6 +185,24 @@ pub fn specs() -> Vec { let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), + cmd!( + "object.swapFillStroke", + "Swap Fill and Stroke", + [], + None, + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, swap_fill_stroke) + ), + cmd!( + "object.defaultFillStroke", + "Default Fill and Stroke", + [], + None, + "{ids?} — no fill and a black stroke; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, default_fill_stroke) + ), cmd!( "object.stroke", "Stroke", @@ -1404,6 +1422,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// An object a colour command may change: neither it nor its layer is locked. +fn unlocked(d: &Document, it: &Item) -> bool { + !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) +} + +fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; + if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) { + Ok(()) + } else { + Err("the selected objects are locked".into()) + } +} + +/// Runs `f` on each unlocked target in one edit (one undo step). +fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + if d.item(*id).is_some_and(|it| unlocked(d, it)) + && let Some(it) = d.item_mut(*id) + { + f(it); + changed += 1; + } + } + Ok(json!({"changed": changed})) + }) +} + +/// A stroke that gets a colour needs a weight to show it. +fn visible_weight(st: &mut Stroke) { + if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 { + st.weight = Stroke::default().weight; + } +} + +fn swap_fill_stroke(it: &mut Item) { + let fill = std::mem::take(&mut it.fill); + it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() }; + it.stroke.swatch = fill.swatch; + it.stroke.tint = fill.tint; + visible_weight(&mut it.stroke); +} + +fn default_fill_stroke(it: &mut Item) { + it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() }; + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.tint = 1.0; + visible_weight(&mut it.stroke); +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result { s.edit(|d, _| { for i in 0..d.spreads.len() { @@ -2125,6 +2197,44 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { mod step_repeat_tests { use super::*; + #[test] + fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |v: Value| ItemId(v["id"].as_u64().unwrap()); + let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()); + let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()); + let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()); + s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap(); + s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap(); + let colors = |s: &Session, i: ItemId| { + let it = s.doc().unwrap().doc.item(i).unwrap().clone(); + (it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint) + }; + let before = [colors(&s, a), colors(&s, b), colors(&s, locked)]; + + assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2); + assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5)); + assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both"); + + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + for i in [a, b] { + assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before); + + s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap(); + assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change"); + } + #[test] fn step_repeat_validates_counts_and_offsets() { assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d3f8c1..b614467 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,8 +85,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), - ("color.defaultFillStroke", "Default Fill and Stroke", Some("D"), "{} — the selection gets no fill and a black stroke"), - ("color.swapFillStroke", "Swap Fill and Stroke", Some("Shift+X"), "{} — the selection's fill and stroke colours (and tints) trade places"), + ( + "color.defaultFillStroke", + "Default Fill and Stroke", + Some("D"), + "{} — the selection gets no fill and a black stroke (object.defaultFillStroke)", + ), + ( + "color.swapFillStroke", + "Swap Fill and Stroke", + Some("Shift+X"), + "{} — each selected object's fill and stroke colours (and tints) trade places (object.swapFillStroke)", + ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ba82550..2a6744d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -410,17 +410,12 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: /// Default Fill and Stroke (D): no fill, a black stroke. pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { - app.run("object.fill", json!({"swatch": "[None]", "tint": 1.0}))?; - app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})) + app.run("object.defaultFillStroke", json!({})) } -/// Swap Fill and Stroke (Shift+X): the selection's fill and stroke colours trade places, tints -/// included. +/// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { - let Some(info) = crate::panels::sel_info(app) else { return Err("nothing selected".into()) }; - let (fill_tint, stroke_tint) = (crate::panels::sel_tint(app, false), crate::panels::sel_tint(app, true)); - app.run("object.fill", json!({"swatch": info.stroke, "tint": stroke_tint}))?; - app.run("object.stroke", json!({"swatch": info.fill, "tint": fill_tint})) + app.run("object.swapFillStroke", json!({})) } /// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy.