Artboards: each artboard can have a background color on the canvas

Squashed from #2403 onto main.
This commit is contained in:
bflatastic
2026-10-21 17:11:08 +07:00
parent 9acfd1ea8a
commit dda52d0b20
27 changed files with 527 additions and 35 deletions
+7
View File
@@ -442,6 +442,12 @@ pub struct Artboard {
pub show_center_mark: bool,
#[serde(default)]
pub show_cross_hairs: bool,
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
/// it out.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
@@ -884,6 +890,7 @@ impl Document {
rect: Rect::new(0.0, 0.0, width, height),
show_center_mark: false,
show_cross_hairs: false,
color: None,
}],
layers: vec![],
swatches: swatches.into(),
+1 -1
View File
@@ -136,7 +136,7 @@ impl Builder {
.into_iter()
.enumerate()
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false, color: None })
.collect();
if self.doc.artboards.is_empty() {
return Err(err("the Affinity document has no page with a size"));
+1 -1
View File
@@ -340,7 +340,7 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
/// `d` with one artboard, `rect`.
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false, color: None }];
d
}
+21 -4
View File
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?} (default: right of the last)",
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none)} (default place: right of the last)",
has_doc,
artboard_new
),
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
"{index | artboards: [{index, name?, color?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -803,16 +803,27 @@ fn rect_from(p: &Value, default: Rect) -> Rect {
Rect::new(x, y, x + w, y + h)
}
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
/// null (no color), else `Some(Some(color))`.
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
match p.get("color") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(v) => color_value(v).map(|c| Some(Some(c))).ok_or_else(|| bad(cmd, format!("bad color {v}"))),
}
}
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = &s.doc()?.doc;
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
let r = rect_from(p, def);
let color = artboard_color(p, "artboard.new")?.flatten();
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false, color });
Ok(d.artboards.len() - 1)
})?;
Ok(json!({ "index": idx }))
@@ -841,6 +852,7 @@ struct ArtboardEdit<'a> {
index: usize,
now: Rect,
name: Option<&'a str>,
color: Option<Option<vectorcraft_color::Color>>,
delta: vectorcraft_geom::Vec2,
resized: bool,
fit: Option<Affine>,
@@ -872,6 +884,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("artboard.setProps", "an artboard is listed twice"));
}
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// The background color: a color sets it, null clears it, absent leaves it as it is.
let color = artboard_color(q, "artboard.setProps")?;
let now = rect_from(q, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
let fit = if scale_art && resized { rect_map(was, now) } else { None };
@@ -881,7 +895,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, delta, resized, fit, transform, art });
}
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
// the preference is then.
@@ -900,6 +914,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
}
if let Some(c) = e.color {
a.color = c;
}
let id = a.id;
if let Some(xf) = e.transform {
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
+1
View File
@@ -966,6 +966,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
rect: b,
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
converted.push(id);
}
+1
View File
@@ -340,6 +340,7 @@ impl DocSettings {
rect,
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
d
+10 -1
View File
@@ -128,6 +128,15 @@ pub fn document(s: &Session) -> Value {
document_opts(s, SummaryOpts::default())
}
/// An artboard's name and rectangle, and its background color (`#rrggbb`) when it has one.
fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value {
let mut v = json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()});
if let Some(c) = a.color {
v["color"] = json!(c.to_hex());
}
v
}
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
@@ -245,7 +254,7 @@ fn document_base(s: &Session) -> Value {
"revision": st.revision,
"units": d.units.label(),
"colorMode": format!("{:?}", d.color_mode),
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
"artboards": d.artboards.iter().map(artboard_summary).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
+62 -1
View File
@@ -1,7 +1,7 @@
//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`).
use serde_json::json;
use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_color::{Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, NodeKind};
use super::*;
@@ -94,6 +94,67 @@ fn artboards_reorder_and_duplicate() {
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
}
/// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets
/// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a
/// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an
/// `artboards` list gives each listed artboard its own color in one undo step, and a bad color is
/// refused.
#[test]
fn artboard_color_is_set_and_cleared_in_one_undo_step() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
let color = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].color;
let blue = Color::from_hex("#336699");
let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0));
s.execute("artboard.setProps", &json!({"index": 1, "color": "#336699"})).unwrap();
assert_eq!((color(&s, 0), color(&s, 1)), (None, blue), "only that artboard");
s.execute("artboard.setProps", &json!({"index": 1, "name": "Dark"})).unwrap();
assert_eq!(color(&s, 1), blue, "a change without a color leaves it");
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 100}})).unwrap();
assert_eq!(color(&s, 1), black);
s.execute("artboard.setProps", &json!({"index": 1, "color": null})).unwrap();
assert_eq!(color(&s, 1), None);
for was in [black, blue, blue, None] {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(color(&s, 1), was);
}
s.execute("edit.redo", &json!({})).unwrap();
let doc = crate::inspect::document(&s);
assert_eq!(doc["artboards"][1]["color"], "#336699");
assert!(doc["artboards"][0].get("color").is_none(), "{doc}");
let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize;
assert_eq!(color(&s, copy), blue, "a duplicate keeps the color");
let made = s.execute("artboard.new", &json!({"color": "#336699"})).unwrap()["index"].as_u64().unwrap() as usize;
assert_eq!(color(&s, made), blue, "a new artboard takes `color` too");
let undo = s.doc().unwrap().history.undo.len();
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": "#336699"}, {"index": 1, "color": null}]})).unwrap();
assert_eq!((color(&s, 0), color(&s, 1)), (blue, None), "each listed artboard takes its own color");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "in one undo step");
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": null}, {"index": 1, "color": "nope"}]})).is_err());
assert!(s.execute("artboard.setProps", &json!({"index": 1, "color": "nope"})).is_err());
assert!(s.execute("artboard.new", &json!({"color": "nope"})).is_err());
assert_eq!(*s.doc().unwrap().doc, *before, "a bad color changes nothing");
}
/// The native format writes an artboard's background color and reads it back. An artboard without
/// one is written as before, with no `color` key.
#[test]
fn artboard_color_round_trips_through_the_native_format() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let keys = |i: usize| saved["document"]["artboards"][i].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
assert_eq!(keys(0), ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]);
assert!(keys(1).contains(&"color".to_string()), "{:?}", keys(1));
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!(back.artboards[0].color, None);
assert_eq!(back.artboards[1].color, Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
}
/// The artboards' left edges and names.
fn boards(s: &Session) -> Vec<(f64, String)> {
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
+2 -1
View File
@@ -2313,11 +2313,12 @@ impl<'a> Reader<'a> {
rect: to_doc.transform_rect_bbox(r),
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
if doc.artboards.is_empty() {
let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
doc.artboards = vec![Artboard { id: 1, name: "Artboard 1".into(), rect, show_center_mark: false, show_cross_hairs: false }];
doc.artboards = vec![Artboard { id: 1, name: "Artboard 1".into(), rect, show_center_mark: false, show_cross_hairs: false, color: None }];
}
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
doc.color_mode = ColorMode::Cmyk;
+1
View File
@@ -391,6 +391,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
rect: ab,
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>, shown: bool| {
let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) };
+4
View File
@@ -81,6 +81,7 @@ fn one_page_per_artboard_with_sizes() {
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
d.artboards.push(Artboard {
id: 3,
@@ -88,6 +89,7 @@ fn one_page_per_artboard_with_sizes() {
rect: Rect::new(0.0, 500.0, 612.0, 1292.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
let bytes = export(&d, &PdfOptions::default()).unwrap();
let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap();
@@ -104,6 +106,7 @@ fn artboard_range_and_bad_index() {
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap();
let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap();
@@ -725,6 +728,7 @@ fn import_max_pages_and_multi_page() {
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
add(&mut d, rect_node(Rect::new(310.0, 10.0, 350.0, 50.0), Color::BLACK));
+8 -1
View File
@@ -23,7 +23,14 @@ fn doc(w: f64, h: f64, shapes_: &[(Rect, Paint)]) -> Document {
}
fn artboard(id: u32, x: f64) -> Artboard {
Artboard { id, name: format!("Artboard {id}"), rect: Rect::new(x, 0.0, x + 100.0, 100.0), show_center_mark: false, show_cross_hairs: false }
Artboard {
id,
name: format!("Artboard {id}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
}
}
fn rgb(r: f32, g: f32, b: f32) -> Paint {
+1
View File
@@ -27,6 +27,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document {
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
}
let mut png = vec![];
+1
View File
@@ -334,6 +334,7 @@ fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option<Vec<vectorc
rect: r,
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
(!pages.is_empty()).then_some(pages)
+2
View File
@@ -601,6 +601,7 @@ mod tests {
rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }];
let (s, p) = (Selection::default(), paint());
@@ -913,6 +914,7 @@ mod tests {
rect: Rect::new(150.0, 200.0, 350.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
doc.ruler_origin = Point::new(5.0, 5.0);
let (paint, selection) = (paint(), Selection::default());
+5 -1
View File
@@ -286,6 +286,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
// An artboard with a background color draws its paper in that color. With Simulate Colored Paper
// on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255,
// rounded. The transparency grid, while it shows, covers it as it covers the others.
painter.rect_filled(rect, 0.0, t.pasteboard);
if app.ui.view.artboards && !app.ui.view.outline {
let setup = &doc.setup;
@@ -300,7 +303,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
match &grid {
Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell),
None => {
painter.add(Shape::convex_polygon(q, paper, Stroke::NONE));
let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper);
painter.add(Shape::convex_polygon(q, fill, Stroke::NONE));
}
}
}
+33 -5
View File
@@ -1,13 +1,17 @@
//! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards
//! panel's options icon or menu): the artboard's fields, sizes parsed with units, and its position
//! from a reference point. A new size also goes to the other selected artboards.
//! panel's options icon or menu): the artboard's fields, sizes parsed with units, its position
//! from a reference point, and its background color (`color`: a color as the paint commands take
//! it, or null for none). A new size also goes to the other selected artboards.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::Unit;
use vectorcraft_engine::cmd::color_value;
use vectorcraft_geom::{Point, Rect};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::artboards::{FILLS, fill_colors, fill_name};
use crate::panels::transform::{artboard_rect, reference_index, reference_locator};
use crate::state::Dialog;
@@ -25,6 +29,8 @@ fn placed(d: &Dialog) -> Rect {
/// share, and X and Y are where its point lies on the rulers, counted from the ruler origin (#1212),
/// as the Transform panel and the Control bar show an artboard's. A new Width or Height keeps the
/// reference point where it was, as their sizes do.
/// Then Background Color: the Fill list (None, White, Black; Custom for another color) and a swatch
/// that picks a color of its own.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui));
let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0));
@@ -41,6 +47,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
position_rows(ui, d, refi, unit, origin);
});
ui.add_space(8.0);
let color = d.fields.get("color").and_then(color_value);
let fills = fill_colors(app);
let mut set = None;
ui.horizontal(|ui| {
form::row_label(ui, tl!("Background Color:"));
if let Some(k) = crate::widgets::dropdown(ui, "ab-options-fill", fill_name(app, color), &FILLS, 110.0) {
set = fills.get(k).copied();
}
let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0);
let mut rgb = [r, g, b];
if ui.color_edit_button_srgb(&mut rgb).changed() {
set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0)));
}
});
if let Some(c) = set {
d.fields.insert("color".into(), c.as_ref().map_or(Value::Null, crate::panels::color_json));
}
false
}
@@ -89,7 +113,8 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let a = st.doc.artboards.get(index).ok_or("no artboard")?;
let r = a.rect;
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
let color = a.color.as_ref().map(crate::panels::color_json);
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "color": color});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(json!({ "dialog": KIND }))
}
@@ -147,8 +172,8 @@ pub fn open_new(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(json!({ "dialog": KIND }))
}
/// OK for a new artboard: `artboard.new` with the fields (an empty name gives the default one),
/// and the new artboard becomes the active one.
/// OK for a new artboard: `artboard.new` with the fields (an empty name gives the default one) and
/// the background color picked, and the new artboard becomes the active one.
fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({});
for k in ["x", "y", "width", "height"] {
@@ -158,6 +183,9 @@ fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if !name.trim().is_empty() {
p["name"] = json!(name);
}
if let Some(c) = d.fields.get("color") {
p["color"] = c.clone();
}
let r = run_and_close(app, "artboard.new", p)?;
if let Some(i) = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()) {
crate::panels::artboards::select(app, i);
+155 -10
View File
@@ -4,10 +4,12 @@
//! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
//! Opened for an artboard (Properties › Artboards › Fill), it shows the color on the artboard's
//! paper live, and OK keeps it as one undo step through `artboard.setProps {index, color}`.
//!
//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint
//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly`
//! and `swatches`. `stroke` and `original` come from the opener.
//! and `swatches`. `stroke`, `artboard`, `keep` and `original` come from the opener.
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
@@ -201,18 +203,37 @@ fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color {
}
}
/// `ui.colorPicker {stroke?, color?}`: open the picker for the fill (or stroke) proxy, starting
/// from `color` or the proxy's colour.
/// `ui.colorPicker {stroke?, artboard?, color?}`: open the picker for the fill (or stroke) proxy,
/// or for artboard `artboard`'s background color, starting from `color`, else the proxy's color
/// or the artboard's color (white when it has none).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active);
let color = match p.get("color") {
Some(v) => color_value(v).ok_or_else(|| format!("bad color {v}"))?,
None => proxy_color(app, stroke),
let artboard = match p.get("artboard") {
None | Some(Value::Null) => None,
Some(v) => {
let index = v.as_u64().and_then(|i| usize::try_from(i).ok());
let found = index.and_then(|i| Some((i, app.session.active()?.doc.artboards.get(i)?.color)));
let (i, color) = found.ok_or("no such artboard")?;
Some((i, color.unwrap_or(Color::WHITE)))
}
};
let color = match (p.get("color"), artboard) {
(Some(v), _) => color_value(v).ok_or_else(|| format!("bad color {v}"))?,
(None, Some((_, c))) => c,
(None, None) => proxy_color(app, stroke),
};
let mut d = Dialog::new(
KIND,
json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}),
);
if let Some((i, _)) = artboard {
d.fields.insert("artboard".into(), json!(i));
// OK with the artboard's color (or the white that stands in for none) unchanged keeps the
// artboard as it is.
if p.get("color").is_none() {
d.fields.insert("keep".into(), json!(true));
}
}
store(&mut d, &Picked::of(color));
d.fields.insert(crate::screen_color::PICKER_ID.into(), json!(crate::screen_color::picker_id()));
app.ui.dialog = Some(d);
@@ -382,15 +403,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction {
/// Show the picked colour on the selected art while the picker is open: each new colour replaces
/// the last preview. Nothing previews until the colour first changes, nor without a selection
/// (the colour then only becomes the default paint, on OK).
/// Opened for an artboard, the picker shows the color on that artboard's paper, with or without a
/// selection.
fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
let (cmd, params) = paint_command(app, d);
let first = !app.session.in_interaction();
let unchanged = d.fields.get(PREVIEWED) == Some(&params) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color);
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
let artboard = d.fields.contains_key("artboard");
let selected = artboard || app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
if unchanged || !selected {
return;
}
let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" });
let _ = app.session.begin_interaction(if artboard {
"Artboard Options"
} else if d.bool("stroke") {
"Stroke Color"
} else {
"Fill Color"
});
if let Err(e) = app.session.preview(cmd, &params) {
app.status(e.to_string());
}
@@ -398,13 +428,16 @@ fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
}
/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK
/// document).
/// document). Opened for an artboard, the command sets that artboard's background color.
fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) {
let mut c = picked(d).color;
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
c = Color::cmyk(cy, m, y, k);
}
if let Some(i) = d.fields.get("artboard").and_then(Value::as_u64) {
return ("artboard.setProps", json!({ "index": i, "color": color_json(&c) }));
}
(if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) }))
}
@@ -556,8 +589,14 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color, side: f32) ->
chosen
}
/// OK: apply the colour to the proxy, keeping the live preview as one undo step.
/// OK: apply the colour to the proxy, keeping the live preview as one undo step. For an artboard,
/// OK with the starting color unchanged and nothing previewed closes the picker and leaves the
/// artboard as it was: without a background color, or with its color in its own color model.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.bool("keep") && !app.session.in_interaction() && d.fields.get("original").and_then(color_value) == Some(picked(d).color) {
app.ui.dialog = None;
return Ok(Value::Null);
}
let (cmd, params) = paint_command(app, d);
super::form::commit_preview(app, cmd, params)
}
@@ -700,6 +739,112 @@ mod tests {
assert_eq!(undo(&app), before + 1);
}
/// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's
/// background color (white without one) and shows each color on its paper live, with nothing
/// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the
/// artboard back.
#[test]
fn an_artboard_shows_its_background_color_live() {
let mut app = app();
app.run("select.set", json!({"ids": []})).unwrap();
app.run("artboard.new", json!({})).unwrap();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let art = app.session.doc().unwrap().doc.layers.clone();
let before = undo(&app);
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper");
frame(&mut app);
assert!(!app.session.in_interaction(), "nothing previews before the color changes");
for hex in ["ff0000", "00ff00"] {
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
frame(&mut app);
assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK");
}
super::super::confirm(&mut app).unwrap();
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
assert_eq!(undo(&app), before + 1, "one step");
assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill");
// Cancel: back to the color it had, which the picker starts from.
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00");
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
frame(&mut app);
assert_eq!(color(&app).as_deref(), Some("#0000ff"));
super::super::cancel(&mut app);
assert_eq!(color(&app).as_deref(), Some("#00ff00"));
assert_eq!(undo(&app), before + 1);
assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err());
}
/// OK on a picker opened for an artboard without a background color, with the color left at the
/// white it starts from, leaves the artboard without one and adds no undo step. A white the
/// opener passes as `color` is set.
#[test]
fn an_untouched_picker_leaves_an_artboard_without_a_color() {
let mut app = app();
app.run("artboard.new", json!({})).unwrap();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let before = undo(&app);
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "OK closes the picker");
assert_eq!(color(&app), None, "no color");
assert_eq!(undo(&app), before, "no undo step");
app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap();
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert_eq!(color(&app).as_deref(), Some("#ffffff"));
assert_eq!(undo(&app), before + 1);
}
/// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK
/// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK.
#[test]
fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() {
let mut app = app();
app.run("artboard.new", json!({})).unwrap();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color;
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let start = color(&app);
assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB");
let before = undo(&app);
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "OK closes the picker");
assert_eq!(color(&app), start, "the RGB color is kept");
assert_eq!(undo(&app), before, "no undo step");
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00"));
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK");
assert_eq!(undo(&app), before + 1);
}
/// OK on an untouched picker opened for an artboard with a background color in an RGB
/// document keeps the color and adds no undo step.
#[test]
fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() {
let mut app = app();
app.run("artboard.new", json!({})).unwrap();
app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex());
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let before = undo(&app);
app.run("ui.colorPicker", json!({"artboard": 1})).unwrap();
frame(&mut app);
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "OK closes the picker");
assert_eq!(color(&app).as_deref(), Some("#ff0000"));
assert_eq!(undo(&app), before, "no undo step");
}
#[test]
fn open_set_hex_and_confirm_fills() {
let mut app = app();
+13 -4
View File
@@ -79,6 +79,14 @@ fn suffix(kind: &str, key: &str) -> Option<&'static str> {
}
}
/// The fields of the form dialogs ([`grid`]) that the dialog's own body draws, by dialog kind.
fn own_rows(kind: &str) -> &'static [&'static str] {
match kind {
"artboardOptions" => &["color"],
_ => &[],
}
}
/// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by
/// name).
fn order(kind: &str) -> &'static [&'static str] {
@@ -199,9 +207,9 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: &
}
}
/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys
/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the
/// [`skew_ends`] fields as sliders.
/// The generic dialog body: a field per non-boolean value (positions, indices, UI-only keys and the
/// kind's [`own_rows`] hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as
/// dropdowns and the [`skew_ends`] fields as sliders.
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
grid_then(ui, d, unit, |_, _| {});
}
@@ -210,11 +218,12 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
/// with the generic ones.
pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: impl FnOnce(&mut egui::Ui, &mut Dialog)) {
let rail = Tokens::get(ui.ctx()).input_border;
let (lengths, first) = (lengths(&d.kind), order(&d.kind));
let (lengths, first, own) = (lengths(&d.kind), order(&d.kind), own_rows(&d.kind));
let mut keys: Vec<String> = d
.fields
.iter()
.filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean())
.filter(|(k, _)| !own.contains(&k.as_str()))
.map(|(k, _)| k.clone())
.collect();
// Stable: the fields `order` doesn't name keep their order after the ones it does.
+44
View File
@@ -1040,6 +1040,50 @@ fn artboard_options_position_follows_the_tool_option_with_another_tool() {
}
}
/// Artboard Options shows the artboard's background color on its Background Color: row (None,
/// White, Black, or Custom for another color) and not as a text field. OK keeps the color while it
/// applies another field, and a null `color` clears it.
#[test]
fn artboard_options_shows_keeps_and_clears_the_background_color() {
let mut app = app();
let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[0].color.map(|c| c.to_hex());
let lines = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx()));
text.lines().map(str::trim).map(str::to_string).collect::<Vec<_>>()
};
crate::dialogs::artboard_options::open(&mut app).unwrap();
let shown = lines(&mut app);
assert!(shown.iter().any(|l| l == "Background Color:") && shown.iter().any(|l| l == "None"), "{shown:?}");
app.ui.dialog = None;
app.run("artboard.setProps", json!({"index": 0, "color": "#336699"})).unwrap();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let shown = lines(&mut app);
assert!(shown.iter().any(|l| l == "Custom") && !shown.iter().any(|l| l == "Color:" || l.starts_with('[')), "{shown:?}");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Card"));
super::confirm(&mut app).unwrap();
assert_eq!((app.session.doc().unwrap().doc.artboards[0].name.as_str(), color(&app).as_deref()), ("Card", Some("#336699")));
crate::dialogs::artboard_options::open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(null));
super::confirm(&mut app).unwrap();
assert_eq!(color(&app), None);
}
/// Artboard Options for a new artboard (`ui.newArtboard`) gives the new artboard the background
/// color picked on its Background Color: row, and none when nothing was picked.
#[test]
fn new_artboard_options_give_the_new_artboard_its_background_color() {
let mut app = app();
for (picked, want) in [(Some(json!("#336699")), Some("#336699")), (None, None)] {
app.run("ui.newArtboard", json!({})).unwrap();
if let Some(c) = picked {
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), c);
}
super::confirm(&mut app).unwrap();
let ab = app.session.doc().unwrap().doc.artboards.last().cloned().unwrap();
assert_eq!(ab.color.map(|c| c.to_hex()).as_deref(), want);
}
}
/// #1482: with Artboards 1 and 3 selected with the Artboard tool, a new Width in Artboard Options
/// goes to both, each keeping its top-left corner and its height, in one undo step. The new name
/// goes only to the artboard the dialog shows, and Artboard 2 keeps its size. A name alone resizes
+1 -1
View File
@@ -454,7 +454,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.colorPicker",
"Color Picker…",
"",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
"{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard",
),
(
"ui.sampleScreenColor",
+20
View File
@@ -10,6 +10,8 @@
use egui::{Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::ColorMode;
use vectorcraft_geom::Rect;
use super::{alt_held, pstate, set_pstate};
@@ -131,6 +133,24 @@ pub(crate) fn move_art(app: &VectorcraftApp) -> bool {
app.session.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true)
}
/// The Fill list of an artboard (Properties › Artboards › Fill, Artboard Options): no background
/// color, White and Black.
pub(crate) const FILLS: [&str; 3] = ["None", "White", "Black"];
/// The background colors [`FILLS`] stand for, White and Black in the document's color mode.
pub(crate) fn fill_colors(app: &VectorcraftApp) -> [Option<Color>; 3] {
if app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk) {
[None, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0)), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))]
} else {
[None, Some(Color::WHITE), Some(Color::BLACK)]
}
}
/// The name the Fill list shows for background `color`: one of [`FILLS`], else Custom.
pub(crate) fn fill_name(app: &VectorcraftApp, color: Option<Color>) -> &'static str {
fill_colors(app).iter().position(|c| *c == color).map_or("Custom", |i| FILLS[i])
}
/// The active artboard (of `n`).
pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
+86 -1
View File
@@ -2,6 +2,7 @@
use egui::Ui;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
use vectorcraft_engine::cmd::newdoc;
@@ -203,7 +204,7 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
/// Edit Artboards (the Artboard tool): the active artboard's name, which can be edited to rename
/// it, and the Artboard Options icon; its position and size, its preset and
/// orientation (#671) (with Scale Artwork with Artboard on, a new size scales its art, #602; a new
/// W or H, preset or orientation goes to every selected artboard, in one undo step), Move
/// W or H, preset or orientation goes to every selected artboard, in one undo step), its Fill (background color), Move
/// and Scale Artwork with Artboard, New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
/// Exit back to the Selection tool (#530).
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -252,6 +253,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
artboard_size_row(app, ui, i, ab.rect.width(), ab.rect.height());
artboard_fill_row(app, ui, i, ab.color);
ui.add_space(2.0);
super::artboards::art_options(app, ui);
}
@@ -350,6 +352,37 @@ pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize,
.ok();
}
/// Artboard `i`'s Fill, its background `color`: the chip shows it (the None paint when the artboard
/// has none) and a click on it opens the Color Picker for the artboard. The list sets None, White or
/// Black (in the document's color mode) or opens the Color Picker.
fn artboard_fill_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, color: Option<Color>) {
let fills = super::artboards::fill_colors(app);
let shown = super::artboards::fill_name(app, color);
let mut options = super::artboards::FILLS.to_vec();
options.push("Color Picker…");
let mut set = None;
let mut pick = false;
ui.horizontal(|ui| {
dim_label(ui, tl!("Fill:"));
let paint = color.map_or(Paint::None, Paint::solid);
let chip = widgets::chip_button(ui, 22.0, false, |ui, r| {
widgets::proxy_chip(ui, r, &paint, false, None);
});
pick = chip.on_hover_text(tl!("Color Picker…")).clicked();
match widgets::dropdown(ui, "ab-fill", shown, &options, 110.0) {
Some(k) if k < fills.len() => set = Some(fills[k]),
Some(_) => pick = true,
None => {}
}
});
if let Some(c) = set {
app.run("artboard.setProps", json!({"index": i, "color": c.as_ref().map(super::color_json)})).ok();
}
if pick {
app.run("ui.colorPicker", json!({"artboard": i})).ok();
}
}
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
section_header(ui, tl!("Document"));
@@ -1019,6 +1052,58 @@ mod tests {
assert_eq!(app.session.tool_id(), "artboard");
}
/// Edit Artboards' Fill (Properties › Artboards › Fill): the list sets the active artboard's
/// background color to Black and back to None, one command and one undo step each, and shows the
/// choice. Color Picker… in the list and a click on the chip open the Color Picker for that
/// artboard.
#[test]
fn edit_artboards_fill_sets_the_background_color() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
app.select_tool("artboard");
super::super::artboards::select(&mut app, 1);
let colors = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.iter().map(|a| a.color).collect::<Vec<_>>();
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let ctx = egui::Context::default();
// Open the list showing `shown` and choose `option` → the number of commands run.
let choose = |app: &mut VectorcraftApp, shown: &str, option: &str| {
frame(app, &ctx, vec![]);
let texts = frame(app, &ctx, vec![]);
click(app, &ctx, &texts, shown);
let texts = frame(app, &ctx, vec![]);
let commands = app.run_count;
click(app, &ctx, &texts, option);
app.run_count.wrapping_sub(commands)
};
let before = undo(&app);
assert_eq!(choose(&mut app, "None", "Black"), 1, "one command");
assert_eq!(colors(&app), [None, Some(Color::BLACK)]);
assert_eq!(undo(&app), before + 1, "one undo step");
assert_eq!(choose(&mut app, "Black", "None"), 1);
assert_eq!(colors(&app), [None, None]);
choose(&mut app, "None", "Color Picker…");
let d = app.ui.dialog.take().expect("the Color Picker");
assert_eq!((d.kind.as_str(), d.fields.get("artboard")), ("colorPicker", Some(&json!(1))));
// The chip is the clickable widget left of the list on the Fill: row.
let texts = frame(&mut app, &ctx, vec![]);
let row = texts.iter().find(|(t, _)| t == "Fill:").map(|(_, r)| *r).unwrap();
let chip = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.min_by(|a, b| a.left().total_cmp(&b.left()))
.unwrap()
.center()
});
let press = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(chip), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields.get("artboard")), Some(Some(&json!(1))), "the chip opens it too");
}
fn artboard_deletion_app(count: usize) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": count})).unwrap();
+32
View File
@@ -94,6 +94,38 @@ fn the_grid_takes_the_document_colours_also_rotated() {
assert!((m.vertices[1].uv.x - 200.0 * zoom / 32.0).abs() < 1e-3, "{:?}", m.vertices[1].uv);
}
/// An artboard with a background color draws its paper in that color, and with Simulate Colored
/// Paper on in that color multiplied with the paper color; an artboard without one keeps white or
/// the paper color. The transparency grid, while it shows, covers both.
#[test]
fn an_artboard_color_draws_its_paper_in_that_color() {
let mut app = app();
app.run("artboard.new", json!({})).unwrap();
app.run("artboard.setProps", json!({"index": 1, "color": "#336699"})).unwrap();
// The fill colors of the closed shapes drawn.
let fills = |app: &mut VectorcraftApp| {
let mut all = vec![];
flatten(&frame(app).0, &mut all);
all.into_iter()
.filter_map(|s| match s {
egui::Shape::Path(p) if p.closed => Some(p.fill),
_ => None,
})
.collect::<Vec<_>>()
};
let blue = egui::Color32::from_rgb(0x33, 0x66, 0x99);
let f = fills(&mut app);
assert!(f.contains(&blue) && f.contains(&egui::Color32::WHITE), "{f:?}");
app.run("document.setup", json!({"simulatePaper": true, "gridColors": ["#ffeecc", "#cccccc"]})).unwrap();
// #336699 times #ffeecc, channel by channel: 0x33 × 0xff / 255, 0x66 × 0xee / 255, 0x99 × 0xcc / 255.
let inked = egui::Color32::from_rgb(51, 95, 122);
let f = fills(&mut app);
assert!(f.contains(&inked) && !f.contains(&blue) && f.contains(&egui::Color32::from_rgb(0xff, 0xee, 0xcc)), "{f:?}");
app.run("view.transparencyGrid", json!({"on": true})).unwrap();
assert!(frame(&mut app).2.is_some(), "the grid shows");
assert!(!fills(&mut app).contains(&inked), "the grid covers the colored artboard too");
}
#[test]
fn white_background_hides_the_grid_and_paper_tints_the_artboard() {
let mut app = app();
+3 -1
View File
@@ -239,6 +239,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G13 Illustrator 2026 (30.x) additions
- **Done:** TIFF choices and format settings in Export for Screens and Asset Export (30.2), using the existing encoder; UI-to-file regressions cover selection, scaling, options and persistence.
- **Done:** artboard background colors on the canvas (30.0): Properties › Artboards › Fill and Artboard Options set
each artboard's color, and the canvas draws its paper in it. Exports and print do not draw the color yet.
- **Done:** Object › Intertwine (#1763): Make, Edit and Release; the Intertwine tool highlights the overlap area under
the pointer, a click swaps which object is on top there, and a path (Shift: a rectangle) drawn around overlaps
@@ -252,7 +254,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's
Gradients option, which traces linear ones only (draft #1849). Inside the estimate below.
- **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular
snapping and the consolidated snapping options (30.0); artboard locking, artboard colours and managing
snapping and the consolidated snapping options (30.0); artboard locking, artboard colors in exports and print, and managing
artboards from the canvas (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7);
the Dimension tool (28.x).
- **Evidence:** Adobe's 30.0 and 30.7 release announcements, see
+11 -2
View File
@@ -350,7 +350,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|---|---|---|
| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. |
| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. |
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
| `inspect_ui` | `{}` | UI state. Remote mode only. |
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. |
@@ -2738,7 +2738,7 @@ value: true}`; the Control bar's and Properties' Scale Artwork with Artboard che
Artboard) makes its handle drags proportional and passes `scaleArt`, as do Properties' and the Transform panel's
artboard size fields while the tool is in use.
`artboard.setProps {artboards: [{index, name?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several
`artboard.setProps {artboards: [{index, name?, color?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several
artboards in one undo step, each with its own fields; the other params apply to all of them. The art that moves or
scales with each artboard is the art inside it before any of them changes, and an object inside several of them goes
with the first one listed that moves or scales its art. An empty list and a list that names an artboard twice are
@@ -2758,6 +2758,15 @@ artboard only, and Artboard Options' Name renames only the artboard it shows.
{"name":"run_command","arguments":{"command":"artboard.setProps","params":{"artboards":[{"index":0,"width":300},{"index":2,"width":300}],"scaleArt":true}}}
```
An artboard's background color (Properties › Artboards › Fill, Artboard Options' Background Color): `artboard.setProps
{index, color}` sets it, with a color as the paint commands take it (`"#rrggbb"`, `{c, m, y, k}`, …), and
`artboard.setProps {index, color: null}` removes it, each in one undo step; `artboard.new {color}` makes an artboard
with one. The canvas draws that artboard's paper in
the color. With Simulate Colored Paper on, it multiplies the color with the paper color, each sRGB channel as
artboard × paper / 255; artboards without a color show the paper color. The transparency grid still covers it while
it shows. Copies, duplicates and the native format keep the color; exports and print do not draw it. `inspect_document`
lists it as the artboard's `color` (`#rrggbb`), and `ui.colorPicker {artboard: index}` opens the Color Picker for it.
```json
{"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}}
{"name":"run_command","arguments":{"command":"edit.paste","params":{}}}
+1
View File
@@ -435,6 +435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests_panelcmds.rs`, `tests_brushsym.rs` and `tests_pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies).
- **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests_pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy).
- **Edit Artboards in Properties:** the active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html).
- **Artboard background colors:** each artboard can have a background color (`artboard.setProps {index, color}`, `color: null` removes it). With the Artboard tool, Properties has a Fill row: its list sets None, White or Black, and its chip and its Color Picker… item open the Color Picker for the artboard. Artboard Options has a Background Color row with a list that sets None, White or Black, and a color button. The canvas draws the artboard's paper in the color, multiplied with the paper color while Simulate Colored Paper is on, and the transparency grid still covers it. The native format saves the color, and a document without colors saves as before. Exports and print do not draw the color. Engine, canvas, Properties, Color Picker and Artboard Options tests cover it. Public reference: [Apply colors to artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/add-background-color-to-artboards.html).
- **Resizing several artboards:** with several artboards selected with the Artboard tool, a new W or H in Properties, the Transform panel or the Control bar, a preset or an orientation in Properties or the Control bar, and a new Width or Height in Artboard Options resize every selected artboard in one undo step (`artboard.setProps {artboards}`); with Scale Artwork with Artboard on, Properties, the Transform panel and the Control bar scale each object once, with the artboard it was inside before the change. The Transform panel, the Control bar and Artboard Options resize each one about its own reference point, a preset sizes each one in its own orientation, and X and Y still move the active artboard alone. Pointer and key tests in `panels/properties.rs`, dialog tests in `dialogs/tests.rs` and an engine test in `tests_xform.rs` cover them. Public references: [Artboard Enhancements In Illustrator CC](https://blog.adobe.com/en/publish/2017/11/10/artboard-enhancements-in-illustrator-cc) ("modify them all in bulk by defining the height and width values") and [Using multiple artboards](https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (2019).
- **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests_distortkeys.rs`.
- **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) ("Create artboard inside another artboard") and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape.