Files
vectorcraft/crates/ui-egui/src/dialogs/envelope.rs
T
bflatasticandClaude Opus 5.5 fe6d6640fb M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
Make with Warp and Make with Mesh open dedicated dialogs (style dropdown,
orientation, bend and distortion sliders; rows and columns) with live
preview; while an envelope is selected the menu shows Reset with Warp /
Reset with Mesh instead, which switch the envelope's kind and keep its
content (Maintain Envelope Shape samples the current surface). Envelope
Options is a full dialog prefilled from the selection (Anti-Alias,
Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients,
Distort Pattern Fills); with nothing selected it sets the defaults for new
envelopes. object.envelope.info reads the settings. Release returns the
warp or mesh shape as a gradient mesh and keeps opacity, blend mode and
mask; Expand keeps name, knockout, isolation and mask; Object > Expand
expands envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:11:10 +07:00

347 lines
15 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.
//! Object → Envelope Distort's dialogs, previewed live on the canvas; OK keeps the preview as one
//! undo step:
//!
//! - **Warp Options** (`envelopeWarp`): Make with Warp, or Reset with Warp for a selected envelope
//! (`reset`). Fields `style` (one of the 15 warp styles), `horizontal` (false: Vertical),
//! `bend`, `h` and `v` (%, -100..100).
//! - **Envelope Mesh** (`envelopeMesh`): Make with Mesh, or Reset with Mesh (`reset`, with
//! `maintainShape`). Fields `rows`, `cols` (1..50).
//! - **Envelope Options** (`envelopeOptions`): the selected envelopes' options, or with none
//! selected the options new envelopes get (no preview then). Fields `antiAlias`,
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
//!
//! Every dialog also has `preview`. They open with the selected envelope's values
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
//! open the Reset variant while an envelope is selected, as the reference app's do.
use serde_json::{Value, json};
use vectorcraft_render::effects::WARP_STYLES;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kinds.
pub const WARP: &str = "envelopeWarp";
pub const MESH: &str = "envelopeMesh";
pub const OPTIONS: &str = "envelopeOptions";
const MAKE_WARP: &str = "object.envelope.makeWithWarp";
const RESET_WARP: &str = "object.envelope.resetWithWarp";
const MAKE_MESH: &str = "object.envelope.makeWithMesh";
const RESET_MESH: &str = "object.envelope.resetWithMesh";
const OPTIONS_CMD: &str = "object.envelope.options";
/// Width of the label column.
const LABEL_W: f32 = 84.0;
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
fn heading(d: &Dialog) -> String {
match d.kind.as_str() {
WARP => "Warp Options",
MESH if d.bool("reset") => "Reset Envelope Mesh",
MESH => "Envelope Mesh",
_ => "Envelope Options",
}
.into()
}
/// Does the menu item of command `id` open one of these dialogs?
pub fn opens(id: &str) -> bool {
matches!(id, MAKE_WARP | RESET_WARP | MAKE_MESH | RESET_MESH | OPTIONS_CMD)
}
/// Open the dialog command `id`'s menu item opens, with the selected envelope's values.
pub fn open(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let info = app.session.execute("object.envelope.info", &json!({})).map_err(|e| e.to_string())?;
let envelope = !info["id"].is_null();
if matches!(id, RESET_WARP | RESET_MESH) && !envelope {
return Err("select an envelope".into());
}
let num = |k: &str, d: f64| info[k].as_f64().unwrap_or(d);
let (kind, fields) = match id {
MAKE_WARP | RESET_WARP => {
let warp = info["type"] == "warp";
let own = |k: &str, d: f64| if warp { num(k, d) } else { d };
let style = if warp { info["style"].clone() } else { json!("arc") };
let horizontal = !warp || info["horizontal"] != false;
(
WARP,
json!({"reset": envelope, "style": style, "horizontal": horizontal, "bend": own("bend", 50.0), "h": own("h", 0.0), "v": own("v", 0.0), "preview": true}),
)
}
MAKE_MESH | RESET_MESH => {
let mesh = info["type"] == "mesh";
let own = |k: &str| if mesh { num(k, 4.0) } else { 4.0 };
(MESH, json!({"reset": envelope, "rows": own("rows"), "cols": own("cols"), "maintainShape": true, "preview": true}))
}
OPTIONS_CMD => {
let mut f = json!({"__envelope": envelope, "preview": envelope});
for k in ["antiAlias", "preserveShape", "fidelity", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
f[k] = info[k].clone();
}
(OPTIONS, f)
}
_ => return Err(format!("`{id}` has no envelope dialog")),
};
app.ui.dialog = Some(Dialog::new(kind, fields));
Ok(Value::Null)
}
/// The command OK runs, its parameters and the preview's undo label.
fn command(d: &Dialog) -> (&'static str, Value, &'static str) {
let reset = d.bool("reset");
match d.kind.as_str() {
WARP => {
let p = json!({"style": d.str("style"), "horizontal": d.bool("horizontal"), "bend": d.f64("bend", 50.0), "h": d.f64("h", 0.0), "v": d.f64("v", 0.0)});
if reset { (RESET_WARP, p, "Reset with Warp") } else { (MAKE_WARP, p, "Make Envelope") }
}
MESH => {
let p = json!({"rows": d.f64("rows", 4.0), "cols": d.f64("cols", 4.0)});
if reset {
let mut p = p;
p["maintainShape"] = json!(d.bool("maintainShape"));
(RESET_MESH, p, "Reset with Mesh")
} else {
(MAKE_MESH, p, "Make Envelope")
}
}
_ => {
let mut p = json!({"fidelity": d.f64("fidelity", 50.0), "preserveShape": d.str("preserveShape")});
for k in ["antiAlias", "distortAppearance", "distortLinearGradients", "distortPatternFills"] {
p[k] = json!(d.bool(k));
}
(OPTIONS_CMD, p, "Envelope Options")
}
}
}
fn set(d: &mut Dialog, key: &str, v: Value) {
d.fields.insert(key.into(), v);
}
/// A −100..100 % slider row.
fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
}
/// A whole number field row clamped to 1..50.
fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) {
set(d, key, json!(n.round().clamp(1.0, 50.0)));
}
});
}
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
let horizontal = d.bool("horizontal");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
if widgets::radio(ui, label, horizontal == h, true) {
set(d, "horizontal", json!(h));
}
ui.add_space(10.0);
}
});
percent(ui, d, "bend", "Bend:");
ui.add_space(6.0);
widgets::subheader(ui, "Distortion");
percent(ui, d, "h", "Horizontal:");
percent(ui, d, "v", "Vertical:");
}
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
count(ui, d, "rows", "Rows:");
count(ui, d, "cols", "Columns:");
if d.bool("reset") {
ui.add_space(4.0);
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
}
}
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::subheader(ui, "Rasters");
ui.horizontal(|ui| {
ui.add_space(12.0);
form::check(ui, d, "antiAlias", "Anti-Alias");
});
ui.add_space(4.0);
widgets::subheader(ui, "Preserve Shape Using:");
let clip = d.str("preserveShape") != "transparency";
ui.horizontal(|ui| {
ui.add_space(12.0);
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
set(d, "preserveShape", json!(value));
}
ui.add_space(10.0);
}
});
ui.add_space(4.0);
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
ui.add_space(4.0);
form::check(ui, d, "distortAppearance", "Distort Appearance");
// Gradients and patterns bend only with the appearance.
let appearance = d.bool("distortAppearance");
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(20.0);
if widgets::check(ui, label, on, appearance) {
set(d, key, json!(!on));
}
});
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
match d.kind.as_str() {
WARP => warp_body(ui, d),
MESH => mesh_body(ui, d),
_ => options_body(ui, d),
}
// With no envelope selected, Envelope Options sets the defaults: nothing to preview.
if d.kind != OPTIONS || d.bool("__envelope") {
let (cmd, p, label) = command(d);
form::preview(app, ui, d, label, cmd, p);
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (cmd, p, _) = command(d);
form::commit_preview(app, cmd, p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::live::EnvelopeKind;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_engine::Session;
fn app_with_rect() -> (VectorcraftApp, NodeId) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.all", json!({})).unwrap();
(app, NodeId(id))
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn envelope(app: &VectorcraftApp) -> (NodeId, vectorcraft_doc::Node) {
let st = app.session.doc().unwrap();
let id = st.selection.objects[0];
(id, st.doc.node(id).unwrap().clone())
}
#[test]
fn warp_dialog_previews_and_ok_keeps_one_step() {
let (mut app, rect) = app_with_rect();
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.fields["reset"].clone())), Some((WARP, json!(false))));
frame(&mut app);
// The preview made the envelope already.
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { .. }));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("style".into(), json!("flag"));
d.fields.insert("horizontal".into(), json!(false));
d.fields.insert("bend".into(), json!(-30));
frame(&mut app);
let undo = app.session.doc().unwrap().history.undo.len();
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let (_, n) = envelope(&app);
match &n.kind {
NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => {
assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false));
assert_eq!(content[0].id, rect);
}
k => panic!("{k:?}"),
}
// With the envelope selected, the same item resets it, starting from its values.
crate::menus::invoke(&mut app, MAKE_WARP, json!({}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0)));
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back");
}
#[test]
fn reset_items_take_the_make_items_place_while_an_envelope_is_selected() {
let labels = |app: &VectorcraftApp| -> Vec<String> {
crate::menus::menu_entries(app)
.into_iter()
.filter(|e| e.path.last().map(String::as_str) == Some("Envelope Distort"))
.map(|e| e.label)
.collect()
};
let (mut app, _) = app_with_rect();
let before = labels(&app);
assert!(before.contains(&"Make with Warp…".to_string()) && !before.contains(&"Reset with Warp…".to_string()), "{before:?}");
app.run(MAKE_MESH, json!({})).unwrap();
let after = labels(&app);
assert!(after.contains(&"Reset with Warp…".to_string()) && after.contains(&"Reset with Mesh…".to_string()), "{after:?}");
assert!(!after.contains(&"Make with Warp…".to_string()) && !after.contains(&"Make with Mesh…".to_string()), "{after:?}");
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_ok());
app.run("select.none", json!({})).unwrap();
assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_err(), "nothing to reset");
}
#[test]
fn reset_mesh_dialog_switches_the_kind_and_cancel_rolls_back() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap();
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH));
frame(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed");
crate::dialogs::cancel(&mut app);
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }));
app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2));
super::super::confirm(&mut app).unwrap();
assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. }));
}
#[test]
fn options_dialog_starts_from_the_envelope_and_without_one_sets_the_defaults() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({})).unwrap();
app.run("object.envelope.options", json!({"fidelity": 80, "distortPatternFills": true})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.fields["fidelity"].clone(), d.fields["distortPatternFills"].clone()), (json!(80.0), json!(true)));
d.fields.insert("preserveShape".into(), json!("transparency"));
frame(&mut app);
super::super::confirm(&mut app).unwrap();
let info = app.session.execute("object.envelope.info", &json!({})).unwrap();
assert_eq!((info["preserveShape"].clone(), info["fidelity"].clone()), (json!("transparency"), json!(80.0)));
// Nothing selected: the defaults for new envelopes, no preview.
app.run("select.none", json!({})).unwrap();
crate::menus::invoke(&mut app, OPTIONS_CMD, json!({}));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields["preview"], json!(false));
d.fields.insert("distortAppearance".into(), json!(false));
frame(&mut app);
assert!(!app.session.in_interaction());
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
}
}