From 528c7ea4891ff121fd63bdecd6a9d3bd5a8ae594 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 22:32:22 +0200 Subject: [PATCH] Fill and stroke with nothing selected set the colours of new shapes, as InDesign's defaults Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 21 +++++ crates/engine/src/cmd/object.rs | 145 +++++++++++++++++++++++++++++--- crates/engine/src/cmd/style.rs | 17 +++- crates/ui-egui/src/toolbar.rs | 47 ++++++++++- 5 files changed, 218 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..27df28e28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- 2026-10-15: With nothing selected, the Tools panel's fill and stroke (the Color Picker, swatches, Apply None, Swap, Default) set the colours new shapes are drawn with, as InDesign's document defaults; the proxy shows them. They were refused ("nothing selected"). - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c0fdc8fe2..66527285f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -349,6 +349,26 @@ pub struct DocSettings { /// Documents saved before the setting existed read as on, the way they were drawn. #[serde(default = "yes")] pub glyph_fallback: bool, + /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets + /// with nothing selected (InDesign's document defaults). + pub new_object: NewObjectColors, +} + +/// The colours of new shapes (see [`DocSettings::new_object`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NewObjectColors { + pub fill: String, + pub fill_tint: f32, + pub stroke: String, + pub stroke_tint: f32, +} + +impl Default for NewObjectColors { + /// No fill and a black stroke, as new documents have. + fn default() -> Self { + NewObjectColors { fill: "[None]".into(), fill_tint: 1.0, stroke: "[Black]".into(), stroke_tint: 1.0 } + } } impl Default for DocSettings { @@ -381,6 +401,7 @@ impl Default for DocSettings { overprint_black: true, track_changes: false, glyph_fallback: false, + new_object: NewObjectColors::default(), } } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbc0a36a1..e88ee9324 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -185,8 +185,8 @@ pub fn specs() -> Vec { "Fill", [], None, - "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)", - has_selection, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field). With nothing selected: the fill of new shapes", + has_doc, |s, p| { let sw = str_param(p, "swatch").map(str::to_string); let tint = match p.get("tint") { @@ -198,6 +198,15 @@ pub fn specs() -> Vec { None if tint.is_none() => return Err(bad("object.fill", "missing swatch")), None => {} } + if nothing_targeted(s, p)? { + return set_new_object(s, |c| { + if let Some(sw) = &sw { + c.fill = sw.clone(); + c.fill_tint = 1.0; + } + c.fill_tint = tint.unwrap_or(c.fill_tint); + }); + } set_flag( s, p, @@ -214,18 +223,33 @@ pub fn specs() -> Vec { "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) + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are. With nothing selected: those of new shapes", + has_doc, + |s, p| { + if nothing_targeted(s, p)? { + return set_new_object(s, |c| { + std::mem::swap(&mut c.fill, &mut c.stroke); + std::mem::swap(&mut c.fill_tint, &mut c.stroke_tint); + }); + } + has_editable_selection(s).map_err(|e| bad("object.swapFillStroke", e))?; + 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) + "{ids?} — no fill and a black stroke; locked objects stay as they are. With nothing selected: for new shapes", + has_doc, + |s, p| { + if nothing_targeted(s, p)? { + return set_new_object(s, |c| *c = designcraft_doc::NewObjectColors::default()); + } + has_editable_selection(s).map_err(|e| bad("object.defaultFillStroke", e))?; + edit_unlocked(s, p, default_fill_stroke) + } ), cmd!( "object.stroke", @@ -233,13 +257,28 @@ pub fn specs() -> Vec { [], None, "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", - has_selection, + has_doc, |s, p| { for key in ["swatch", "gapSwatch"] { if let Some(name) = p.get(key).and_then(Value::as_str) { known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; } } + // With nothing selected, the swatch and tint are those of new shapes. + if nothing_targeted(s, p)? { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32); + if sw.is_none() && tint.is_none() { + return Err(bad("object.stroke", super::NOTHING_SELECTED)); + } + return set_new_object(s, |c| { + if let Some(sw) = &sw { + c.stroke = sw.clone(); + c.stroke_tint = 1.0; + } + c.stroke_tint = tint.unwrap_or(c.stroke_tint); + }); + } let p2 = p.clone(); // A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one. let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids")); @@ -897,6 +936,39 @@ pub fn specs() -> Vec { ] } +/// No objects named and none selected (and no text): fill and stroke go to new shapes. +pub(crate) fn nothing_targeted(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + Ok(p.get("ids").is_none() + && p.get("id").is_none() + && st.selection.items.is_empty() + && st.selection.text.is_none() + && st.selection.cells.is_none()) +} + +/// Change the colours new shapes are drawn with (one undo step). +pub(crate) fn set_new_object(s: &mut Session, f: impl FnOnce(&mut designcraft_doc::NewObjectColors)) -> Result { + s.edit(|d, _| { + f(&mut d.settings.new_object); + let c = &d.settings.new_object; + Ok(json!({"newObject": {"fill": c.fill, "fillTint": c.fill_tint, "stroke": c.stroke, "strokeTint": c.stroke_tint}})) + }) +} + +/// A new shape's fill and stroke: the document's colours for new objects (a swatch deleted +/// since leaves the shape's own). +fn new_object_colors(d: &designcraft_doc::Document, it: &mut Item) { + let c = &d.settings.new_object; + let known = |sw: &str| sw == designcraft_color::swatch::NONE || d.swatches.iter().any(|w| w.name == sw); + if known(&c.fill) { + it.fill = Fill { swatch: c.fill.clone(), tint: c.fill_tint, ..it.fill.clone() }; + } + if known(&c.stroke) { + it.stroke.swatch = c.stroke.clone(); + it.stroke.tint = c.stroke_tint; + } +} + fn frame_create(s: &mut Session, p: &Value) -> Result { let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?; let shape = str_param(p, "shape").unwrap_or("rectangle"); @@ -937,8 +1009,10 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { if content == "graphic" { it.object_style = d.styles.default_graphic_frame.clone(); } else { - // Shapes drawn with Rectangle/Ellipse/Polygon get the default 1 pt black stroke. + // Shapes drawn with Rectangle/Ellipse/Polygon get a 1 pt stroke in the document's + // colours for new objects (no fill and black at first). it.stroke = Stroke::default(); + new_object_colors(d, &mut it); } d.insert_item(sr, it, None)?; *sel = Selection::items(vec![id]); @@ -970,6 +1044,7 @@ fn line_create(s: &mut Session, p: &Value) -> Result { let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, Shape::GraphicLine, shapes::line(a, b)); it.stroke = Stroke::default(); + new_object_colors(d, &mut it); d.insert_item(sr, it, None)?; *sel = Selection::items(vec![id]); Ok(json!({"id": id.0})) @@ -2594,6 +2669,56 @@ mod arrange_tests { mod step_repeat_tests { use super::*; + /// With nothing selected, fill and stroke set the colours new shapes are drawn with, as + /// InDesign's document defaults; frames for graphics and text keep theirs. + #[test] + fn with_nothing_selected_fill_and_stroke_are_for_new_shapes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let colors = |s: &Session, id: u64| { + let it = s.doc().unwrap().doc.item(ItemId(id)).unwrap(); + (it.fill.swatch.clone(), it.stroke.swatch.clone(), it.stroke.weight) + }; + let rect = |s: &mut Session| { + let id = s.execute("frame.create", &json!({"rect": [72, 72, 144, 144], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + id + }; + // New documents: no fill and a 1 pt black stroke. + let a = rect(&mut s); + assert_eq!(colors(&s, a), ("[None]".into(), "[Black]".into(), 1.0)); + // A picked colour and Apply None, with nothing selected. + let r = s.execute("object.color", &json!({"color": "#7c1315"})).unwrap(); + let red = r["swatch"].as_str().unwrap().to_string(); + s.execute("object.stroke", &json!({"swatch": "[None]"})).unwrap(); + let b = rect(&mut s); + assert_eq!(colors(&s, b), (red.clone(), "[None]".into(), 1.0), "the next shape has them"); + assert_eq!(colors(&s, a).0, "[None]", "shapes drawn before keep theirs"); + let line = s.execute("line.create", &json!({"a": [0, 0], "b": [100, 0]})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(colors(&s, line).1, "[None]"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + // A graphics frame keeps its object style's. + let g = s.execute("frame.create", &json!({"rect": [200, 72, 300, 144]})).unwrap()["id"].as_u64().unwrap(); + assert_ne!(colors(&s, g).0, red); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + // Swap and Default. + s.execute("object.swapFillStroke", &json!({})).unwrap(); + let c = &s.doc().unwrap().doc.settings.new_object; + assert_eq!((c.fill.as_str(), c.stroke.as_str()), ("[None]", red.as_str())); + s.execute("object.fill", &json!({"swatch": "[Paper]"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]"); + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.new_object, designcraft_doc::NewObjectColors::default()); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]"); + // With a selection, they still colour it and leave the defaults. + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + assert_eq!(colors(&s, a).0, "[Black]"); + assert_eq!(s.doc().unwrap().doc.settings.new_object.fill, "[Paper]"); + } + #[test] fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 1cccc7fd1..6e0c7589b 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -181,8 +181,8 @@ pub fn specs() -> Vec { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: cmyk|rgb|gray, …}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", - has_text_or_selection, + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: cmyk|rgb|gray, …}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection (with nothing selected, the colour of new shapes); `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", + has_doc, apply_color ), cmd!( @@ -1054,6 +1054,19 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { let named = p.get("ids").is_some() || p.get("id").is_some(); let text = !named && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + // Nothing selected: the colour of new shapes (InDesign's document defaults). + if !text && super::object::nothing_targeted(s, p)? { + return s.edit(|d, _| { + let name = color_swatch(d, color); + let c = &mut d.settings.new_object; + if stroke { + (c.stroke, c.stroke_tint) = (name.clone(), 1.0); + } else { + (c.fill, c.fill_tint) = (name.clone(), 1.0); + } + Ok(json!({"swatch": name, "newObject": true})) + }); + } let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; if text && texts.is_empty() { return Err(bad("object.color", "select the text to colour")); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7e6ca1cd2..69beb25e2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -208,7 +208,10 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text; let (fill_sw, stroke_sw) = match &target { Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()), - None => ("[None]".to_string(), "[Black]".to_string()), + // Nothing selected: the colours new shapes are drawn with. + None => doc + .as_ref() + .map_or(("[None]".to_string(), "[Black]".to_string()), |d| (d.settings.new_object.fill.clone(), d.settings.new_object.stroke.clone())), }; let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0; // Row 1: default colours + swap. @@ -767,6 +770,48 @@ mod tests { } } + /// With nothing selected, the Color Picker and Apply None set the colours of the next shape + /// (the course's poster: a scarlet fill picked before drawing), and the proxy shows them. + #[test] + fn with_nothing_selected_the_proxy_sets_the_next_shapes_colours() { + use serde_json::json; + let mut app = app(false); + app.run("file.new", json!({})).unwrap(); + // Frames 1 ms apart, so two clicks make a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut crate::DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + // Double-click the Fill box: the picker opens; OK with nothing selected keeps the colour. + let at = h.get_by_label(FILL).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("colorPicker")); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("#7c1315")); + crate::dialogs::confirm(app).unwrap(); + // Apply None with the stroke in front. + app.ui.stroke_front = true; + app.run("color.applyNone", json!({})).unwrap(); + let red = app.session.active().unwrap().doc.settings.new_object.fill.clone(); + assert_eq!(app.session.active().unwrap().doc.resolve_color(&red, 1.0).unwrap().to_hex(), "#7c1315"); + // The next rectangle has them. + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let st = app.session.active().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), (red.as_str(), "[None]")); + } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. #[test] fn fill_stroke_proxy_keys() {