Add shared presets and artwork fitting to Artboard Options

Squashed from #3277 onto main.
This commit is contained in:
bflatastic
2026-10-22 10:40:37 +07:00
parent 56b7dc300d
commit 438c6194a6
7 changed files with 651 additions and 22 deletions
+96 -6
View File
@@ -1,7 +1,8 @@
//! 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, 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.
//! it, or null for none). Sizing choices also go to the other selected artboards; fitting
//! changes only the active artboard around stationary artwork.
use serde_json::{Value, json};
use vectorcraft_color::Color;
@@ -12,6 +13,7 @@ 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::properties::ArtboardSizeChoice;
use crate::panels::transform::{artboard_rect, reference_index, reference_locator};
use crate::state::Dialog;
@@ -34,8 +36,24 @@ fn placed(d: &Dialog) -> Rect {
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));
let bounds = [fit_bounds(app, ui.ctx(), false), fit_bounds(app, ui.ctx(), true)];
let (choice, fit) = crate::panels::properties::artboard_size_controls_with_fit(app, ui, w0, h0, bounds.map(|b| b.is_some()));
let picked = choice.is_some();
if let Some(choice) = choice {
if let Some((w, h)) = choice.size_for(w0, h0) {
set_rect(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false));
}
remember_size_choice(d, choice);
d.fields.insert("__refx".into(), json!(refi % 3));
d.fields.insert("__refy".into(), json!(refi / 3));
d.fields.remove("__fit");
}
ui.add_space(8.0);
form::grid_then(ui, d, unit, |ui, d| {
let (w, h) = (d.f64("width", 0.0), d.f64("height", 0.0));
if !picked && (w != w0 || h != h0) {
d.fields.remove("__sizeChoice");
}
let n = artboard_rect(was, refi, None, None, Some(w), Some(h), false);
// Only the side that changed moves, so an untouched coordinate stays exactly as it was. The
// reference point's column or row is kept for the other selected artboards (`confirm`).
@@ -47,6 +65,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
position_rows(ui, d, refi, unit, origin);
});
// Apply a Fit after the ordinary size/reference-point grid: that grid must not replace
// the chosen artwork's position with one derived from the previous artboard rectangle.
if let Some(selection) = fit
&& let Some(bounds) = bounds[usize::from(selection)]
{
set_rect(d, bounds);
d.fields.insert("__fit".into(), json!(true));
d.fields.remove("__sizeChoice");
}
ui.add_space(8.0);
let color = d.fields.get("color").and_then(color_value);
let fills = fill_colors(app);
@@ -68,6 +95,59 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// Store document-coordinate draft bounds, retaining an editable minimum for zero-area art.
fn set_rect(d: &mut Dialog, r: Rect) {
for (key, value) in [("x", r.x0), ("y", r.y0), ("width", r.width().max(1.0)), ("height", r.height().max(1.0))] {
d.fields.insert(key.into(), json!(value));
}
}
/// Preserve preset/orientation intent separately from typed dimensions for the selected set.
fn remember_size_choice(d: &mut Dialog, choice: ArtboardSizeChoice) {
let value = match choice {
ArtboardSizeChoice::Preset(w, h) => json!({"preset": [w, h]}),
ArtboardSizeChoice::Orientation(landscape) => json!({"landscape": landscape}),
};
d.fields.insert("__sizeChoice".into(), value);
}
/// Validate stored sizing intent before deriving the other selected artboards' dimensions.
fn size_choice(d: &Dialog) -> Option<ArtboardSizeChoice> {
let value = d.fields.get("__sizeChoice")?;
if let Some(landscape) = value.get("landscape").and_then(Value::as_bool) {
return Some(ArtboardSizeChoice::Orientation(landscape));
}
let preset = value.get("preset")?.as_array()?;
let (w, h) = (preset.first()?.as_f64()?, preset.get(1)?.as_f64()?);
(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0).then_some(ArtboardSizeChoice::Preset(w, h))
}
#[derive(Clone)]
struct ArtworkFitBounds {
key: (u64, u64),
bounds: Option<Rect>,
}
/// Artwork fitting includes all drawn artwork, even outside every artboard; selection uses
/// the shared selection cache. Draft/target geometry never restricts membership. Global artwork
/// bounds are cached only for the active document and its revision.
fn fit_bounds(app: &mut VectorcraftApp, ctx: &egui::Context, selection: bool) -> Option<Rect> {
if selection {
return app.selection_bounds();
}
let st = app.session.active()?;
let key = (st.uid, st.revision);
let id = egui::Id::new("artboard-options-artwork-fit-bounds");
if let Some(cached) = ctx.data_mut(|data| data.get_temp::<ArtworkFitBounds>(id))
&& cached.key == key
{
return cached.bounds;
}
let bounds = vectorcraft_render::encode::art_bounds(&st.doc);
ctx.data_mut(|data| data.insert_temp(id, ArtworkFitBounds { key, bounds }));
bounds
}
/// The X and Y rows: where reference point `refi` lies, counted from the ruler origin `origin`,
/// with the locator before X's label. A typed X or Y moves the artboard so that its reference point
/// lands there.
@@ -119,10 +199,12 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(json!({ "dialog": KIND }))
}
/// OK sets the artboard's fields. A Width or Height that differs from the artboard's also goes to
/// the other selected artboards (when this one is among them), each resized about the reference
/// OK sets the artboard's fields. A preset or orientation choice applies separately to each
/// selected artboard; a typed Width or Height that differs also goes to the others (when this
/// one is among them), each resized about the reference
/// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and
/// row; the top-left corner when the form set none), all in one undo step.
/// row; the top-left corner when the form set none), all in one undo step. Fitting applies only
/// to this artboard and never moves its artwork.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.bool("new") {
return confirm_new(app, d);
@@ -142,17 +224,25 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (w, h) = (changed("width", was.map(|r| r.width())), changed("height", was.map(|r| r.height())));
let part = |k: &str| d.fields.get(k).and_then(Value::as_u64).map_or(0, |v| v.min(2) as usize);
let refi = part("__refy") * 3 + part("__refx");
let choice = size_choice(d);
let mut entries = vec![];
if w.is_some() || h.is_some() {
if !d.bool("__fit") && (choice.is_some() || w.is_some() || h.is_some()) {
for i in crate::panels::artboards::resized_with(app, index).into_iter().filter(|i| *i != index) {
if let Some(r) = app.session.active().and_then(|st| st.doc.artboards.get(i)).map(|a| a.rect) {
let (w, h) = if let Some(choice) = choice {
let Some((w, h)) = choice.size_for(r.width(), r.height()) else { continue };
(Some(w), Some(h))
} else {
(w, h)
};
let n = artboard_rect(r, refi, None, None, w, h, false);
entries.push(json!({"index": i, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()}));
}
}
}
entries.insert(0, p);
let common = json!({"moveArt": crate::panels::artboards::move_art(app)});
// Fitting changes the artboard around existing art; it must never move that art.
let common = json!({"moveArt": !d.bool("__fit") && crate::panels::artboards::move_art(app)});
let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?;
run_and_close(app, "artboard.setProps", p)
}
+397
View File
@@ -1792,3 +1792,400 @@ fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
assert_eq!(app.session.tool_options_of("polarGrid"), options);
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// Open the real Preset menu before choosing a Fit; these actions are no longer body buttons.
fn artboard_fit_preset_at(frames: &mut Frames, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
let texts = frames.texts(app);
assert!(texts.iter().all(|(t, _)| !matches!(t.as_str(), "Fit to Artwork Bounds" | "Fit to Selected Art")), "Fit lives only in Preset");
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let combo = frames.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, widgets)| widgets.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()
});
frames.press(app, combo, true);
frames.press(app, combo, false);
frames.texts(app).into_iter().find(|(text, _)| text.trim() == label).unwrap().1.center()
}
#[test]
fn artboard_options_fit_drafts_cancel_and_apply_without_moving_art() {
for selected in [false, true] {
let mut app = app();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let label = if selected { "Fit to Selected Art" } else { "Fit to Artwork Bounds" };
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "draft only");
if !apply {
cancel(&mut app);
continue;
}
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, before.layers, "art stays in place");
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
#[test]
fn artboard_options_shared_preset_drafts_and_resizes_selected_boards_in_their_orientations() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":100,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":150,"height":300})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = |texts: &[(String, egui::Rect)], label: &str| {
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
};
let custom = at(&frames.texts(&mut app), "Custom");
frames.press(&mut app, custom, true);
frames.press(&mut app, custom, false);
let print = at(&frames.texts(&mut app), "Print");
frames.run(&mut app, vec![egui::Event::PointerMoved(print)]);
let letter = at(&frames.texts(&mut app), "Letter");
frames.press(&mut app, letter, true);
frames.press(&mut app, letter, false);
assert_eq!(app.session.doc().unwrap().doc, before, "preset is a draft until OK");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (792.0, 612.0));
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
continue;
}
let steps = app.session.doc().unwrap().history.undo.len();
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (792.0, 612.0));
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (612.0, 792.0));
assert_eq!(doc.artboards[1], before.artboards[1], "unselected board stays unchanged");
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
#[test]
fn artboard_options_orientation_applies_to_other_selected_boards_when_active_is_already_portrait() {
let mut app = app();
app.run("file.new", json!({"width":100,"height":200,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":300,"height":150})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let texts = frames.texts(&mut app);
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let mut buttons: Vec<egui::Rect> = frames.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)
.collect()
});
buttons.sort_by(|a, b| a.left().total_cmp(&b.left()));
let portrait = buttons[buttons.len() - 2].center();
frames.press(&mut app, portrait, true);
frames.press(&mut app, portrait, false);
assert_eq!(app.session.doc().unwrap().doc, before, "orientation remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0], before.artboards[0], "active board was already portrait");
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (150.0, 300.0));
assert_eq!(doc.artboards[1], before.artboards[1]);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_changes_only_active_board_even_with_an_artboard_multiselection() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.session.doc().unwrap().doc, before, "fit remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
assert_eq!(&doc.artboards[1..], &before.artboards[1..]);
assert_eq!(doc.layers, before.layers, "fitting never moves artwork");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_artwork_ignores_draft_membership_and_invalidates_on_edits() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
artboard_options::open(&mut app).unwrap();
let dialog = app.ui.dialog.as_mut().unwrap();
dialog.fields.insert("width".into(), json!(1));
dialog.fields.insert("height".into(), json!(1));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 80.0, 60.0),
"Fit queries all artwork, not the shrunken draft"
);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
// A remote command can edit the document while the modal remains open.
app.run("shape.rectangle", json!({"x":600,"y":120,"width":40,"height":30})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let layers = app.session.doc().unwrap().doc.layers.clone();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 610.0, 110.0),
"A document revision must invalidate cached fit bounds"
);
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
#[test]
fn artboard_options_fit_artwork_includes_all_boards_and_is_independent_of_target() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320,"artboards":2})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
for (index, width, height) in [(0, 80.0, 60.0), (1, 40.0, 30.0)] {
let rect = boards[index].rect;
app.run("shape.rectangle", json!({"x":rect.x0+30.0,"y":rect.y0+40.0,"width":width,"height":height})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
let layers = app.session.doc().unwrap().doc.layers.clone();
let expected =
vectorcraft_geom::Rect::new(boards[0].rect.x0 + 30.0, boards[0].rect.y0 + 40.0, boards[0].rect.x0 + 110.0, boards[0].rect.y0 + 100.0).union(
vectorcraft_geom::Rect::new(boards[1].rect.x0 + 30.0, boards[1].rect.y0 + 40.0, boards[1].rect.x0 + 70.0, boards[1].rect.y0 + 70.0),
);
app.select_tool("artboard");
let mut frames = Frames::new();
for index in [0, 1, 0] {
crate::panels::artboards::select(&mut app, index);
artboard_options::open(&mut app).unwrap();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(expected.x0, expected.y0, expected.width(), expected.height())
);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
}
}
#[test]
fn artboard_options_fit_artwork_new_board_includes_art_outside_its_draft() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":450,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":170,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
let layers = app.session.doc().unwrap().doc.layers.clone();
app.run("ui.newArtboard", json!({})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().bool("new"));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!((dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), (30.0, 40.0, 500.0, 150.0));
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.artboards.clone();
assert_eq!(after.len(), 2);
assert_eq!(after[0], boards[0]);
assert_eq!(after[1].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 530.0, 190.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, after);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
/// Fit and background color share one draft and one undo step for existing and new boards.
#[test]
fn artboard_options_fit_and_background_color_coexist_through_cancel_undo_and_redo() {
for new in [false, true] {
for apply in [false, true] {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("artboard.setProps", json!({"index":0,"color":"#336699"})).unwrap();
let x = if new { 450.0 } else { 30.0 };
app.run("shape.rectangle", json!({"x":x,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let steps = app.session.doc().unwrap().history.undo.len();
if new {
app.run("ui.newArtboard", json!({})).unwrap();
} else {
artboard_options::open(&mut app).unwrap();
}
let labels = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(labels.contains("Background Color:") && labels.contains("Preset:") && !labels.contains("Fit to Artwork Bounds"), "{labels}");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#c05030"));
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["color"], json!("#c05030"));
assert_eq!(app.session.doc().unwrap().doc, before, "both changes remain a draft");
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps);
continue;
}
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
let board = &after.artboards[usize::from(new)];
assert_eq!(board.rect, vectorcraft_geom::Rect::new(x, 40.0, x + 80.0, 100.0));
assert_eq!(board.color.map(|c| c.to_hex()).as_deref(), Some("#c05030"));
assert_eq!(after.layers, before.layers, "fitting never moves the art");
if new {
assert_eq!(after.artboards[0], before.artboards[0]);
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
}
}
/// Moving only the board, with no resize, must not translate its art even when Move Artwork is on.
#[test]
fn artboard_options_position_only_fit_keeps_art_with_move_art_enabled() {
let mut app = app();
app.run("file.new", json!({"width":80,"height":60})).unwrap();
app.run("shape.rectangle", json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":70,"y":50,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("tool.setOption", json!({"tool":"artboard", "values":{"moveArt":true}})).unwrap();
assert!(crate::panels::artboards::move_art(&app));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 10.0, 90.0, 70.0));
assert_eq!(after.layers, before.layers, "position-only fitting never moves art");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
#[test]
fn artboard_options_fit_preset_disabled_without_art_or_selection() {
for has_art in [false, true] {
for label in ["Fit to Artwork Bounds", "Fit to Selected Art"] {
if has_art && label == "Fit to Artwork Bounds" {
continue;
}
let mut app = app();
if has_art {
app.run("shape.rectangle", json!({"x":600,"y":40,"width":80,"height":60})).unwrap();
app.run("select.none", json!({})).unwrap();
}
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let draft = app.ui.dialog.as_ref().unwrap();
assert_ne!(draft.fields.get("__fit"), Some(&json!(true)), "disabled {label} must not become a fit draft");
assert_eq!(app.session.doc().unwrap().doc, before);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
#[test]
fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
for (x, y, w, h) in [(30, 40, 80, 60), (1000, 500, 50, 40)] {
app.run("shape.rectangle", json!({"x":x,"y":y,"width":w,"height":h})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Selected Art");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "selection fit is a draft");
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 1050.0, 540.0));
assert_eq!(after.artboards[1..], before.artboards[1..]);
assert_eq!(after.layers, before.layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
+62 -15
View File
@@ -323,6 +323,58 @@ fn turned(landscape: bool, pw: f64, ph: f64) -> (f64, f64) {
/// width and height of each selected artboard that is not square and not already in it. Each
/// top-left corner stays put.
pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h: f64) {
let Some(choice) = artboard_size_controls(app, ui, w, h) else { return };
let scale_art = super::artboards::scale_art(app);
let indices = super::artboards::resized_with(app, i);
super::artboards::set_props(app, &indices, json!({"scaleArt": scale_art}), |j, r| {
let (width, height) = choice.size_for(r.width(), r.height())?;
Some(json!({"index": j, "width": width, "height": height}))
})
.ok();
}
/// A shared picker choice, applied separately to each artboard's orientation.
#[derive(Clone, Copy)]
pub(crate) enum ArtboardSizeChoice {
Preset(f64, f64),
Orientation(bool),
}
impl ArtboardSizeChoice {
pub(crate) fn size_for(self, w: f64, h: f64) -> Option<(f64, f64)> {
match self {
Self::Preset(pw, ph) => Some(turned(w > h, pw, ph)),
Self::Orientation(landscape) if (w > h) != landscape && w != h => Some((h, w)),
Self::Orientation(_) => None,
}
}
}
/// Shared existing presets/orientation UI. Dialogs keep the chosen size in their draft until OK.
pub(crate) fn artboard_size_controls(app: &VectorcraftApp, ui: &mut Ui, w: f64, h: f64) -> Option<ArtboardSizeChoice> {
artboard_size_controls_inner(app, ui, w, h, None).0
}
/// The dialog's shared preset/orientation picker with optional artwork and selection fits.
/// The second return value is a chosen Fit: false for all artwork, true for selected artwork.
pub(crate) fn artboard_size_controls_with_fit(
app: &VectorcraftApp,
ui: &mut Ui,
w: f64,
h: f64,
enabled: [bool; 2],
) -> (Option<ArtboardSizeChoice>, Option<bool>) {
artboard_size_controls_inner(app, ui, w, h, Some(enabled))
}
/// Properties and Control keep their ordinary picker; Artboard Options adds two Fit entries.
fn artboard_size_controls_inner(
app: &VectorcraftApp,
ui: &mut Ui,
w: f64,
h: f64,
fits: Option<[bool; 2]>,
) -> (Option<ArtboardSizeChoice>, Option<bool>) {
let landscape = w > h;
let same = |s: &newdoc::DocSettings| {
let (pw, ph) = turned(landscape, s.width, s.height);
@@ -338,10 +390,19 @@ pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize,
// The preset's width and height as listed, or the orientation clicked.
let mut preset = None;
let mut orientation = None;
let mut fit = None;
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
preset = widgets::combo(ui, "ab-preset", &current, 140.0, false, |ui| {
let mut chosen = None;
if let Some(enabled) = fits {
for (selection, label) in [(false, tl!("Fit to Artwork Bounds")), (true, tl!("Fit to Selected Art"))] {
if widgets::menu_item_name(ui, label, enabled[usize::from(selection)], false) {
fit = Some(selection);
}
}
ui.separator();
}
for (cat, list) in &cats {
ui.menu_button(tl!(cat), |ui| {
widgets::menu_scroll(ui, |ui| {
@@ -362,21 +423,7 @@ pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize,
}
}
});
if preset.is_none() && orientation.is_none() {
return;
}
let scale_art = super::artboards::scale_art(app);
let indices = super::artboards::resized_with(app, i);
super::artboards::set_props(app, &indices, json!({"scaleArt": scale_art}), |j, r| {
let (w, h) = (r.width(), r.height());
let (width, height) = match (preset, orientation) {
(Some((pw, ph)), _) => turned(w > h, pw, ph),
(None, Some(l)) if (w > h) != l && w != h => (h, w),
_ => return None,
};
Some(json!({"index": j, "width": width, "height": height}))
})
.ok();
(preset.map(|(w, h)| ArtboardSizeChoice::Preset(w, h)).or_else(|| orientation.map(ArtboardSizeChoice::Orientation)), fit)
}
/// Artboard `i`'s Fill, its background `color`: the chip shows it (the None paint when the artboard
+24
View File
File diff suppressed because one or more lines are too long
+23 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23
View File
File diff suppressed because one or more lines are too long