mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #4049 from dotCooCoo/fix/dialog-set-unknown-field
Dialogs: ui.dialog.set returns an error for a field the open dialog does not read
This commit is contained in:
@@ -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<i>` 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Value, String> {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -174,6 +174,12 @@ pub(crate) struct DialogSpec {
|
||||
pub max_width: Option<f32>,
|
||||
/// 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<fn(&VectorcraftApp) -> &'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<String> {
|
||||
let spec = spec(&d.kind);
|
||||
let mut names: Vec<String> = 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);
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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<Value, String> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user