M3.8: split the paint/panel command modules and the dialogs into per-area files

No behaviour change: every command keeps its id, label, menu, shortcut and
params doc.

- Engine: swatch.* -> cmd/swatch.rs, graphicStyle.* -> cmd/style.rs,
  appearance.* (incl. copyFrom) -> cmd/appearance.rs, stroke.set/setAdvanced
  -> cmd/stroke.rs, paint.editGradient/setGradientGeom -> cmd/gradient.rs.
  Shared helpers in cmd/mod.rs: leaf_targets/paint_targets (replacing three
  copies) and unique_name (swatch and graphic style naming).
- UI: dialogs.rs -> dialogs/ with one file per dialog and a DialogSpec
  registry (one DialogKind row per dialog); shared field widgets in
  dialogs/form.rs. Headless frame tests draw and confirm every dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 11:22:23 +07:00
co-authored by Claude Opus 5.5
parent d99853ed2a
commit b7969a6a5d
30 changed files with 2159 additions and 1546 deletions
+39
View File
@@ -0,0 +1,39 @@
//! Help → About VectorCraft.
use crate::VectorcraftApp;
use crate::theme;
/// The About window, while `UiState::about` is set.
pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if !app.ui.about {
return;
}
let mut open = true;
egui::Window::new("About VectorCraft")
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18)))
.show(ctx, |ui| {
ui.set_width(380.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover());
crate::brand::paint_mark(ui.painter(), r);
ui.vertical(|ui| {
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
});
});
ui.add_space(12.0);
crate::community::links(app, ui);
ui.add_space(12.0);
ui.label(
egui::RichText::new(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
)
.size(11.0),
);
});
app.ui.about = open;
}
+22
View File
@@ -0,0 +1,22 @@
//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer.
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
let mut picked = false;
for g in vectorcraft_tools::TOOL_GROUPS {
ui.horizontal_wrapped(|ui| {
for tool in g.iter() {
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
app.select_tool(tool.id);
picked = true;
}
}
});
}
picked
}
@@ -0,0 +1,20 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
if d.fields.contains_key(k) {
p[k] = json!(d.f64(k, 0.0));
}
}
run_and_close(app, "artboard.setProps", p)
}
+26
View File
@@ -0,0 +1,26 @@
//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`,
//! headed `__label`) and runs it on OK.
use serde_json::Value;
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: |_, ui, d| {
form::param_fields(ui, d);
false
},
confirm,
..DialogSpec::FORM
};
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let params = form::params(d);
app.ui.dialog = None;
app.run(&cmd, params)
}
@@ -0,0 +1,11 @@
//! File → Document Setup: the document's ruler units.
use serde_json::json;
use super::{DialogSpec, run_and_close};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Document Setup".into(),
confirm: |app, d| run_and_close(app, "document.setUnits", json!({"units": d.str("units")})),
..DialogSpec::FORM
};
+41
View File
@@ -0,0 +1,41 @@
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a
//! live Preview that OK keeps as one undo step and Cancel rolls back.
use serde_json::{Value, json};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let changed = form::param_fields(ui, d);
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
let _ = app.session.begin_interaction(&label);
let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)}));
} else if !pv && pv_changed {
let _ = app.session.cancel_interaction();
}
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let effect = d.str("__effect");
let params = form::params(d);
let r = if app.session.in_interaction() {
// Live preview already applied: keep it (the interaction commits as one undo step).
let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params}));
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
} else {
app.run("effect.apply", json!({"effect": effect, "params": params}))
};
app.last_effect = Some((effect, params));
app.ui.dialog = None;
r
}
@@ -0,0 +1,154 @@
//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows,
//! destination folder and file-name prefix.
use serde_json::{Value, json};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let boards: Vec<usize> = d
.fields
.get("boards")
.and_then(Value::as_array)
.map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect())
.unwrap_or_default();
let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")});
app.ui.dialog = None;
let r = app.run("document.exportForScreens", params);
if let Ok(v) = &r {
let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0);
app.status(format!("Exported {n} file(s) to {}", d.str("folder")));
}
r
}
/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(names.len(), true);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
ui.label(egui::RichText::new("Artboards").color(t.text));
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let ir = egui::Rect::from_center_size(r.center(), sz * s);
ui.painter().image(
tex.id(),
ir,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
ui.checkbox(&mut boards[i], name);
});
}
});
});
let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover());
ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider));
ui.vertical(|ui| {
ui.set_width(330.0);
ui.label(egui::RichText::new("Export to").color(t.text));
let mut folder = d.str("folder");
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() {
d.fields.insert("folder".into(), json!(folder));
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Formats").color(t.text));
let mut formats: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut remove = None;
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Scale").color(t.text_dim));
ui.label(egui::RichText::new("Suffix").color(t.text_dim));
ui.label(egui::RichText::new("Format").color(t.text_dim));
ui.label("");
ui.end_row();
for (i, f) in formats.iter_mut().enumerate() {
let mut sc = f["scale"].as_f64().unwrap_or(1.0);
if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() {
f["scale"] = json!(sc);
}
let mut suffix = f["suffix"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() {
f["suffix"] = json!(suffix);
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
}
});
if ui.small_button("×").clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove {
formats.remove(i);
}
if ui.button("+ Add Scale").clicked() {
let next = formats.len() as f64 + 1.0;
formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")}));
}
d.fields.insert("formats".into(), Value::Array(formats));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Prefix").color(t.text_dim));
let mut prefix = d.str("prefix");
if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() {
d.fields.insert("prefix".into(), json!(prefix));
}
});
});
});
d.fields.insert("boards".into(), json!(boards));
false
}
/// A small cached rendering of artboard `i` (keyed by document revision).
fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
let st = app.session.active()?;
let key = egui::Id::new(("ab-thumb", i, st.revision));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(t);
}
let doc = st.doc.clone();
let r = doc.artboards.get(i)?.rect;
let scale = 92.0 / r.width().max(r.height()).max(1.0);
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
Some(tex)
}
+122
View File
@@ -0,0 +1,122 @@
//! Field widgets shared by the dialogs: labelled text fields, checkboxes, the generic field grid
//! and the typed parameter editor of the command and effect dialogs.
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::Tokens;
/// A labelled text field bound to `d.fields[key]` (one grid row).
pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
let mut s = d.str(key);
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
ui.end_row();
}
/// A checkbox bound to `d.fields[key]`.
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, label).changed() {
d.fields.insert(key.into(), Value::Bool(b));
}
}
/// The generic dialog body: a text field per non-boolean value (positions and indices hidden).
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
field(ui, d, &k, &humanize(&k));
}
});
}
/// Command/effect parameters from the dialog fields (drop UI-only keys).
pub(super) fn params(d: &Dialog) -> Value {
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours.
/// Returns true when a value changed.
pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text));
match v {
Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0);
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
d.fields.insert(k, json!(x));
changed = true;
}
}
Value::Bool(mut b) => {
if ui.checkbox(&mut b, "").changed() {
d.fields.insert(k, json!(b));
changed = true;
}
}
Value::String(mut s) if s.contains('\n') => {
// Multi-line values (Graph Data CSV) get a text area.
if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
Value::String(mut s) => {
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
d.fields.insert(k, json!(s));
changed = true;
}
}
other => {
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
}
}
ui.end_row();
}
});
changed
}
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
pub(super) fn humanize(k: &str) -> String {
let mut s = String::new();
for (i, c) in k.chars().enumerate() {
if i == 0 {
s.extend(c.to_uppercase());
} else if c.is_uppercase() {
s.push(' ');
s.push(c);
} else {
s.push(c);
}
}
match s.as_str() {
"Dx" => "Horizontal".into(),
"Dy" => "Vertical".into(),
"Sx" => "Horizontal %".into(),
"Sy" => "Vertical %".into(),
"Radius1" => "Radius 1".into(),
"Radius2" => "Radius 2".into(),
_ => format!("{s}:"),
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them
//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`.
//!
//! Each dialog is a file here exporting a [`DialogSpec`] (heading, body, OK action, buttons) plus
//! a row in the `registry!` below that maps its `Dialog::kind` strings to it. Dialogs inside the
//! shared modal frame only supply a body and a confirm action; a few (Preferences, Keyboard
//! Shortcuts, Workspaces, Find Font) draw their own window.
mod about;
mod all_tools;
mod artboard_options;
mod command;
mod document_setup;
mod effect;
mod export_for_screens;
mod form;
mod new_document;
mod path_ops;
mod recolor;
mod save_changes;
mod shapes;
mod tools;
mod transform;
use serde_json::{Value, json};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
type DialogResult = Result<Value, String>;
/// How a dialog draws and applies itself. Specs start from [`DialogSpec::FORM`] and override what
/// differs.
pub(crate) struct DialogSpec {
/// Draws its own window instead of the shared frame (the frame fields below are then unused).
pub window: Option<fn(&mut VectorcraftApp, &egui::Context)>,
/// The heading (and window title).
pub heading: fn(&Dialog) -> String,
/// Draws the fields. Returns true to close the dialog as Cancel would.
pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool,
/// OK, Enter or `ui.dialog.confirm`: applies the dialog and closes it when done.
pub confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult,
/// The OK button's label. None: no OK button, Enter does nothing and Cancel reads "Close".
pub ok: Option<&'static str>,
/// An extra button left of Cancel that sets `discard: true` and confirms ("Don't Save").
pub discard: Option<&'static str>,
pub min_width: f32,
pub max_width: Option<f32>,
/// The body runs a live preview interaction that Cancel rolls back.
pub preview: bool,
}
impl DialogSpec {
/// A text field per value; OK just closes. Also the fallback for unregistered kinds.
pub const FORM: Self = Self {
window: None,
heading: |_| "Dialog".into(),
body: |_, ui, d| {
form::grid(ui, d);
false
},
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: Some("OK"),
discard: None,
min_width: 320.0,
max_width: None,
preview: false,
};
/// A dialog that draws its own window and confirms through its module.
const fn window(show: fn(&mut VectorcraftApp, &egui::Context), confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult) -> Self {
Self { window: Some(show), confirm, ..Self::FORM }
}
}
/// The dialog registry: one row per dialog with its [`DialogKind`] variant, the `Dialog::kind`
/// strings it handles and its [`DialogSpec`]. Adding a dialog is a new file plus one row.
macro_rules! registry {
($($variant:ident: [$($kind:pat),+] => $spec:expr,)+) => {
/// Every registered dialog.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DialogKind {
$($variant,)+
}
impl DialogKind {
/// The dialog handling a `Dialog::kind` string.
pub fn of(kind: &str) -> Option<Self> {
match kind {
$($($kind)|+ => Some(Self::$variant),)+
_ => None,
}
}
fn spec(self) -> &'static DialogSpec {
match self {
$(Self::$variant => {
static SPEC: DialogSpec = $spec;
&SPEC
})+
}
}
}
};
}
registry! {
NewDocument: ["newDocument"] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: ["recolor"] => recolor::SPEC,
Command: ["command"] => command::SPEC,
Effect: ["effect"] => effect::SPEC,
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)),
Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),
FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)),
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
fn spec(kind: &str) -> &'static DialogSpec {
static FALLBACK: DialogSpec = DialogSpec::FORM;
DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec)
}
/// 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);
app.ui.dialog = None;
r
}
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
(spec(&d.kind).confirm)(app, &d)
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
let Some(mut d) = app.ui.dialog.clone() else { return };
let spec = spec(&d.kind);
if let Some(window) = spec.window {
return window(app, ctx);
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
let mut discard = false;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
// Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app).
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let heading = (spec.heading)(&d);
egui::Window::new(heading.as_str())
.id(egui::Id::new("dialog"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_min_width(spec.min_width);
if let Some(w) = spec.max_width {
ui.set_max_width(w);
}
ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
cancel = (spec.body)(app, ui, &mut d);
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok
&& widgets::primary_button(ui, label).clicked()
{
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
cancel = true;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
});
});
if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
if discard {
d.fields.insert("discard".into(), json!(true));
ok = true;
}
app.ui.dialog = Some(d);
if cancel {
if spec.preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,66 @@
//! File → New: presets and the document details (name, size, artboards, colour mode).
use serde_json::{Value, json};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::{self, Tokens};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM };
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let presets: [(&str, &str, &str, &str); 8] = [
("Letter", "612 pt", "792 pt", "Points"),
("Legal", "612 pt", "1008 pt", "Points"),
("Tabloid", "792 pt", "1224 pt", "Points"),
("A4", "595.28 pt", "841.89 pt", "Points"),
("A3", "841.89 pt", "1190.55 pt", "Points"),
("Web 1920", "1920 px", "1080 px", "Pixels"),
("Phone 390×844", "390 px", "844 px", "Pixels"),
("Square Post", "1080 px", "1080 px", "Pixels"),
];
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
ui.label(egui::RichText::new("Presets").color(t.text_dim));
ui.horizontal_wrapped(|ui| {
for (name, w, h, u) in presets {
let sel = d.str("preset") == name;
if ui.selectable_label(sel, name).clicked() {
d.fields.insert("preset".into(), json!(name));
d.fields.insert("width".into(), json!(w));
d.fields.insert("height".into(), json!(h));
d.fields.insert("units".into(), json!(u));
}
}
});
});
ui.separator();
ui.vertical(|ui| {
ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5)));
egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
form::field(ui, d, "name", "Name:");
form::field(ui, d, "width", "Width:");
form::field(ui, d, "height", "Height:");
form::field(ui, d, "artboards", "Artboards:");
form::field(ui, d, "colorMode", "Color Mode:");
});
});
});
false
}
/// Create the document; the dialog stays open when that fails (bad size, …).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let r = app.run(
"file.new",
json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}),
);
if r.is_ok() {
app.ui.dialog = None;
}
r
}
+30
View File
@@ -0,0 +1,30 @@
//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"average" => "Average",
"offsetPath" => "Offset Path",
"simplify" => "Simplify",
_ => "Split Into Grid",
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (id, params) = match d.kind.as_str() {
"average" => ("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => {
("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)}))
}
"simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
_ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
};
run_and_close(app, id, params)
}
+105
View File
@@ -0,0 +1,105 @@
//! Edit → Edit Colors → Recolor Artwork: current → new colour pairs, previewed live.
use serde_json::{Value, json};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if editor(ui, d) || !app.session.in_interaction() {
let _ = app.session.begin_interaction("Recolor Artwork");
let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(d)}));
}
false
}
/// Keep the previewed recolour as one undo step (or apply it when no preview is running).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let map = recolor_map(d);
app.ui.dialog = None;
if app.session.in_interaction() {
let _ = app.session.preview("recolor.apply", &json!({"map": map}));
return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
}
app.run("recolor.apply", json!({"map": map}))
}
fn recolor_map(d: &Dialog) -> Value {
let mut m = serde_json::Map::new();
for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() {
if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) {
m.insert(a.to_string(), json!(b));
}
}
Value::Object(m)
}
/// Current → new colour rows, harmony rules, randomize. Returns true when changed.
fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
use vectorcraft_color::Color;
let t = Tokens::get(ui.ctx());
let mut pairs: Vec<Value> = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default();
let mut changed = false;
let chip = |ui: &mut egui::Ui, hex: &str| {
let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover());
let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0);
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
ui.add_space(70.0);
ui.label(egui::RichText::new("New").color(t.text_dim));
});
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for p in pairs.iter_mut() {
let from = p[0].as_str().unwrap_or("#000000").to_string();
let mut to = p[1].as_str().unwrap_or("#000000").to_string();
ui.horizontal(|ui| {
chip(ui, &from);
ui.label(egui::RichText::new("→").color(t.text_dim));
chip(ui, &to);
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() {
p[1] = json!(to);
changed = true;
}
});
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Randomize").clicked() {
// Deterministic scramble of hues (seeded by the colour itself), keeping brightness.
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0;
p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex());
}
changed = true;
}
for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] {
if ui.button(label).clicked() {
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) };
p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex());
}
changed = true;
}
}
if ui.button("Reset").clicked() {
for p in pairs.iter_mut() {
p[1] = p[0].clone();
}
changed = true;
}
});
d.fields.insert("pairs".into(), Value::Array(pairs));
changed
}
@@ -0,0 +1,19 @@
//! Save Changes (closing a modified document): Save / Don't Save / Cancel. The flow lives in
//! [`crate::unsaved`]; this is its face in the shared dialog frame.
use super::DialogSpec;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| format!("Do you want to save the changes you made to “{}”?", d.str("name")),
body: |_, ui, _| {
let t = crate::theme::Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim));
false
},
confirm: |app, _| crate::unsaved::confirm(app),
ok: Some("Save"),
discard: Some("Don't Save"),
// Long document names wrap instead of widening the dialog.
max_width: Some(420.0),
..DialogSpec::FORM
};
+42
View File
@@ -0,0 +1,42 @@
//! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon,
//! Star and Line Segment Tool Options.
use serde_json::{Value, json};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"rectangle" => "Rectangle",
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
_ => "Line Segment Tool Options",
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
let (id, params) = match d.kind.as_str() {
"ellipse" => ("shape.ellipse", json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})),
"polygon" => ("shape.polygon", json!({"cx": x, "cy": y, "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})),
"star" => (
"shape.star",
json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}),
),
"lineSegment" => {
let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians());
("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
}
_ => (
"shape.rectangle",
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}),
),
};
run_and_close(app, id, params)
}
+165
View File
@@ -0,0 +1,165 @@
//! The dialog registry and the shared frame, drawn headlessly.
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp, input: egui::RawInput) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn enter() -> egui::RawInput {
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
egui::RawInput { events: vec![key], ..Default::default() }
}
fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) {
app.ui.dialog = Some(Dialog::new(kind, fields));
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()
}
#[test]
fn kinds_map_to_their_dialogs() {
for (kind, want) in [
("newDocument", DialogKind::NewDocument),
("roundedRectangle", DialogKind::Shape),
("lineSegment", DialogKind::Shape),
("shear", DialogKind::Transform),
("splitIntoGrid", DialogKind::PathOp),
("artboardOptions", DialogKind::ArtboardOptions),
("command", DialogKind::Command),
("effect", DialogKind::Effect),
(crate::unsaved::KIND, DialogKind::SaveChanges),
("manageWorkspaces", DialogKind::Workspaces),
("findFont", DialogKind::FindFont),
] {
assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
}
assert_eq!(DialogKind::of("nope"), None);
assert!(spec("allTools").ok.is_none());
assert!(spec("effect").preview && spec("recolor").preview && !spec("move").preview);
assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
}
#[test]
fn every_dialog_draws() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let openers: [(&str, serde_json::Value); 8] = [
("file.newDialog", json!({})),
("file.documentSetup", json!({})),
("file.exportForScreens", json!({})),
("ui.recolorDialog", json!({})),
("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2, "csv": "a\nb"}})),
("effect.dialog", json!({"effect": "distort.roughen"})),
("edit.preferences", json!({})),
("type.findFont", json!({})),
];
for (id, p) in openers {
app.run(id, p).unwrap();
assert!(app.ui.dialog.is_some(), "{id} opened nothing");
frame(&mut app, Default::default());
app.ui.dialog = None;
let _ = app.session.cancel_interaction();
}
for kind in
["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rotate", "reflect", "scale", "shear", "artboardOptions"]
{
open_tool_dialog(&mut app, kind, json!({"x": 5, "y": 5, "index": 0}));
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == kind), "{kind}");
frame(&mut app, Default::default());
}
for (kind, fields) in [
("move", json!({"dx": "0 pt", "dy": "0 pt"})),
("average", json!({"axis": "both"})),
("allTools", json!({})),
(crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})),
("newWorkspace", json!({"name": "Mine"})),
("shortcuts", json!({})),
("unregistered", json!({"value": "1"})),
] {
open(&mut app, kind, fields);
frame(&mut app, Default::default());
}
}
#[test]
fn enter_confirms_through_the_dialog_spec() {
let mut app = app();
open_tool_dialog(&mut app, "rectangle", json!({"x": 10, "y": 20}));
frame(&mut app, enter());
assert!(app.ui.dialog.is_none());
assert_eq!(objects(&app), 1);
// All Tools has no OK: Enter leaves it open.
open(&mut app, "allTools", json!({}));
frame(&mut app, enter());
assert!(app.ui.dialog.is_some());
// An unregistered kind still closes on OK without running anything.
open(&mut app, "unregistered", json!({}));
assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null));
assert!(app.ui.dialog.is_none());
assert_eq!(confirm(&mut app), Err("no dialog open".into()));
}
#[test]
fn confirm_runs_each_dialogs_command() {
let mut app = app();
open(&mut app, "star", json!({"x": 50, "y": 50, "radius1": "40 pt", "radius2": "20 pt", "points": 6}));
confirm(&mut app).unwrap();
open(&mut app, "lineSegment", json!({"x": 0, "y": 0, "length": "30 pt", "angle": 90}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 2);
app.run("select.all", json!({})).unwrap();
// Scale with Uniform uses the horizontal value for both axes; Copy keeps the original.
open(&mut app, "scale", json!({"sx": 200, "sy": 50, "uniform": true, "copy": true}));
confirm(&mut app).unwrap();
assert_eq!(objects(&app), 4);
// The parameter dialog closes before its command runs, then runs it with the edited values.
app.run("ui.paramDialog", json!({"command": "view.saved.new", "label": "New View", "params": {"name": "Close-up"}})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.doc().unwrap().doc.views.last().map(|v| v.name.as_str()), Some("Close-up"));
// New Document creates the document and closes.
let docs = app.session.documents().len();
open(
&mut app,
"newDocument",
json!({"width": "300 pt", "height": "200 pt", "units": "Points", "name": "Card", "artboards": 1, "colorMode": "RGB"}),
);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), docs + 1);
}
#[test]
fn effect_preview_is_kept_as_one_undo_step() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
frame(&mut app, Default::default());
assert!(app.session.in_interaction(), "the preview runs as an interaction");
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert_eq!(app.last_effect.as_ref().map(|e| e.0.as_str()), Some("distort.roughen"));
assert_ne!(app.session.doc().unwrap().doc, before);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
+53
View File
@@ -0,0 +1,53 @@
//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options
//! and artboard options.
use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::state::Dialog;
/// A click with a shape tool opens its size dialog.
pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let d = match kind {
"rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})),
"roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})),
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
// Graph tool click: the graph's size.
"graph" => Dialog::new(
"command",
json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}),
),
// After drawing a graph: Graph Data (CSV: header row of series, then category, values…).
"graphData" => match app.session.execute("graph.setData", &json!({})) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
"reflect" => json!({"axis": "vertical"}),
"scale" => json!({"sx": 100, "sy": 100, "uniform": true}),
"shear" => json!({"angle": 0, "axis": "horizontal"}),
_ => json!({}),
};
if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) {
for (k, v) in o {
b.insert(k.clone(), v.clone());
}
}
Dialog::new(kind, base)
}
_ => return,
};
app.ui.dialog = Some(d);
}
+53
View File
@@ -0,0 +1,53 @@
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy, and Uniform for Scale).
use serde_json::{Value, json};
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, ..DialogSpec::FORM };
fn title(kind: &str) -> &'static str {
match kind {
"move" => "Move",
"rotate" => "Rotate",
"scale" => "Scale",
"reflect" => "Reflect",
_ => "Shear",
}
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d);
ui.add_space(6.0);
if d.kind == "scale" {
form::check(ui, d, "uniform", "Uniform");
}
form::check(ui, d, "copy", "Copy (make a transformed copy)");
false
}
/// Pass a tool-chosen reference point (origin) through to transform commands.
fn origin_params(d: &Dialog, mut p: Value) -> Value {
if let Some(o) = d.fields.get("origin") {
p["origin"] = o.clone();
}
p
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let copy = d.bool("copy");
let (id, params) = match d.kind.as_str() {
"move" => return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
"scale" => {
let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
("object.scale", json!({"sx": sx, "sy": sy, "copy": copy}))
}
"reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})),
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
};
run_and_close(app, id, origin_params(d, params))
}