Fill and stroke with nothing selected set the colours of new shapes, as InDesign's defaults

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-15 22:32:22 +02:00
co-authored by Claude Opus 5.5
parent a930276fd7
commit 528c7ea489
5 changed files with 218 additions and 13 deletions
+1
View File
@@ -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.
+21
View File
@@ -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(),
}
}
}
+135 -10
View File
@@ -185,8 +185,8 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
[],
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<CommandSpec> {
]
}
/// 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<bool> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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();
+15 -2
View File
@@ -181,8 +181,8 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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"));
+46 -1
View File
@@ -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() {