diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3a5648e79..451dcb0c5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,7 +15,8 @@ //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` -//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` +//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` (setting a field the +//! dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]) //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) //! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its @@ -409,6 +410,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let Some(f) = s("field") else { return err("missing `field`") }; + if !crate::dialogs::accepts_field(d, f) { + let names = crate::dialogs::field_names(d).join(", "); + return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); + } d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); ok(serde_json::to_value(&*d).unwrap_or_default()) } @@ -583,6 +588,7 @@ mod tests { use vectorcraft_engine::Session; use super::{ControlRequest, Outcome, handle}; + use crate::state::Dialog; use crate::{Services, VectorcraftApp}; fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value { @@ -783,4 +789,89 @@ mod tests { assert_eq!(click(&mut app), Some(b), "a click on a selected object makes it the key"); assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go"); } + + /// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields, + /// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard` + /// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`. + #[test] + fn dialog_set_refuses_a_field_the_dialog_does_not_have() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let fields = json!({"index": 0, "uid": 1, "name": "Untitled-1", "then": "close"}); + app.ui.dialog = Some(Dialog::new(crate::unsaved::KIND, fields.clone())); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "discrad", "value": true})); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["error"], "the `saveChanges` dialog has no field `discrad`; its fields: discard, index, name, then, uid"); + assert_eq!(app.ui.dialog.as_ref().map(|d| Value::Object(d.fields.clone())), Some(fields)); + for field in ["name", "discard", "__note"] { + let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], true, "{field}: {r}"); + } + // Move opens without Copy, which its checkbox adds once clicked; Scale's options and the + // reference point belong to the other transform dialogs. + let set = |app: &mut VectorcraftApp, kind: &str, fields: Value, cases: &[(&str, bool)]| { + app.ui.dialog = Some(Dialog::new(kind, fields)); + for (field, ok) in cases { + let r = input_request(app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], *ok, "{kind} {field}: {r}"); + } + }; + let moved = + [("copy", true), ("preview", true), ("distance", true), ("discard", false), ("copies", false), ("corners", false), ("origin", false)]; + set(&mut app, "move", json!({"dx": "0 pt", "dy": "0 pt"}), &moved); + set( + &mut app, + "scale", + json!({"sx": 100, "sy": 100, "uniform": true}), + &[("corners", true), ("strokes", true), ("origin", true), ("distance", false)], + ); + set(&mut app, "rotate", json!({"angle": 0}), &[("origin", true), ("objects", true), ("corners", false)]); + set(&mut app, "rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", true), ("sides", false)]); + set(&mut app, "ellipse", json!({"x": 0, "y": 0, "width": 10, "height": 10}), &[("radius", false)]); + // Corners that differ in kind and radius open without either. + let mixed = json!({"id": 1, "corners": [0, 1, 2, 3], "relativeRadii": false}); + set(&mut app, vectorcraft_tools::corners::DIALOG, mixed, &[("kind", true), ("radius", true), ("radii", false)]); + set(&mut app, "saveForOffice", json!({"ppi": 150, "transparent": false, "artboard": 0}), &[("path", true)]); + set(&mut app, "brushOptions", json!({"name": "Dots"}), &[("tips", true), ("tip", false)]); + set(&mut app, "justification", json!({"wordDesired": 100}), &[("wordMin", true), ("singleWord", true), ("wordMid", false)]); + set(&mut app, "hyphenation", json!({"hyphenate": true}), &[("afterFirst", true), ("hyphenationZone", true), ("afterfirst", false)]); + set(&mut app, "effectGallery", json!({"stack": []}), &[("browseEffect", true), ("browseParams", true), ("browse", false)]); + // A new artboard's dialog opens without a background color or display marks. + let new_board = json!({"new": true, "name": "Artboard 2", "x": 0, "y": 0, "width": 10, "height": 10}); + let marks = [ + ("color", true), + ("showCenterMark", true), + ("showCrossHairs", true), + ("showVideoSafeAreas", true), + ("videoRulerPixelAspectRatio", true), + ("showCenterMarks", false), + ]; + set(&mut app, "artboardOptions", new_board, &marks); + // Glass and Texturizer take the texture Load Texture… reads; other effects don't. + set(&mut app, "effect", json!({"__effect": "distort.glass"}), &[("textureImage", true)]); + set(&mut app, "effect", json!({"__effect": "blur.gaussian"}), &[("textureImage", false)]); + // The generic dialog takes every parameter of its command. + set(&mut app, "command", json!({"__command": "shape.rectangle", "x": 0, "y": 0}), &[("width", true), ("widht", false)]); + // Transparency Flattener Presets holds the selected preset's options before its first frame. + crate::dialogs::flattener_presets::open(&mut app, None); + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "textToOutlines", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + // Edit Selection takes `deleted` for each of its rows only. + app.run("file.new", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + for name in ["A", "B"] { + app.run("select.save", json!({ "name": name })).unwrap(); + } + crate::dialogs::edit_selection::open(&mut app).unwrap(); + for (field, ok) in [("deleted1", true), ("deleted", false), ("deletedx", false), ("deleted01", false), ("deleted2", false)] { + let r = input_request(&mut app, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["ok"], ok, "{field}: {r}"); + } + let r = input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"})); + assert_eq!( + r["error"], + "the `editSelection` dialog has no field `name`; its fields: count, deleted0, deleted1, name0, name1, orig0, orig1, selected" + ); + app.ui.dialog = None; + assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"field": "name", "value": "A"}))["error"], "no dialog open"); + } } diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index 233d9648b..94ec54317 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -23,7 +23,15 @@ use crate::panels::properties::ArtboardSizeChoice; use crate::panels::transform::{artboard_rect, reference_index, reference_locator}; use crate::state::Dialog; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), body, confirm, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Artboard Options").into(), + body, + confirm, + // A new artboard's dialog opens without these; its swatch, checkboxes and Video Ruler Pixel + // Aspect Ratio field add them. + inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT], + ..DialogSpec::FORM +}; /// Where the fields put the artboard: `x` and `y` are its top-left corner in document points, as /// `artboard.setProps` and `artboard.new` take them. diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index 3d6dc863c..542dfb75c 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -30,7 +30,7 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of Brush Options. pub const KIND: &str = "brushOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm: ok, min_width: 440.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm: ok, min_width: 440.0, inputs: |_| &["tips"], ..DialogSpec::FORM }; /// The field of a Brush Options dialog that New Brush opened. const CREATE: &str = "__create"; diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index e4237ca27..8e8196ade 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -19,9 +19,16 @@ pub(super) const SPEC: DialogSpec = DialogSpec { false }, confirm, + passes: takes, ..DialogSpec::FORM }; +/// Does the dialog's command take parameter `key` (its params description names it)? +fn takes(d: &Dialog, key: &str) -> bool { + let id = d.str("__command"); + vectorcraft_engine::find_command(&id).is_some_and(|c| vectorcraft_engine::check_keys(&id, c.params, &serde_json::json!({ key: null })).is_ok()) +} + /// The parameters of the commands this dialog edits that are distances. fn lengths(command: &str) -> &'static [&'static str] { match command { diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 2d72072e6..939acd9c9 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -21,7 +21,15 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of Corners (the corner widgets' double-click). pub const KIND: &str = vectorcraft_tools::corners::DIALOG; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").into(), body, confirm, min_width: 280.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Corners").into(), + body, + confirm, + min_width: 280.0, + // Corners that differ open without these; the kind buttons and the radius field add them. + inputs: |_| &["kind", "radius"], + ..DialogSpec::FORM +}; /// Open Corners for `{id?, corners?}`: the path `id` (default: the selected one) and its /// `corners` (default: the selected corners), filled in with their kind and radius. diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index 3593dfe91..dc6a54d16 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -25,6 +25,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), + inputs: |_| &["path"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/edit_selection.rs b/crates/ui-egui/src/dialogs/edit_selection.rs index 55adb3b1e..23c283a87 100644 --- a/crates/ui-egui/src/dialogs/edit_selection.rs +++ b/crates/ui-egui/src/dialogs/edit_selection.rs @@ -28,6 +28,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { for (i, n) in names.iter().enumerate() { fields[format!("orig{i}")] = json!(n); fields[format!("name{i}")] = json!(n); + fields[format!("deleted{i}")] = json!(false); } app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index ddd462830..695c3d454 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -22,6 +22,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { 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 }; diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index 92af1125f..4fe560bb5 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -30,6 +30,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { preview: true, min_width: 720.0, max_width: Some(820.0), + inputs: |_| &["browseEffect", "browseParams"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs index e2c24e12b..3a20a6799 100644 --- a/crates/ui-egui/src/dialogs/eps_options.rs +++ b/crates/ui-egui/src/dialogs/eps_options.rs @@ -25,6 +25,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), + inputs: |_| &["path"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 160721561..415cf3a8e 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -39,6 +39,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok: Some("Relink"), min_width: 440.0, max_width: Some(560.0), + inputs: |_| &["searchFolder"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index 5a546b532..e95d0e683 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -8,8 +8,8 @@ //! edited; on a built-in preset they go back). use serde_json::{Value, json}; -use vectorcraft_engine::cmd::FlattenOptions; use vectorcraft_engine::cmd::flatten::{PRESET_EXTS, PRESET_FORMAT}; +use vectorcraft_engine::cmd::{FlattenOptions, FlattenerPreset}; use super::flatten::{options_editor, put_options}; use super::{DialogSpec, form}; @@ -35,10 +35,28 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ..DialogSpec::FORM }; -/// Open the presets manager on `selected` (default: the first preset). +/// Open the presets manager on `selected` (default: the first preset), holding its name and +/// options. pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) { let first = FlattenOptions::preset_label(FlattenOptions::PRESETS[0]).unwrap_or_default(); - app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(first) }))); + let mut d = Dialog::new(KIND, json!({ "selected": selected.unwrap_or(first) })); + show_selected(&app.session.flattener_presets(), &mut d); + app.ui.dialog = Some(d); +} + +/// The index in `presets` of the preset `selected` names (the first when none), whose name and +/// options the fields take when they hold another preset's. +fn show_selected(presets: &[FlattenerPreset], d: &mut Dialog) -> usize { + let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0); + if let Some(preset) = presets.get(i) + && d.str(SHOWN) != preset.name + { + put_options(&mut d.fields, &preset.options); + for k in ["selected", "name", SHOWN] { + d.fields.insert(k.into(), json!(preset.name)); + } + } + i } /// What a button asked for, done once the list and fields are drawn. @@ -54,14 +72,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let presets = app.session.flattener_presets(); let builtins = FlattenOptions::PRESETS.len(); - let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0); + let i = show_selected(&presets, d); let (preset, builtin) = (&presets[i], i < builtins); - if d.str(SHOWN) != preset.name { - put_options(&mut d.fields, &preset.options); - for k in ["selected", "name", SHOWN] { - d.fields.insert(k.into(), json!(preset.name)); - } - } let mut act = None; let mut renaming = false; ui.horizontal_top(|ui| { diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 0c6f39b8b..b188ad265 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -27,7 +27,7 @@ use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::{self, Live}; -pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); +pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["color", "opacity", "location", "spread"], ..DialogSpec::window(show, confirm) }; /// The field holding the frame the popover was first drawn in. const OPENED: &str = "__opened"; diff --git a/crates/ui-egui/src/dialogs/hyphenation.rs b/crates/ui-egui/src/dialogs/hyphenation.rs index d982f37c0..a8e0dac7d 100644 --- a/crates/ui-egui/src/dialogs/hyphenation.rs +++ b/crates/ui-egui/src/dialogs/hyphenation.rs @@ -23,7 +23,15 @@ pub const KIND: &str = "hyphenation"; /// The field holding the values the dialog opened with. const INITIAL: &str = "__initial"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Hyphenation").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Hyphenation").into(), + body, + confirm, + min_width: 300.0, + // A value the paragraphs don't share opens blank, without its field. + inputs: |_| &["hyphenate", "wordsLongerThan", "afterFirst", "beforeLast", "hyphenLimit", "hyphenationZone", "hyphenateCapitalized"], + ..DialogSpec::FORM +}; /// Open Hyphenation on the paragraph attributes the Paragraph panel shows. pub fn open(app: &mut VectorcraftApp) -> Result { diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index 054c08159..5e8c5b6e6 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -38,6 +38,22 @@ pub(super) const SPEC: DialogSpec = DialogSpec { preview: true, min_width: 460.0, ok_enabled: Some(|_, d| problem(d).is_none()), + // A value the paragraphs don't share opens blank, without its field. + inputs: |_| { + &[ + "wordMin", + "wordDesired", + "wordMax", + "letterMin", + "letterDesired", + "letterMax", + "glyphMin", + "glyphDesired", + "glyphMax", + "autoLeading", + "singleWord", + ] + }, ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index 45130b92a..d27a56b39 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -37,6 +37,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { discard: Some("Find Fonts"), min_width: 440.0, max_width: Some(540.0), + inputs: |_| &["searchFolder"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 5ea9d77b8..b71705769 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -29,6 +29,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { discard: Some("Ignore"), min_width: 380.0, max_width: Some(460.0), + inputs: |_| &["searchFolder"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index d0289780c..60f9cb05f 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -174,6 +174,12 @@ pub(crate) struct DialogSpec { pub max_width: Option, /// The body runs a live preview interaction that Cancel rolls back. pub preview: bool, + /// The fields that a dialog of a kind reads but may not hold yet, which `ui.dialog.set` also + /// takes (see [`accepts_field`]). + pub inputs: fn(&str) -> &'static [&'static str], + /// Whether the dialog passes a field it neither holds nor lists in `inputs` on to what it + /// runs (a parameter of the generic dialog's command), so `ui.dialog.set` takes it too. + pub passes: fn(&Dialog, &str) -> bool, /// The OK button's label when it depends on the app (Export for Screens' "Download" on the /// web); `ok` when none. Only with an `ok`. pub ok_label: Option &'static str>, @@ -201,6 +207,8 @@ impl DialogSpec { min_width: 320.0, max_width: None, preview: false, + inputs: |_| &[], + passes: |_, _| false, ok_label: None, ok_enabled: None, }; @@ -378,6 +386,32 @@ fn spec(kind: &str) -> &'static DialogSpec { DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec) } +/// Whether `ui.dialog.set` can set `field` on `d`: a field it holds, a `__` field (the dialog's +/// own state), `discard` on a dialog with a discard button, `preview` on one with a live preview, +/// one of its spec's `inputs` for its kind, or one it `passes` on. +pub fn accepts_field(d: &Dialog, field: &str) -> bool { + let spec = spec(&d.kind); + d.fields.contains_key(field) + || field.starts_with("__") + || (field == "discard" && spec.discard.is_some()) + || (field == "preview" && spec.preview) + || (spec.inputs)(&d.kind).contains(&field) + || (spec.passes)(d, field) +} + +/// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, +/// `discard`, `preview` and the spec's `inputs` for its kind. +pub fn field_names(d: &Dialog) -> Vec { + let spec = spec(&d.kind); + let mut names: Vec = d.fields.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + names.extend(spec.discard.map(|_| "discard".to_string())); + names.extend(spec.preview.then(|| "preview".to_string())); + names.extend((spec.inputs)(&d.kind).iter().map(|i| i.to_string())); + names.sort_unstable(); + names.dedup(); + names +} + /// Run a command and close the dialog (whether or not the command succeeded). fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogResult { let r = app.run(id, params); diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index f5e34f998..dde5c83fb 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -23,8 +23,8 @@ pub(super) const KIND: &str = "newDocument"; /// More Settings. pub(super) const MORE: &str = "newDocumentMore"; -pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); -pub(super) const MORE_SPEC: DialogSpec = DialogSpec::window(show_more, confirm); +pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show, confirm) }; +pub(super) const MORE_SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], ..DialogSpec::window(show_more, confirm) }; /// The fields `file.new` takes. const PARAMS: [&str; 12] = [ diff --git a/crates/ui-egui/src/dialogs/office_export.rs b/crates/ui-egui/src/dialogs/office_export.rs index 59d6d1a56..45893bf15 100644 --- a/crates/ui-egui/src/dialogs/office_export.rs +++ b/crates/ui-egui/src/dialogs/office_export.rs @@ -11,8 +11,15 @@ use crate::{VectorcraftApp, io, widgets}; pub(crate) const KIND: &str = "saveForOffice"; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| tl!("Save for Office Documents").into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Save for Office Documents").into(), + body, + confirm, + ok: Some("Save…"), + min_width: 340.0, + inputs: |_| &["path"], + ..DialogSpec::FORM +}; /// Open the dialog (150 ppi on white, the first artboard). pub fn open(app: &mut VectorcraftApp) -> Result { diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index d21692ac3..04257bc64 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -18,7 +18,8 @@ use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = + DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, inputs: |_| &["path"], ..DialogSpec::FORM }; /// Resolution presets (pixels per inch). pub(super) const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 21494d783..743e56ff9 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -40,6 +40,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Save…" }), discard: Some("Done"), min_width: PREVIEW_W + GAP + SIDE_W, + inputs: |_| &["path", "preset"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 9118252b3..1b3c98816 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -27,7 +27,8 @@ use crate::{VectorcraftApp, io, widgets}; /// Dialog kind. pub const KIND: &str = "saveOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ok_enabled: Some(can_save), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = + DialogSpec { heading, body, confirm, min_width: 380.0, ok_enabled: Some(can_save), inputs: |_| &["all", "boards"], ..DialogSpec::FORM }; /// OK saves a legacy `.ai` file only with an artboard checked. fn can_save(app: &VectorcraftApp, d: &Dialog) -> bool { diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 685255391..9e608785d 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -27,7 +27,8 @@ use crate::{VectorcraftApp, io, widgets}; pub(super) const KIND: &str = "savePdf"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Save PDF").into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Save PDF").into(), body, confirm, ok: Some("Save PDF"), inputs: |_| &["path"], ..DialogSpec::FORM }; /// The dialog kind of the preset editor (New / Edit in Edit → PDF Presets). pub(super) const PRESET_KIND: &str = "pdfPreset"; diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index f7965965a..bc9f2916f 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -14,7 +14,13 @@ use crate::panels::transform::artboard_rect; use crate::state::Dialog; use crate::widgets; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(d).into(), body, confirm, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| title(d).into(), + body, + confirm, + inputs: |kind| if kind == "rectangle" { &["radius"] } else { &[] }, + ..DialogSpec::FORM +}; /// The field that marks a dialog opened as its tool's Tool Options rather than by a click. const TOOL_OPTIONS: &str = "__toolOptions"; diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 363fa00cc..261d51b8a 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -19,6 +19,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { ok: Some("OK"), min_width: 380.0, max_width: Some(560.0), + inputs: |_| &["path", "showCode"], ..DialogSpec::FORM }; diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 72dba0074..5bf48a739 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -13,7 +13,17 @@ use super::{DialogSpec, form, run_and_close}; use crate::VectorcraftApp; use crate::state::Dialog; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, preview: true, inputs, ..DialogSpec::FORM }; + +/// The fields a transform dialog reads that it may not hold yet: Copy and the object options, +/// Move's Distance and Angle, Scale's scale options, and the reference point of the others. +fn inputs(kind: &str) -> &'static [&'static str] { + match kind { + "move" => &["copy", "objects", "patterns", "distance", "angle"], + "scale" => &["copy", "objects", "patterns", "corners", "strokes", "origin"], + _ => &["copy", "objects", "patterns", "origin"], + } +} fn title(kind: &str) -> &'static str { match kind { @@ -76,12 +86,19 @@ fn clean(v: f64) -> f64 { (v * 1e6).round() / 1e6 + 0.0 } -/// The Move dialog's fields as it opens: Horizontal and Vertical are the offset moved last, and -/// Copy, Transform Objects and Preview are as it was last confirmed with (Transform Patterns is -/// General › Transform Pattern Tiles, which its OK sets). +/// The Move dialog's fields as it opens: Horizontal and Vertical are the offset moved last, Distance +/// and Angle are the same offset, and Copy, Transform Objects and Preview are as it was last +/// confirmed with (Transform Patterns is General › Transform Pattern Tiles, which its OK sets). pub(crate) fn move_fields(app: &VectorcraftApp) -> Value { let (dx, dy) = last_move(app).unwrap_or_default(); - let mut fields = json!({"dx": format!("{} pt", clean(dx)), "dy": format!("{} pt", clean(dy))}); + let [dx, dy, distance, angle] = [dx, dy, dx.hypot(dy), (-dy).atan2(dx).to_degrees()].map(clean); + let mut fields = json!({ + "dx": format!("{dx} pt"), + "dy": format!("{dy} pt"), + "distance": distance, + "angle": angle, + "__polar": [dx, dy, distance, angle], + }); let kept = &app.ui.move_options; let kept_bool = |k: &str| kept.get(k).and_then(Value::as_bool); for k in ["copy", "objects", "preview"] { @@ -207,6 +224,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if app.session.in_interaction() { app.session.cancel_interaction().map_err(|e| e.to_string())?; } + // A Distance and Angle set since the last frame set the offset Move runs with. + let mut d = d.clone(); + if d.kind == "move" { + link_distance_and_angle(&mut d); + } + let d = &d; let (id, mut params) = command(app, d); if d.kind == "scale" { params = with_scale_options(app, d, params)?; @@ -385,6 +408,39 @@ mod tests { assert_eq!((b.x0, b.y0), (100.0, 90.0)); } + /// A Distance and Angle set before the dialog's first frame, or just before OK, move by that + /// offset (as `ui.dialog.set` then `ui.dialog.confirm` from a control client). + #[test] + fn move_takes_a_distance_and_an_angle_set_before_a_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let corner = |app: &VectorcraftApp| { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.x0, b.y0) + }; + // Up 10 pt with no frame, then right 20 pt (Move opens on the last offset) with one. + for (with_frame, distance, angle, want, corner_after) in + [(false, 10.0, 90.0, (0.0, -10.0), (100.0, 90.0)), (true, 20.0, 0.0, (20.0, 0.0), (120.0, 90.0))] + { + crate::menus::invoke(&mut app, "object.move", json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.extend([("distance".to_string(), json!(distance)), ("angle".to_string(), json!(angle))]); + if with_frame { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(["distance", "angle"].map(|k| d.f64(k, f64::NAN)), [distance, angle], "the first frame keeps them"); + } + crate::dialogs::confirm(&mut app).unwrap(); + let (cmd, p) = app.session.journal.last().unwrap(); + assert_eq!((cmd.as_str(), &p["dx"], &p["dy"]), ("object.move", &json!(want.0), &json!(want.1)), "with a frame: {with_frame}"); + assert_eq!(corner(&app), corner_after); + } + } + /// Move opens on the offset of the last drag or Move, and with the checkboxes it was last /// confirmed with: Enter, Enter makes copies at the same spacing. #[test] diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 88ce141ce..634810d29 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -21,8 +21,15 @@ pub const KIND: &str = "transformEach"; const CMD: &str = "object.transformEach"; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| tl!("Transform Each").into(), body, confirm, preview: true, min_width: 360.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Transform Each").into(), + body, + confirm, + preview: true, + min_width: 360.0, + inputs: |_| &["objects", "patterns", "corners", "strokes"], + ..DialogSpec::FORM +}; /// The fields a fresh dialog starts with. pub fn fields() -> Value { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1bd469ac0..da6078a61 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,8 +102,9 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |