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.
This commit is contained in:
aiongg committed 2026-10-12 14:28:58 +00:00
1 parent b21111f232
commit 4ac6e9a540
4 files changed
+126 -11

No files matched your search

+1 -1
View File
@@ -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.
+110
View File
@@ -185,6 +185,24 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
s.edit(|d, _| {
for i in 0..d.spreads.len() {
@@ -2125,6 +2197,44 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
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);
+12 -2
View File
@@ -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"), "{}"),
+3 -8
View File
@@ -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<Value, String> {
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<Value, String> {
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.