mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a930276fd7
commit
528c7ea489
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user