mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
290 lines
14 KiB
Rust
290 lines
14 KiB
Rust
//! Shape Builder Tool Options (double-click the Shape Builder tool): the options the tool keeps,
|
|
//! which last across tool switches and are saved with the preferences. OK sets them with
|
|
//! `tool.setOption {tool: "shapeBuilder", values}`.
|
|
//!
|
|
//! Fields: `gapDetection` and `gapLength` (Gap Detection), `openFilledAsClosed` (Consider Open
|
|
//! Filled Paths as Closed), `clickStrokeSplitsPath` (In Merge Mode, Clicking Stroke Splits the
|
|
//! Path), `pickColorFrom` (`artwork` or `swatches`), `cursorSwatchPreview` (with `swatches`),
|
|
//! `selection` (`freeform` or `straightLine`), `highlightFill` (Highlight: Fill),
|
|
//! `highlightStroke` (Highlight: Stroke When Editable) and `highlightColor` (its color: `#rrggbb`,
|
|
//! or a layer color's name such as "Red").
|
|
|
|
use serde_json::{Map, Value, json};
|
|
use vectorcraft_color::Color;
|
|
use vectorcraft_doc::LAYER_COLORS;
|
|
|
|
use super::tile_edge_color::resolve;
|
|
use super::{DialogSpec, form, run_and_close};
|
|
use crate::state::Dialog;
|
|
use crate::{VectorcraftApp, widgets};
|
|
|
|
/// The dialog kind of Shape Builder Tool Options.
|
|
pub const KIND: &str = "shapeBuilderOptions";
|
|
|
|
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM };
|
|
|
|
/// The options the dialog edits.
|
|
const KEYS: [&str; 10] = [
|
|
"openFilledAsClosed",
|
|
"pickColorFrom",
|
|
"cursorSwatchPreview",
|
|
"selection",
|
|
"highlightFill",
|
|
"gapDetection",
|
|
"gapLength",
|
|
"clickStrokeSplitsPath",
|
|
"highlightStroke",
|
|
"highlightColor",
|
|
];
|
|
|
|
/// Width of the label column.
|
|
const LABEL_W: f32 = 110.0;
|
|
|
|
fn heading(_: &Dialog) -> String {
|
|
let label = vectorcraft_tools::tool_info("shapeBuilder").map_or("", |t| t.label);
|
|
crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))])
|
|
}
|
|
|
|
/// Open the Shape Builder's options with their current values.
|
|
pub fn open(app: &mut VectorcraftApp) -> Value {
|
|
let o = app.session.tool_options_of("shapeBuilder");
|
|
let fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
|
|
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
|
json!({ "dialog": KIND })
|
|
}
|
|
|
|
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|
form::check(ui, d, "gapDetection", tl!("Gap Detection"));
|
|
ui.add_enabled_ui(d.bool("gapDetection"), |ui| {
|
|
widgets::label_row(ui, tl!("Gap Length:"), LABEL_W, |ui| {
|
|
let lengths = vectorcraft_tools::builder::GAP_LENGTHS;
|
|
let len = d.f64("gapLength", lengths[0]);
|
|
let current = lengths.iter().position(|n| *n == len).unwrap_or(3);
|
|
let labels = [tl!("Small"), tl!("Medium"), tl!("Large"), tl!("Custom")];
|
|
if let Some(i) = widgets::dropdown(ui, "shape-builder-gap", labels[current], &labels, 120.0) {
|
|
d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0)));
|
|
}
|
|
ui.add_enabled_ui(current == 3, |ui| {
|
|
if let Some(n) =
|
|
widgets::range_field(ui, "shape-builder-gap-length", len, 0.0..=vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP, "pt", 1, 64.0)
|
|
{
|
|
d.fields.insert("gapLength".into(), json!(n));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
ui.add_space(8.0);
|
|
form::check(ui, d, "openFilledAsClosed", tl!("Consider Open Filled Paths as Closed"));
|
|
form::check(ui, d, "clickStrokeSplitsPath", tl!("In Merge Mode, Clicking Stroke Splits the Path"));
|
|
ui.add_space(8.0);
|
|
let sources = [("artwork", tl!("Artwork")), ("swatches", tl!("Color Swatches"))];
|
|
form::choice(ui, d, "pickColorFrom", tl!("Pick Color From:"), (LABEL_W, 160.0), &sources);
|
|
// Cursor Swatch Preview can be changed with Color Swatches only.
|
|
let preview = d.bool("cursorSwatchPreview");
|
|
if widgets::check(ui, tl!("Cursor Swatch Preview"), preview, d.str("pickColorFrom") == "swatches") {
|
|
d.fields.insert("cursorSwatchPreview".into(), json!(!preview));
|
|
}
|
|
ui.add_space(8.0);
|
|
let selection = d.str("selection");
|
|
widgets::label_row(ui, tl!("Selection:"), LABEL_W, |ui| {
|
|
for (value, label) in [("freeform", tl!("Freeform")), ("straightLine", tl!("Straight Line"))] {
|
|
if widgets::radio(ui, label, selection == value, true) {
|
|
d.fields.insert("selection".into(), json!(value));
|
|
}
|
|
}
|
|
});
|
|
widgets::label_row(ui, tl!("Highlight:"), LABEL_W, |ui| {
|
|
form::check(ui, d, "highlightFill", tl!("Fill"));
|
|
form::check(ui, d, "highlightStroke", tl!("Stroke When Editable"));
|
|
});
|
|
// The stroke highlight takes a layer color, or any other color from the color button.
|
|
let color = resolve(&d.str("highlightColor")).unwrap_or(Color::BLACK);
|
|
let [r, g, b, _] = color.to_rgba8(1.0);
|
|
let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]);
|
|
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
|
|
ui.add_enabled_ui(d.bool("highlightStroke"), |ui| {
|
|
widgets::label_row(ui, tl!("Color:"), LABEL_W, |ui| {
|
|
if let Some((_, [r, g, b])) = widgets::dropdown(ui, "shape-builder-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0)
|
|
.and_then(|i| LAYER_COLORS.get(i))
|
|
{
|
|
d.fields.insert("highlightColor".into(), json!(Color::rgb8(*r, *g, *b).to_hex()));
|
|
}
|
|
let mut rgb = [r, g, b];
|
|
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
|
d.fields.insert("highlightColor".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex()));
|
|
}
|
|
});
|
|
});
|
|
false
|
|
}
|
|
|
|
/// OK: set the Shape Builder's options.
|
|
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
|
|
run_and_close(app, "tool.setOption", json!({ "tool": "shapeBuilder", "values": values }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use vectorcraft_engine::Session;
|
|
|
|
fn app() -> VectorcraftApp {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
|
app
|
|
}
|
|
|
|
/// The labels the open dialog paints.
|
|
fn painted(app: &mut VectorcraftApp) -> String {
|
|
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
|
}
|
|
|
|
#[test]
|
|
fn gap_options_share_the_existing_dialog_and_custom_is_capped() {
|
|
let mut a = app();
|
|
a.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
|
|
let text = painted(&mut a);
|
|
assert!(text.contains("Gap Detection") && text.contains("Gap Length:"), "{text}");
|
|
let d = a.ui.dialog.as_mut().unwrap();
|
|
d.fields.insert("gapDetection".into(), json!(true));
|
|
d.fields.insert("gapLength".into(), json!(1000));
|
|
super::super::confirm(&mut a).unwrap();
|
|
let opts = a.session.tool_options_of("shapeBuilder");
|
|
assert_eq!(opts["gapLength"], 72.0);
|
|
assert_eq!(opts["gapDetection"], true);
|
|
assert_eq!(opts["openFilledAsClosed"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn double_clicking_the_tool_opens_its_options_and_ok_keeps_them() {
|
|
let mut app = app();
|
|
let r = app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
|
|
assert_eq!(r["dialog"], json!(KIND));
|
|
let text = painted(&mut app);
|
|
for label in [
|
|
"Shape Builder Tool Options",
|
|
"Consider Open Filled Paths as Closed",
|
|
"Pick Color From:",
|
|
"Artwork",
|
|
"Cursor Swatch Preview",
|
|
"Selection:",
|
|
"Freeform",
|
|
"Straight Line",
|
|
"Highlight:",
|
|
"Fill",
|
|
] {
|
|
assert!(text.contains(label), "{label} in {text}");
|
|
}
|
|
// The defaults: VectorCraft's Shape Builder as it works without the dialog.
|
|
let d = app.ui.dialog.as_mut().unwrap();
|
|
assert_eq!(
|
|
(d.bool("openFilledAsClosed"), d.str("pickColorFrom"), d.bool("cursorSwatchPreview"), d.str("selection"), d.bool("highlightFill")),
|
|
(true, "artwork".into(), false, "freeform".into(), true)
|
|
);
|
|
for (k, v) in [
|
|
("openFilledAsClosed", json!(false)),
|
|
("pickColorFrom", json!("swatches")),
|
|
("cursorSwatchPreview", json!(true)),
|
|
("selection", json!("straightLine")),
|
|
("highlightFill", json!(false)),
|
|
] {
|
|
d.fields.insert(k.into(), v);
|
|
}
|
|
super::super::confirm(&mut app).unwrap();
|
|
assert!(app.ui.dialog.is_none());
|
|
// They last while another tool is chosen, and the dialog opens on them again.
|
|
app.select_tool("shapeBuilder");
|
|
app.select_tool("selection");
|
|
let o = app.session.tool_options_of("shapeBuilder");
|
|
assert_eq!(
|
|
o,
|
|
json!({
|
|
"openFilledAsClosed": false,
|
|
"pickColorFrom": "swatches",
|
|
"cursorSwatchPreview": true,
|
|
"selection": "straightLine",
|
|
"highlightFill": false,
|
|
"gapDetection": false,
|
|
"gapLength": 3.0,
|
|
"clickStrokeSplitsPath": false,
|
|
"highlightStroke": true,
|
|
"highlightColor": "#ff4f4f"
|
|
})
|
|
);
|
|
app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
|
|
let d = app.ui.dialog.as_ref().unwrap();
|
|
assert_eq!((d.str("selection"), d.bool("highlightFill")), ("straightLine".into(), false));
|
|
}
|
|
|
|
#[test]
|
|
fn the_stroke_rows_turn_on_stroke_splitting_and_its_highlight_color() {
|
|
let mut app = app();
|
|
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
|
app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
|
|
let text = painted(&mut app);
|
|
for label in ["In Merge Mode, Clicking Stroke Splits the Path", "Stroke When Editable", "Color:", "Red"] {
|
|
assert!(text.contains(label), "{label} in {text}");
|
|
}
|
|
let d = app.ui.dialog.as_mut().unwrap();
|
|
assert_eq!((d.bool("clickStrokeSplitsPath"), d.bool("highlightStroke"), d.str("highlightColor")), (false, true, "#ff4f4f".into()));
|
|
d.fields.insert("clickStrokeSplitsPath".into(), json!(true));
|
|
d.fields.insert("highlightColor".into(), json!("Green"));
|
|
super::super::confirm(&mut app).unwrap();
|
|
let o = app.session.tool_options_of("shapeBuilder");
|
|
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#4fff4f")));
|
|
// A click on the rectangle's top side highlights it in that color and splits the rectangle
|
|
// into the side and the rest.
|
|
app.run("select.all", json!({})).unwrap();
|
|
app.select_tool("shapeBuilder");
|
|
let view = app.view_info();
|
|
let ev = |kind| vectorcraft_tools::PointerEvent::new(kind, 200.0, 100.5);
|
|
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Move), view);
|
|
let ov = app.session.overlays(view);
|
|
assert!(ov.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [0x4f, 0xff, 0x4f], .. })), "{ov:?}");
|
|
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Down), view);
|
|
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Up), view);
|
|
let doc = &app.session.doc().unwrap().doc;
|
|
let mut bounds: Vec<_> =
|
|
doc.layers[0].children().unwrap().iter().filter_map(|n| doc.bounds_of(&[n.id], false)).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect();
|
|
bounds.sort_by(|a, b| a[3].total_cmp(&b[3]));
|
|
assert_eq!(bounds, vec![[100.0, 100.0, 300.0, 100.0], [100.0, 100.0, 300.0, 200.0]]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_straight_line_drag_merges_only_the_regions_the_line_crosses() {
|
|
// A wide rectangle and a tall one over the middle of its top edge: three regions, the wide
|
|
// one's own, the overlap and the tall one's top.
|
|
let mut app = app();
|
|
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 180, "y": 50, "width": 40, "height": 100})).unwrap();
|
|
// A drag that goes up into the tall rectangle's top and back down; the bounds of each
|
|
// object afterwards.
|
|
let drag = |app: &mut VectorcraftApp| {
|
|
app.run("select.all", json!({})).unwrap();
|
|
app.select_tool("shapeBuilder");
|
|
let view = app.view_info();
|
|
let ev = |kind, (x, y): (f64, f64)| vectorcraft_tools::PointerEvent::new(kind, x, y);
|
|
let pts = [(120.0, 180.0), (200.0, 75.0), (280.0, 180.0)];
|
|
crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Down, pts[0]), view);
|
|
for p in &pts[1..] {
|
|
crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Drag, *p), view);
|
|
}
|
|
crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Up, pts[2]), view);
|
|
let doc = &app.session.doc().unwrap().doc;
|
|
let ids: Vec<_> = doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
|
ids.iter().filter_map(|id| doc.bounds_of(&[*id], false)).map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::round)).collect::<Vec<_>>()
|
|
};
|
|
// Freeform: the drag merges all three regions into one shape.
|
|
assert_eq!(drag(&mut app), vec![[100.0, 50.0, 300.0, 200.0]]);
|
|
app.run("edit.undo", json!({})).unwrap();
|
|
// Straight Line: the line from the first point to the last stays in the wide rectangle's
|
|
// own region, so only that region is merged: the shape made from it leaves the tall
|
|
// rectangle's top out, and the overlap stays an object of its own.
|
|
app.run("tool.setOption", json!({"tool": "shapeBuilder", "values": {"selection": "straightLine"}})).unwrap();
|
|
let after = drag(&mut app);
|
|
assert!(!after.iter().any(|b| b[1] == 50.0 && b[0] == 100.0), "{after:?}");
|
|
assert!(after.contains(&[180.0, 100.0, 220.0, 150.0]), "{after:?}");
|
|
}
|
|
}
|