From 0641412cce834519d1d05c3247ab7808cdb7df91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 22:10:16 -0700 Subject: [PATCH 1/2] Dialogs: ui.dialog.set returns an error for a field the open dialog does not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui.dialog.set stored any field name it was given, so a misspelled field (`discrad` for `discard`) was kept without effect and the dialog then ran with its old value. It now answers such a field with an error that lists the dialog's fields, and leaves the dialog unchanged. A dialog accepts the fields it holds, `__` fields, `discard` with a discard button, `preview` with a live preview, the fields it reads before a control adds them (each spec's `inputs`, for example Copy and the object options of the Transform dialogs, Move's `distance` and `angle`, the reference point, `path` of the export options dialogs, `searchFolder`, Brush Options' `tips`, the Justification and Hyphenation values that open blank, the Corners `kind` and `radius` of corners that differ, the Gradient Stop popover's `color`, `opacity`, `location` and `spread`, and a new artboard's `color` and display marks), and the fields it passes to the command it runs: every parameter of the generic `command` dialog's command, and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects. Edit Selection now opens holding `deleted` for each of its rows, so setting a row number it does not have returns an error, and Transparency Flattener Presets opens holding the selected preset's name and options instead of adding them on its first frame. --- crates/ui-egui/src/control.rs | 93 ++++++++++++++++++- .../ui-egui/src/dialogs/artboard_options.rs | 10 +- crates/ui-egui/src/dialogs/brush_options.rs | 2 +- crates/ui-egui/src/dialogs/command.rs | 7 ++ crates/ui-egui/src/dialogs/corners.rs | 10 +- crates/ui-egui/src/dialogs/dxf_options.rs | 1 + crates/ui-egui/src/dialogs/edit_selection.rs | 1 + crates/ui-egui/src/dialogs/effect.rs | 2 + crates/ui-egui/src/dialogs/effect_gallery.rs | 1 + crates/ui-egui/src/dialogs/eps_options.rs | 1 + crates/ui-egui/src/dialogs/find_links.rs | 1 + .../ui-egui/src/dialogs/flattener_presets.rs | 32 +++++-- crates/ui-egui/src/dialogs/gradient_stop.rs | 2 +- crates/ui-egui/src/dialogs/hyphenation.rs | 10 +- crates/ui-egui/src/dialogs/justification.rs | 16 ++++ crates/ui-egui/src/dialogs/missing_fonts.rs | 1 + crates/ui-egui/src/dialogs/missing_links.rs | 1 + crates/ui-egui/src/dialogs/mod.rs | 34 +++++++ crates/ui-egui/src/dialogs/new_document.rs | 4 +- crates/ui-egui/src/dialogs/office_export.rs | 11 ++- crates/ui-egui/src/dialogs/png_options.rs | 3 +- crates/ui-egui/src/dialogs/save_for_web.rs | 1 + crates/ui-egui/src/dialogs/save_options.rs | 3 +- crates/ui-egui/src/dialogs/save_pdf.rs | 3 +- crates/ui-egui/src/dialogs/shapes.rs | 8 +- crates/ui-egui/src/dialogs/svg_options.rs | 1 + crates/ui-egui/src/dialogs/transform.rs | 12 ++- crates/ui-egui/src/dialogs/transform_each.rs | 11 ++- docs/control-protocol.md | 3 +- 29 files changed, 257 insertions(+), 28 deletions(-) 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..3da97c4a6 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 { 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 | From 8c67ade8248f695c76496020df780ff0ab086389 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:50:53 -0700 Subject: [PATCH 2/2] Dialogs: Move runs with a Distance and Angle set before its next frame Move opened without Distance and Angle, so its first frame replaced the values a control client set with ones computed from Horizontal and Vertical, and OK read Horizontal and Vertical without the Distance and Angle set since the last frame. Move now opens holding the Distance and Angle of its offset, and OK applies a Distance and Angle set since the last frame. --- crates/ui-egui/src/dialogs/transform.rs | 54 +++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 3da97c4a6..5bf48a739 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -86,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"] { @@ -217,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)?; @@ -395,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]