Files
vectorcraft/crates/ui-egui/src/dialogs/effect.rs
T

1123 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Live effect dialogs (Effect menu, Appearance panel): the effect's parameters (`__effect`, headed
//! `__label`) with a live Preview that OK keeps as one undo step and Cancel rolls back. `__item`,
//! when present, is the appearance item the effect goes to (else the Appearance panel's active
//! item applies). With `__index` the dialog edits that applied effect in place
//! (`effect.setParams`) instead of adding one (`effect.apply`).
//!
//! Choosing an effect that the target already carries first asks ([`EXISTS`]) whether to edit it
//! or add another one.
use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form, free_distort, transform_each};
use crate::panels::c32;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| if super::warp_effect::is_warp(&d.str("__effect")) { tl!("Warp Options").to_string() } else { heading_label(d) },
body,
confirm,
preview: true,
// Load Texture… adds the texture it reads.
passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER),
..DialogSpec::FORM
};
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
fn heading_label(d: &Dialog) -> String {
let label = d.str("__label");
if vectorcraft_plugins::effect::plugin_id(&d.str("__effect")).is_some() {
return label;
}
let dotted = format!("{label}…");
let shown = tl!(&dotted);
if shown != dotted { shown.trim_end_matches('…').to_string() } else { tl!(&label).to_string() }
}
/// The dialog kind asking whether to edit an effect that is already applied or add another.
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
/// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds
/// a new one.
/// `__editTarget` stores the targets used when editing the applied effect.
pub const EXISTS: &str = "effectExists";
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
heading: |d| crate::i18n::fmt(tl!("{effect} is already applied"), &[("effect", &heading_label(d))]),
body: |_, ui, _| {
ui.label(egui::RichText::new(tl!("Edit the applied effect, or add another one?")).color(Tokens::get(ui.ctx()).text_dim));
false
},
confirm: confirm_exists,
ok: Some("Edit"),
discard: Some("Add New"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// `effect.dialog {effect, index?, item?}`: edit applied effect `index` of `item` (null: the
/// object's effects; omitted: the active item's, else the object's) prefilled with its values,
/// or add `effect`, first asking when that list already has it.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let id = p.get("effect").and_then(Value::as_str).unwrap_or_default();
let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?;
// The list the effect goes to: `item` as given, else the active item's (the object's without
// one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual.
let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item()));
let (edit_target, applied) = target_effects(app, p);
let target = edit_target.get("item").cloned().unwrap_or(target);
let mut fields = Map::new();
fields.insert("__effect".into(), json!(id));
fields.insert("__label".into(), json!(info.label.trim_end_matches('…')));
if let Some(item) = p.get("item") {
fields.insert("__item".into(), item.clone());
}
// A shared row of a mixed selection: OK edits each object's own copy of the effect.
if p.get("aligned").and_then(Value::as_bool) == Some(true) {
fields.insert("__aligned".into(), json!(true));
}
match p.get("index").and_then(Value::as_u64) {
Some(k) => {
let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?;
if e.id != id {
return Err(format!("effect {k} is `{}`, not `{id}`", e.id));
}
fields.insert("__item".into(), target);
fields.insert("__editTarget".into(), edit_target);
fields.insert("__index".into(), json!(k));
fields.extend(params_of(e));
}
None => {
if let Some(k) = applied.iter().rposition(|e| e.id == id) {
fields.insert("__target".into(), target);
fields.insert("__editTarget".into(), edit_target);
fields.insert("__index".into(), json!(k));
app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields });
return Ok(json!({ "pending": EXISTS }));
}
fields.extend(info.defaults.as_object().cloned().unwrap_or_default());
if super::warp_effect::is_warp(id) {
super::warp_effect::merge_remembered(&mut fields, app.last_warp.as_ref());
}
}
}
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
/// The function returns target parameters and effects at `item` (an index or null).
/// An omitted `item` uses the active row.
fn target_effects(app: &VectorcraftApp, p: &Value) -> (Value, Vec<vectorcraft_doc::Effect>) {
let mut params = json!({"aligned": p.get("aligned").and_then(Value::as_bool).unwrap_or(false)});
if let Some(item) = p.get("item") {
params["item"] = item.clone();
}
app.session.appearance_effect_target(&params).map(|(target, effects)| (target, effects.to_vec())).unwrap_or_else(|| (json!({}), vec![]))
}
/// An applied effect's values over its defaults.
fn params_of(e: &vectorcraft_doc::Effect) -> Map<String, Value> {
match vectorcraft_effects::merged_params(&e.id, &e.params) {
Value::Object(m) => m,
_ => Map::new(),
}
}
/// OK in [`EXISTS`]: the dialog editing the applied effect, or (`discard`) a fresh one.
fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut fields = d.fields.clone();
fields.remove("discard");
let target = fields.remove("__target").unwrap_or(Value::Null);
if d.bool("discard") {
fields.remove("__index");
fields.remove("__editTarget");
let id = d.str("__effect");
let defaults = vectorcraft_effects::default_params(&id);
fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default());
if super::warp_effect::is_warp(id.as_str()) {
super::warp_effect::merge_remembered(&mut fields, app.last_warp.as_ref());
}
} else {
let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize;
let edit_target = fields.get("__editTarget").cloned().unwrap_or_else(|| json!({"item": target}));
let applied = app.session.appearance_effect_target(&edit_target).map(|(_, effects)| effects.to_vec()).unwrap_or_default();
let params = applied.get(k).map(params_of).unwrap_or_default();
fields.extend(params);
fields.insert("__item".into(), target);
}
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (id, relative) = (d.str("__effect"), d.bool("relative"));
if super::warp_effect::is_warp(&id) {
super::warp_effect::body(ui, d);
let (cmd, params) = command(d);
form::preview(app, ui, d, "Warp Options", cmd, params);
return false;
}
if vectorcraft_effects::is_three_d(&id) {
super::revolve::body(app, ui, d);
let (cmd, params) = command(d);
form::preview(app, ui, d, &d.str("__label"), cmd, params);
return false;
}
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()),
// The Color Effects add-on's take the plain form of their parameters.
None if vectorcraft_effects::is_adjustment(&id) && !vectorcraft_effects::addons::color::is_color_effect(&id) => adjust_fields(ui, d),
None if id == vectorcraft_effects::addons::halftone::HALFTONE => {
super::halftone::screen_fields(ui, d);
values_changed(d)
}
None if id == vectorcraft_effects::addons::duotone::DUOTONE => {
super::duotone::fields(app, ui, d);
values_changed(d)
}
None if id == GLASS => glass_fields(app, ui, d, GLASS),
None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None if id == SCRIBBLE => scribble_fields(ui, d, app.session.general_unit()),
None if id == ZIG_ZAG || id == ROUGHEN => size_fields(ui, d, &id, app.session.general_unit()),
None if id == PUCKER_BLOAT => pucker_bloat_fields(ui, d),
None if id == TWEAK => tweak_fields(ui, d, app.session.general_unit()),
None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()),
None if id == "artistic.filmGrain" => film_grain_fields(ui, d),
None if id == free_distort::FREE_DISTORT => {
free_distort::fields(app, ui, d);
values_changed(d)
}
None if id == vectorcraft_effects::RASTERIZE => {
super::raster_effects::effect_fields(app, ui, d);
values_changed(d)
}
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
let changed =
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit);
// The fields show what the effect stores: Scribble's 270° angle reads -90°.
if let Value::Object(m) = vectorcraft_effects::upgrade_params(&id, &Value::Object(d.fields.clone())) {
d.fields = m;
}
changed
}
};
ui.add_space(6.0);
let pv_changed = widgets::check(ui, tl!("Preview"), d.bool("preview"), true);
let pv = d.bool("preview") != pv_changed;
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
let _ = app.session.begin_interaction(&label);
let (cmd, p) = command(d);
let _ = app.session.preview(cmd, &p);
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
/// Film Grain's bounded, whole-number controls, in the same order as its parameter docs.
pub(crate) const FILM_GRAIN_FIELDS: [(&str, &str, f64); 3] =
[("grain", "Grain", 20.0), ("highlightArea", "Highlight Area", 20.0), ("intensity", "Intensity", 10.0)];
/// Paint Daubs › Brush Type labels ([`vectorcraft_effects::pixel::PAINT_DAUB_BRUSH_TYPES`]); extend
/// [`crate::menus::menu_strings`] with these like [`FILM_GRAIN_FIELDS`].
pub(crate) const PAINT_DAUB_BRUSH_TYPE_LABELS: [&str; 6] = ["Simple", "Light Rough", "Dark Rough", "Wide Sharp", "Wide Blurry", "Sparkle"];
fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let before = d.fields.clone();
// Use one column wide enough for this language's longest label, keeping every rail aligned.
let font = egui::TextStyle::Body.resolve(ui.style());
let text = Tokens::get(ui.ctx()).text;
let label_w = FILM_GRAIN_FIELDS
.iter()
.map(|(_, label, _)| ui.painter().layout_no_wrap(tl!(label).to_string(), font.clone(), text).size().x)
.fold(form::SLIDER_LABEL, f32::max)
.ceil()
+ 4.0;
for (key, label, max) in FILM_GRAIN_FIELDS {
form::slider_w(ui, d, (key, label, label_w), 0.0..=max, "", &grey_rail);
}
before != d.fields
}
/// The Glass effect (Effect › Distort › Glass…).
const GLASS: &str = "distort.glass";
/// The Texturizer effect (Effect › Texture › Texturizer…).
const TEXTURIZER: &str = "texture.texturizer";
/// Glass and Texturizer: parameters, the Texture menu ending with Loaded Texture once a texture is
/// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from
/// those last previewed.
fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) -> bool {
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
// The stored texture is shown as its size, not as a text field.
let stored = d.fields.remove(TEXTURE_IMAGE);
let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size());
let menu = if id == TEXTURIZER { texturizer_textures(size.is_some()) } else { glass_textures(size.is_some()) };
let doc = vectorcraft_effects::effect_info(id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(id, k) };
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(id, k, false), &pick, &|k| doc_rank(doc, k), unit);
if let Some(v) = stored {
d.fields.insert(TEXTURE_IMAGE.into(), v);
}
let mut load = false;
ui.horizontal(|ui| {
let can_read = app.services.read.is_some();
load = ui.add_enabled_ui(can_read, |ui| widgets::flat_button(ui, tl!("Load Texture…"), 110.0)).inner.clicked();
if let Some((w, h)) = size {
widgets::dim_label(ui, &format!("{w} × {h} px"));
}
});
if load
&& let Err(e) = crate::picks::in_dialog(app, d, load_texture)
&& e != "cancelled"
{
app.status(e);
}
values_changed(d)
}
/// Glass's Texture menu, ending with Loaded Texture once a texture is `loaded`.
fn glass_textures(loaded: bool) -> form::Choices {
use vectorcraft_effects::pixel::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED};
if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES }
}
/// Texturizer's Texture menu, ending with Loaded Texture once a texture is `loaded`.
fn texturizer_textures(loaded: bool) -> form::Choices {
use vectorcraft_effects::pixel::{TEXTURES, TEXTURES_LOADED};
if loaded { &TEXTURES_LOADED } else { &TEXTURES }
}
/// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores
/// it and picked in the Texture menu.
fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
use vectorcraft_engine::cmd::fileio;
let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, fileio::read_extensions(f))));
let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?;
// The button is off where files can't be read.
let Some(read) = app.services.read.as_ref() else { return Ok(()) };
let stored = fileio::texture_image(&read(&path)?).map_err(|e| e.to_string())?;
d.fields.insert(vectorcraft_effects::pixel::TEXTURE_IMAGE.into(), json!(stored));
d.fields.insert("texture".into(), json!(vectorcraft_effects::pixel::LOADED));
Ok(())
}
/// The parameters of the effect dialogs that pick one of some values, as (label, value).
fn choices(effect: &str, key: &str) -> Option<form::Choices> {
match (effect, key) {
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
("blur.smart", "blurMode") => Some(&vectorcraft_effects::pixel::SMART_BLUR_MODES),
("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("artistic.paintDaubs", "brushType") => Some(&vectorcraft_effects::pixel::PAINT_DAUB_BRUSH_TYPES),
("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES),
("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "lightDirection") => {
Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS)
}
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
("stylize.dropShadow", "shade") => Some(SHADES),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => crate::addons::pathtools::effect_choices(effect, key),
}
}
/// The parameters whose dialogs list their values in their own bodies, as (label, value), each
/// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI
/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context
/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled
/// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and
/// the channel of Levels and Curves.
pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> {
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(vectorcraft_effects::EXTRUDE, "bevel") => vec![
(tl!("None"), "none"),
(tl!("Classic"), "classic"),
(c3("Rounded"), "rounded"),
(tl!("Cove"), "cove"),
(tl!("Step"), "step"),
(tl!("Ogee"), "ogee"),
],
(_, "orientation") if super::warp_effect::is_warp(effect) => vec![(tl!("Horizontal"), "horizontal"), (tl!("Vertical"), "vertical")],
(vectorcraft_effects::RASTERIZE, "colorModel") => {
vec![(if cmyk { "CMYK" } else { "RGB" }, "document"), (tl!("Grayscale"), "grayscale"), (tl!("Bitmap"), "bitmap")]
}
(vectorcraft_effects::RASTERIZE, "background") => vec![(tl!("White"), "white"), (tl!("Transparent"), "transparent")],
(vectorcraft_effects::RASTERIZE, "antiAlias") => vectorcraft_render::AntiAlias::ALL.iter().map(|a| (a.label(), a.id())).collect(),
("adjust.levels" | "adjust.curves", "channel") => vec![("RGB", "rgb"), (tl!("Red"), "red"), (tl!("Green"), "green"), (tl!("Blue"), "blue")],
_ => return None,
})
}
/// The values that parameter `key` of applied effect `effect` picks from, as its dialog lists
/// them, as (label, value): our own effects' labels in the UI language, a plug-in's choices as the
/// plug-in names them. Glass's Texture menu ends with Loaded Texture when the effect's values
/// `params` hold a loaded texture, and the lists of [`own_choices`] are translated as their
/// dialogs translate them (`cmyk`: the document is in CMYK). `None` for a parameter that is not one
/// of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>, cmyk: bool) -> Option<Vec<(String, String)>> {
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
return plugin.manifest().param(key).and_then(|p| match &p.spec {
vectorcraft_plugins::ParamSpec::Choice { options, .. } => Some(options.iter().map(|o| (o.clone(), o.clone())).collect()),
_ => None,
});
}
if let Some(own) = own_choices(effect, key, cmyk) {
return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect());
}
let options = if (effect == GLASS || effect == TEXTURIZER) && key == "texture" {
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
let loaded = params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some();
if effect == GLASS { glass_textures(loaded) } else { texturizer_textures(loaded) }
} else {
choices(effect, key)?
};
Some(options.iter().map(|(label, value)| (form::choice_label(label).to_string(), value.to_string())).collect())
}
/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …,
/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there.
pub(super) fn doc_rank(doc: &str, key: &str) -> usize {
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
doc.match_indices(key)
.map(|(at, _)| at)
.find(|at| starts(*at) && doc.get(at + key.len()..).is_some_and(|rest| rest.starts_with(':') || rest.starts_with("?:")))
.unwrap_or(usize::MAX)
}
/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1).
type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32);
fn rgb(r: f32, g: f32, b: f32) -> egui::Color32 {
c32(&Color::rgb(r, g, b))
}
fn grey_rail(x: f32) -> egui::Color32 {
rgb(x, x, x)
}
fn hue_rail(x: f32) -> egui::Color32 {
c32(&Color::from_hsb(((x - 0.5) * 360.0).rem_euclid(360.0), 0.8, 0.9))
}
fn saturation_rail(x: f32) -> egui::Color32 {
c32(&Color::from_hsb(0.0, x, 0.9))
}
fn temperature_rail(x: f32) -> egui::Color32 {
rgb(0.35 + 0.65 * x, 0.55 + 0.05 * x, 1.0 - 0.8 * x)
}
fn tint_rail(x: f32) -> egui::Color32 {
rgb(0.3 + 0.6 * x, 0.85 - 0.55 * x, 0.3 + 0.6 * x)
}
const SLIDERS: [Slider; 12] = [
("adjust.brightnessContrast", "brightness", "Brightness", (-100.0, 100.0), grey_rail),
("adjust.brightnessContrast", "contrast", "Contrast", (-100.0, 100.0), grey_rail),
("adjust.hueSaturation", "hue", "Hue", (-180.0, 180.0), hue_rail),
("adjust.hueSaturation", "saturation", "Saturation", (-100.0, 100.0), saturation_rail),
("adjust.hueSaturation", "lightness", "Lightness", (-100.0, 100.0), grey_rail),
("adjust.levels", "inputBlack", "Input Black", (0.0, 255.0), grey_rail),
("adjust.levels", "inputWhite", "Input White", (0.0, 255.0), grey_rail),
("adjust.levels", "outputBlack", "Output Black", (0.0, 255.0), grey_rail),
("adjust.levels", "outputWhite", "Output White", (0.0, 255.0), grey_rail),
("adjust.shiftToColor", "amount", "Amount", (0.0, 100.0), grey_rail),
("adjust.temperatureTint", "temperature", "Temperature", (-100.0, 100.0), temperature_rail),
("adjust.temperatureTint", "tint", "Tint", (-100.0, 100.0), tint_rail),
];
/// The colour adjustment dialogs (Effect → Color Adjustments): a slider per amount, then the
/// channel as a menu and the other values (Curves' points, Levels' gamma, the target colour) in
/// the same label column, and the options. Returns whether the values differ from those last
/// previewed.
fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
const LABEL_W: f32 = 84.0;
let id = d.str("__effect");
for (_, key, label, (min, max), rail) in SLIDERS.iter().filter(|s| s.0 == id) {
form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail);
}
if d.fields.contains_key("channel") {
let channels: Vec<(&str, &str)> =
own_choices(&id, "channel", false).unwrap_or_default().into_iter().map(|(label, value)| (value, label)).collect();
form::choice(ui, d, "channel", tl!("Channel"), (LABEL_W, 120.0), &channels);
}
if d.fields.contains_key("points") {
ui.horizontal(|ui| {
ui.add_space(LABEL_W + ui.spacing().item_spacing.x);
let mut pts = vectorcraft_effects::curve_points(d.fields.get("points"));
if curve_graph(ui, &mut pts, 255.0) {
d.fields.insert("points".into(), json!(points_text(&pts, 255.0)));
}
});
}
for (key, label, width) in [("points", tl!("Points"), 220.0), ("color", tl!("Color"), 120.0)] {
if d.fields.contains_key(key) {
widgets::label_row(ui, label, LABEL_W, |ui| {
if key == "color" {
form::color_text(ui, d, key, width);
} else {
form::text(ui, d, key, width);
}
});
}
}
if d.fields.contains_key("gamma") {
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
if let Some(g) = widgets::range_field(ui, "gamma", d.f64("gamma", 1.0), 0.1..=10.0, "", 2, 60.0) {
d.fields.insert("gamma".into(), json!(g));
}
});
}
for (key, label) in [("colorize", tl!("Colorize")), ("preserveLightness", tl!("Preserve Lightness"))] {
if d.fields.contains_key(key) {
form::check(ui, d, key, label);
}
}
values_changed(d)
}
/// Do the dialog's values differ from those last previewed? (Remembers them for the next frame.)
fn values_changed(d: &mut Dialog) -> bool {
let params = form::params(d);
let changed = d.fields.get(form::PREVIEWED) != Some(&params);
d.fields.insert(form::PREVIEWED.into(), params);
changed
}
/// The Transform effect (Effect › Distort & Transform › Transform…).
const TRANSFORM: &str = "distort.transform";
/// Scribble (Effect › Stylize › Scribble…).
const SCRIBBLE: &str = "stylize.scribble";
/// Dialog-only: which Settings preset is selected (`custom` when the values match none).
const SCRIBBLE_SETTING: &str = "__scribbleSetting";
/// Scribble's dialog: a Settings preset menu, then its parameters. Returns whether the values differ
/// from those last previewed.
fn scribble_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
if !d.fields.contains_key(SCRIBBLE_SETTING) {
let id = vectorcraft_effects::scribble_setting_id(&form::params(d)).unwrap_or("custom");
d.fields.insert(SCRIBBLE_SETTING.into(), json!(id));
}
let presets = vectorcraft_effects::scribble_settings();
let cur = d.fields.get(SCRIBBLE_SETTING).and_then(Value::as_str).unwrap_or("default");
let labels: Vec<&str> = presets.iter().map(|p| p.label).chain(["Custom"]).collect();
let ids: Vec<&str> = presets.iter().map(|p| p.id).chain(["custom"]).collect();
let current = if cur == "custom" { "Custom" } else { presets.iter().find(|p| p.id == cur).map(|p| p.label).unwrap_or(labels[0]) };
widgets::subheader(ui, tl!("Settings"));
let mut settings_changed = false;
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "fx-scribble-setting", current, &labels, 160.0) {
let picked = ids[i];
d.fields.insert(SCRIBBLE_SETTING.into(), json!(picked));
if picked != "custom"
&& let Some(preset) = presets.iter().find(|p| p.id == picked)
&& let Some(m) = preset.params.as_object()
{
for (k, v) in m {
d.fields.insert(k.clone(), v.clone());
}
}
settings_changed = true;
}
});
ui.add_space(4.0);
let doc = vectorcraft_effects::effect_info(SCRIBBLE).map(|e| e.params).unwrap_or_default();
let fields_changed =
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(SCRIBBLE, k, false), &|k| choices(SCRIBBLE, k), &|k| doc_rank(doc, k), unit);
if let Value::Object(m) = vectorcraft_effects::upgrade_params(SCRIBBLE, &Value::Object(d.fields.clone())) {
d.fields = m;
}
let id = vectorcraft_effects::scribble_setting_id(&form::params(d)).unwrap_or("custom");
d.fields.insert(SCRIBBLE_SETTING.into(), json!(id));
settings_changed || fields_changed
}
/// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then
/// the options Transform Objects, Transform Patterns and Scale Strokes & Effects (#885), Copies,
/// the reflections, the reference point and Random. Returns whether the values differ from those
/// last previewed.
fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| transform_each::sections(ui, d, unit, true));
ui.add_space(16.0);
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
form::check(ui, d, "objects", tl!("Transform Objects"));
form::check(ui, d, "patterns", tl!("Transform Patterns"));
form::check(ui, d, "strokes", tl!("Scale Strokes & Effects"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Copies:"));
if let Some(n) = widgets::range_field_live(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) {
d.fields.insert("copies".into(), json!(n));
}
});
transform_each::reflect_options(ui, d);
});
});
values_changed(d)
}
/// The Zig Zag effect (Effect › Distort & Transform › Zig Zag…).
const ZIG_ZAG: &str = "distort.zigZag";
/// The Roughen effect (Effect › Distort & Transform › Roughen…).
const ROUGHEN: &str = "distort.roughen";
/// The Pucker & Bloat effect (Effect › Distort & Transform › Pucker & Bloat…).
const PUCKER_BLOAT: &str = "distort.puckerBloat";
/// The second slider of [`size_fields`]: Zig Zag's Ridges per segment, Roughen's Detail (per inch),
/// as (key, label, default, suffix).
fn second_slider(effect: &str) -> (&'static str, &'static str, f64, &'static str) {
if effect == ROUGHEN { ("detail", "Detail:", 10.0, "/in") } else { ("ridges", "Ridges per segment:", 4.0, "") }
}
/// Zig Zag's and Roughen's dialog (#1133, #4488): Size and Ridges per segment (Roughen: Detail) as
/// sliders with their fields, Relative or Absolute and the Points as radio buttons. Returns
/// whether the values differ from those last previewed.
/// The label column and field width of [`relative_rows`] and the sliders under them.
const DISTORT_LABEL_W: f32 = 124.0;
const DISTORT_FIELD_W: f32 = 70.0;
/// A slider with its field for each of `rows` (key, label, default), each a share of the object's
/// size with Relative or a distance with Absolute, then the Relative and Absolute radio buttons.
fn relative_rows(ui: &mut egui::Ui, d: &mut Dialog, rows: &[(&str, &str, f64)], unit: vectorcraft_doc::Unit) {
let rail = Tokens::get(ui.ctx()).input_border;
let track = move |_| rail;
let relative = d.bool("relative");
for &(key, label, default) in rows {
widgets::label_row(ui, tl!(label), DISTORT_LABEL_W, |ui| {
let shown = if relative {
Some(widgets::SliderValue::field(d.f64(key, default).clamp(0.0, 100.0), 0.0..=100.0, "%", 0))
} else {
form::length_value(d, key, unit, 0.0..=100.0)
};
form::slider_rail_valued(ui, d, key, tl!(label), 0.0..=100.0, shown, &track);
if !relative {
form::length(ui, d, key, unit, DISTORT_FIELD_W);
} else if let Some(n) = widgets::range_field(ui, ("zz", key), d.f64(key, default), 0.0..=100.0, "%", 0, DISTORT_FIELD_W) {
d.fields.insert(key.into(), json!(n));
}
});
}
ui.horizontal(|ui| {
ui.add_space(DISTORT_LABEL_W + ui.spacing().item_spacing.x);
for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] {
if widgets::radio(ui, label, relative == on, true) {
d.fields.insert("relative".into(), json!(on));
}
}
});
}
fn size_fields(ui: &mut egui::Ui, d: &mut Dialog, effect: &str, unit: vectorcraft_doc::Unit) -> bool {
const LABEL_W: f32 = DISTORT_LABEL_W;
const FIELD_W: f32 = DISTORT_FIELD_W;
let rail = Tokens::get(ui.ctx()).input_border;
let track = move |_| rail;
let size = if effect == ROUGHEN { 5.0 } else { 10.0 };
widgets::subheader(ui, tl!("Options"));
// Size is a share of the object's size (Relative) or a distance (Absolute).
relative_rows(ui, d, &[("size", "Size:", size)], unit);
let (key, label, default, suffix) = second_slider(effect);
widgets::label_row(ui, tl!(label), LABEL_W, |ui| {
form::slider_rail_named(ui, d, key, tl!(label), 0.0..=100.0, &track);
if let Some(n) = widgets::range_field(ui, ("zz", key), d.f64(key, default), 0.0..=100.0, suffix, 0, FIELD_W) {
d.fields.insert(key.into(), json!(n));
}
});
ui.add_space(4.0);
widgets::subheader(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", "Points"));
let points = d.str("points");
ui.horizontal(|ui| {
for (label, value) in choices(effect, "points").unwrap_or_default() {
if widgets::radio(ui, crate::i18n::tr_ctx(crate::i18n::current(), "effect", label), points == *value, true) {
d.fields.insert("points".into(), json!(value));
}
}
});
values_changed(d)
}
/// The Tweak effect (Effect › Distort & Transform › Tweak…).
const TWEAK: &str = "distort.tweak";
/// Tweak's dialog (#4488): Horizontal and Vertical as sliders with their fields (a share of the
/// object's size with Relative, a distance with Absolute), then which points it moves. Returns
/// whether the values differ from those last previewed.
fn tweak_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
widgets::subheader(ui, tl!("Amount"));
relative_rows(ui, d, &[("h", "Horizontal:", 10.0), ("v", "Vertical:", 10.0)], unit);
ui.add_space(4.0);
widgets::subheader(ui, tl!("Modify"));
form::check(ui, d, "anchors", tl!("Anchor Points"));
form::check(ui, d, "in", tl!("\"In\" Control Points"));
form::check(ui, d, "out", tl!("\"Out\" Control Points"));
values_changed(d)
}
/// Pucker & Bloat's dialog (#4488): its amount as a slider from Pucker (−200 %) to Bloat (200 %)
/// with its field. Returns whether the value differs from the one last previewed.
fn pucker_bloat_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let rail = Tokens::get(ui.ctx()).input_border;
form::slider(ui, d, "amount", tl!("Amount:"), -200.0..=200.0, "%", &move |_| rail);
form::slider_ends(ui, form::SLIDER_LABEL, (tl!("Pucker"), tl!("Bloat")));
values_changed(d)
}
/// Drop Shadow (Effect › Stylize › Drop Shadow…).
const DROP_SHADOW: &str = "stylize.dropShadow";
/// What Drop Shadow's shadow takes its color from (`shade`), as (label, value).
pub(crate) const SHADES: form::Choices = &[("Color", "color"), ("Darkness", "darkness")];
/// Drop Shadow's dialog: Mode, Opacity, the offsets and Blur as the other effects show them, then
/// Color and Darkness as radio buttons, each with its field (the field of the one not chosen is
/// dimmed). Returns whether the values differ from those last previewed.
fn drop_shadow_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
let doc = vectorcraft_effects::effect_info(DROP_SHADOW).map(|e| e.params).unwrap_or_default();
// The generic rows, without the three this dialog shows as radio buttons.
let own: Vec<(String, Value)> = ["shade", "color", "darkness"].iter().filter_map(|k| d.fields.remove(*k).map(|v| (k.to_string(), v))).collect();
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(DROP_SHADOW, k, false), &|k| choices(DROP_SHADOW, k), &|k| doc_rank(doc, k), unit);
d.fields.extend(own);
let darkness = d.str("shade").eq_ignore_ascii_case("darkness");
ui.add_space(4.0);
egui::Grid::new("fx-shade").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if widgets::radio(ui, tl!("Color"), !darkness, true) {
d.fields.insert("shade".into(), json!("color"));
}
ui.add_enabled_ui(!darkness, |ui| {
form::color_text(ui, d, "color", 128.0);
});
ui.end_row();
if widgets::radio(ui, tl!("Darkness"), darkness, true) {
d.fields.insert("shade".into(), json!("darkness"));
}
ui.add_enabled_ui(darkness, |ui| {
if let Some(v) = widgets::spin_range(ui, "fx-darkness", Some(d.f64("darkness", 100.0)), 0.0..=100.0, "%", 0, 86.0, 1.0) {
d.fields.insert("darkness".into(), json!(v));
}
});
ui.end_row();
});
values_changed(d)
}
/// A curve's graph of its points `pts` (0..1 each; input across, output up), as Curves and
/// Duotone's tones edit them on a scale of 0..`max`: drag a point to move it between its
/// neighbours, press on the graph away from the points to add one there, drag one off the graph
/// to remove it (two always stay). Returns whether the points changed.
pub(super) fn curve_graph(ui: &mut egui::Ui, pts: &mut Vec<(f32, f32)>, max: f32) -> bool {
const SIZE: f32 = 200.0;
/// How near (screen points) a press must be to take a point.
const GRAB: f32 = 8.0;
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::click_and_drag());
let screen = |(x, y): (f32, f32)| egui::pos2(rect.left() + x * SIZE, rect.bottom() - y * SIZE);
let unit = |p: egui::Pos2| (((p.x - rect.left()) / SIZE).clamp(0.0, 1.0), ((rect.bottom() - p.y) / SIZE).clamp(0.0, 1.0));
let held = ui.scope_id().with("curve-point");
let mut grabbed: Option<usize> = ui.data(|m| m.get_temp(held)).flatten();
let mut changed = false;
if let Some(p) = resp.interact_pointer_pos()
&& resp.drag_started()
{
let near =
pts.iter().enumerate().map(|(i, q)| (i, screen(*q).distance(p))).filter(|(_, dist)| *dist <= GRAB).min_by(|a, b| a.1.total_cmp(&b.1));
grabbed = match near {
Some((i, _)) => Some(i),
None => {
let q = unit(p);
let i = pts.partition_point(|a| a.0 < q.0);
pts.insert(i, q);
changed = true;
Some(i)
}
};
}
if let (Some(i), Some(p)) = (grabbed, resp.interact_pointer_pos())
&& resp.dragged()
{
if !rect.expand(24.0).contains(p) && pts.len() > 2 {
pts.remove(i.min(pts.len() - 1));
grabbed = None;
} else {
let step = 1.0 / max;
let lo = if i == 0 { 0.0 } else { pts.get(i - 1).map_or(0.0, |a| a.0 + step) };
let hi = pts.get(i + 1).map_or(1.0, |a| a.0 - step);
let (x, y) = unit(p);
if let Some(q) = pts.get_mut(i) {
*q = (x.clamp(lo, hi.max(lo)), y);
}
}
changed = true;
}
if resp.drag_stopped() {
grabbed = None;
}
ui.data_mut(|m| m.insert_temp(held, grabbed));
// The graph: quarter grid, the identity line, the curve and its points.
let painter = ui.painter_at(rect.expand(4.0));
painter.rect_filled(rect, 2.0, t.input);
let faint = egui::Stroke::new(1.0, t.input_border);
for k in 1..4 {
let f = k as f32 / 4.0;
painter.line_segment([screen((f, 0.0)), screen((f, 1.0))], faint);
painter.line_segment([screen((0.0, f)), screen((1.0, f))], faint);
}
painter.line_segment([screen((0.0, 0.0)), screen((1.0, 1.0))], faint);
let curve: Vec<egui::Pos2> = (0..=64).map(|k| k as f32 / 64.0).map(|x| screen((x, vectorcraft_effects::curve_at(pts.as_slice(), x)))).collect();
painter.add(egui::Shape::line(curve, egui::Stroke::new(1.5, t.text)));
for (i, q) in pts.iter().enumerate() {
let fill = if Some(i) == grabbed { t.accent } else { t.panel };
painter.circle(screen(*q), 4.0, fill, egui::Stroke::new(1.5, t.text));
}
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
changed
}
/// Curve points `pts` (0..1) as the `"x,y x,y …"` text of a curve on a scale of 0..`max`.
pub(super) fn points_text(pts: &[(f32, f32)], max: f32) -> String {
pts.iter().map(|(x, y)| format!("{},{}", (x * max).round(), (y * max).round())).collect::<Vec<_>>().join(" ")
}
/// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the
/// dialog's effect with its values on the target item.
fn command(d: &Dialog) -> (&'static str, Value) {
let params = form::params(d);
let item = d.fields.get("__item").cloned();
if let Some(index) = d.fields.get("__index") {
let mut p = d.fields.get("__editTarget").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({}));
p["index"] = index.clone();
p["item"] = item.unwrap_or(Value::Null);
p["params"] = params;
let id = d.str("__effect");
// The displayed style comes from the first target. Preserve other targets' Warp IDs
// unless the user deliberately chooses Style, including after a preview rollback.
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
p["warpStyle"] = json!(id.strip_prefix("warp."));
}
if d.bool("__aligned") {
p["aligned"] = json!(true);
}
return ("effect.setParams", p);
}
let mut p = json!({"effect": d.str("__effect"), "params": params});
if let Some(item) = item {
p["item"] = item;
}
("effect.apply", p)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p) = command(d);
// A live preview commits as one undo step. When the command refuses the values (a Color the
// effect can't draw), the dialog stays open and the error is returned.
let r = form::commit_preview(app, cmd, p.clone())?;
// Last Effect repeats applied effects, not edits.
if cmd == "effect.apply" {
let id = d.str("__effect");
app.last_effect = Some((id.clone(), p["params"].clone()));
if super::warp_effect::is_warp(id.as_str()) {
app.last_warp = Some(super::warp_effect::remembered_params(d));
}
}
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
/// An applied effect's dialog opened on a shared row of a mixed selection (`aligned`) edits
/// each object's own copy on OK; opened otherwise it edits the index as given.
#[test]
fn a_shared_rows_dialog_edits_each_objects_copy() {
let dialog = |aligned: bool| {
let mut fields = Map::new();
fields.insert("__effect".into(), json!("stylize.feather"));
fields.insert("__index".into(), json!(0));
fields.insert("__item".into(), Value::Null);
fields.insert("radius".into(), json!(8));
if aligned {
fields.insert("__aligned".into(), json!(true));
}
Dialog { kind: "effect".into(), fields }
};
let (cmd, p) = command(&dialog(true));
assert_eq!((cmd, &p["aligned"], &p["params"]["radius"]), ("effect.setParams", &json!(true), &json!(8)));
assert!(p["params"].get("__aligned").is_none());
assert!(command(&dialog(false)).1.get("aligned").is_none());
}
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
for (effect, key) in [
("path.offsetPath", "joins"),
("distort.zigZag", "points"),
("distort.roughen", "points"),
("stylize.innerGlow", "source"),
(DROP_SHADOW, "shade"),
("artistic.paintDaubs", "brushType"),
] {
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
assert_eq!(values.join("|"), listed, "{effect} {key}");
}
}
/// The lists that dialogs draw in their own bodies hold the values the effect's parameter
/// documentation lists, in its order; Rasterize's Color Model names the document's mode first.
#[test]
fn lists_drawn_in_a_dialogs_body_are_the_documented_values() {
for (effect, key) in [
(vectorcraft_effects::REVOLVE, "edge"),
(vectorcraft_effects::EXTRUDE, "bevel"),
("warp.arc", "orientation"),
("warp.twist", "orientation"),
(vectorcraft_effects::RASTERIZE, "background"),
(vectorcraft_effects::RASTERIZE, "antiAlias"),
("adjust.levels", "channel"),
("adjust.curves", "channel"),
] {
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().replace('"', "");
let values: Vec<&str> = own_choices(effect, key, false).unwrap().iter().map(|(_, v)| *v).collect();
assert_eq!(values.join("|"), listed, "{effect} {key}");
}
for (cmyk, mode) in [(false, "RGB"), (true, "CMYK")] {
let models = own_choices(vectorcraft_effects::RASTERIZE, "colorModel", cmyk).unwrap();
assert_eq!(models, [(mode, "document"), ("Grayscale", "grayscale"), ("Bitmap", "bitmap")]);
}
assert!(own_choices("distort.twist", "orientation", false).is_none() && own_choices("adjust.hueSaturation", "channel", false).is_none());
}
/// Glass's Load Texture… reads the picked image into the dialog, the Texture menu shows Loaded
/// Texture and the texture's size instead of its stored text, and OK applies it.
#[test]
fn glass_load_texture_reads_the_picked_image_into_the_effect() {
let dir = std::env::temp_dir().join(format!("vectorcraft-glass-dialog-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("texture.png");
image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap();
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let picked = path.to_string_lossy().into_owned();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": GLASS})).unwrap();
let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()));
let text = paint(&mut app);
assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Frosted"), "{text}");
assert!(!text.contains("Loaded Texture") && !text.contains(" px"), "{text}");
let mut d = app.ui.dialog.take().unwrap();
load_texture(&mut app, &mut d).unwrap();
let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE);
assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4))));
app.ui.dialog = Some(d);
let text = paint(&mut app);
assert!(text.lines().any(|l| l == "Loaded Texture") && text.lines().any(|l| l == "6 × 4 px"), "{text}");
assert!(!text.contains(&stored[..24]), "the stored text isn't shown");
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params;
assert_eq!((&params["texture"], &params["textureImage"]), (&json!("loaded"), &json!(stored)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Texturizer's Load Texture… matches Glass: pick, Loaded Texture in the menu, OK applies.
#[test]
fn texturizer_load_texture_reads_the_picked_image_into_the_effect() {
let dir = std::env::temp_dir().join(format!("vectorcraft-texturizer-dialog-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("texture.png");
image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap();
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let picked = path.to_string_lossy().into_owned();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": TEXTURIZER})).unwrap();
let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()));
let text = paint(&mut app);
assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Canvas"), "{text}");
let mut d = app.ui.dialog.take().unwrap();
load_texture(&mut app, &mut d).unwrap();
let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE);
assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4))));
app.ui.dialog = Some(d);
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params;
assert_eq!((&params["texture"], &params["textureImage"]), (&json!("loaded"), &json!(stored)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Effect > Stylize > Outer Glow: a swatch beside Color shows the effect's colour, typing a
/// hex recolours it, and a double-click opens the Color Picker over the dialog; its OK fills
/// Color, and the effect applies that colour.
#[test]
fn outer_glow_colour_swatch_opens_the_color_picker() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": "stylize.outerGlow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
for n in 0..6 {
frame(&mut app, f64::from(n) * 0.01, vec![]);
}
let rect = ctx.read_response(form::colour_swatch_id("color")).expect("the colour swatch is drawn").rect;
// The colour of the swatch drawn at `rect`.
let swatch = |out: &egui::FullOutput| {
out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.rect == rect => Some(r.fill),
_ => None,
})
};
let yellow = egui::Color32::from_rgb(255, 255, 0);
assert_eq!(swatch(&frame(&mut app, 0.2, vec![])), Some(yellow), "the swatch shows the effect's #ffff00");
// Typing a colour recolours the swatch.
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
assert_eq!(swatch(&frame(&mut app, 0.25, vec![])), Some(egui::Color32::from_rgb(255, 0, 0)), "typing a hex recolours the swatch");
// A double-click opens the Color Picker on the field's colour.
let pos = rect.center();
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut app, 0.3, vec![egui::Event::PointerMoved(pos), button(true)]);
frame(&mut app, 0.35, vec![button(false)]);
frame(&mut app, 0.4, vec![button(true)]);
frame(&mut app, 0.45, vec![button(false)]);
frame(&mut app, 0.5, vec![]);
let d = app.ui.dialog.as_ref().expect("a dialog");
assert_eq!(d.kind, "colorPicker", "a double-click opens the Color Picker over the effect dialog");
assert_eq!(d.fields.get("color").and_then(vectorcraft_engine::cmd::color_value), Some(Color::rgb(1.0, 0.0, 0.0)), "on the field's colour");
// Its OK fills the effect's Color, and the effect applies it.
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#00ff00"));
super::super::confirm(&mut app).unwrap();
let d = app.ui.dialog.as_ref().expect("the effect dialog is back");
assert_eq!((d.kind.as_str(), d.fields.get("color")), ("effect", Some(&json!("#00ff00"))), "OK fills Color and comes back");
frame(&mut app, 0.6, vec![]);
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let fx = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0];
assert_eq!(fx.params.get("color"), Some(&json!("#00ff00")), "Outer Glow takes the picked colour");
}
/// Effect > Stylize > Drop Shadow and Outer Glow: Opacity, the offsets, Blur and Darkness have
/// spinner arrows (#5367, #5368). A click on the top half steps up, on the bottom half down;
/// Opacity stops at 100 % and Blur at 0, and OK applies the stepped values.
#[test]
fn drop_shadow_and_outer_glow_fields_have_spinner_arrows() {
for (effect, count) in [("stylize.dropShadow", 5), ("stylize.outerGlow", 2)] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
app.run("effect.dialog", json!({"effect": effect})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let time = std::cell::Cell::new(0.0);
// One frame → the spinners' bounds, top to bottom.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time.set(time.get() + 0.05);
let mut out =
ctx.run_ui(egui::RawInput { time: Some(time.get()), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let mut spinners: Vec<egui::Rect> = out
.platform_output
.accesskit_update
.map(|u| u.nodes)
.unwrap_or_default()
.iter()
.filter(|(_, n)| n.label() == Some("Step up or down"))
.filter_map(|(_, n)| n.bounds())
.map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)))
.collect();
spinners.sort_by(|a, b| a.top().total_cmp(&b.top()));
spinners
};
for _ in 0..6 {
frame(&mut app, vec![]);
}
let spinners = frame(&mut app, vec![]);
assert_eq!(spinners.len(), count, "{effect}: a spinner beside each number field: {spinners:?}");
let click = |app: &mut VectorcraftApp, r: egui::Rect, up: bool| {
let pos = r.center() + egui::vec2(0.0, if up { -r.height() / 4.0 } else { r.height() / 4.0 });
let button =
|pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(app, vec![egui::Event::PointerMoved(pos), button(true)]);
frame(app, vec![button(false)]);
frame(app, vec![]);
};
let (opacity, blur) = (spinners[0], spinners[count - 1 - usize::from(count == 5)]);
click(&mut app, opacity, true);
let field = |app: &VectorcraftApp, k: &str| app.ui.dialog.as_ref().unwrap().fields.get(k).and_then(Value::as_f64);
assert_eq!(field(&app, "opacity"), Some(76.0), "{effect}: the up arrow steps Opacity up");
click(&mut app, blur, false);
assert_eq!(field(&app, "blur"), Some(4.0), "{effect}: the down arrow steps Blur down");
app.ui.dialog.as_mut().unwrap().fields.insert("opacity".into(), json!(100.0));
app.ui.dialog.as_mut().unwrap().fields.insert("blur".into(), json!(0.0));
frame(&mut app, vec![]);
click(&mut app, opacity, true);
click(&mut app, blur, false);
assert_eq!((field(&app, "opacity"), field(&app, "blur")), (Some(100.0), Some(0.0)), "{effect}: the arrows stop at the ends");
click(&mut app, opacity, false);
if count == 5 {
click(&mut app, spinners[1], true);
assert_eq!(field(&app, "x"), Some(8.0), "the X offset steps up");
let darkness = spinners[4];
app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!("darkness"));
frame(&mut app, vec![]);
click(&mut app, darkness, false);
assert_eq!(field(&app, "darkness"), Some(99.0), "Darkness steps down");
}
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let fx = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0];
let param = |k: &str| fx.params.get(k).and_then(Value::as_f64);
assert_eq!((param("opacity"), param("blur")), (Some(99.0), Some(0.0)), "{effect}: OK applies them");
}
}
}