Files
vectorcraft/crates/ui-egui/src/dialogs.rs
T
bflatastic ca0785573d Add-ons: Arrange Tools — Arrange in Grid, Swap, Replace with Top Object, Set Size of Each, Zoom to Selection
A built-in add-on in Object › Plug-ins › Arrange Tools (#4830, #4831, #4832, #4833, #4834):

- addon.arrange.grid lays the top-level selected objects out in rows and
  columns, a row or a column (stacking order from the back, visual bounds,
  each object centred in its cell), from the selection's top-left or
  centred on the artboard.
- addon.arrange.swap swaps two objects' bounds centres, and with `size`
  their widths and heights.
- addon.arrange.replace puts a copy of the topmost selected object centred
  on each of the others, in its stacking place, kept, stretched or fitted
  in proportion; the replaced objects are deleted unless `keep`.
- addon.arrange.size gives each selected object an exact width and/or
  height about a reference point.
- addon.arrange.zoomSelection (a UI command) fits the active view to the
  selection.

Places and sizes are measured as object.setBounds measures them (its
transform, now also available per object), strokes scale as Scale Strokes
& Effects says, each command is one undo step, and Arrange in Grid,
Replace with Top Object and Set Size of Each open previewed dialogs.
Catalog rows, command sweep cases, docs/mcp.md, parity docs and the
ROADMAP progress log.
2026-10-31 22:22:15 +07:00

775 lines
32 KiB
Rust

//! 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;
pub mod action_batch;
pub mod action_set_options;
pub mod artboard_options;
pub mod blend_options;
mod brush_art_options;
pub mod brush_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
mod command;
pub mod confirm;
pub mod corners;
pub mod dimension;
mod document_setup;
mod duotone;
pub mod dxf_import;
pub mod dxf_options;
pub mod edit_selection;
pub mod edit_views;
mod effect;
pub mod effect_gallery;
pub mod envelope;
pub mod eps_options;
pub mod expand;
mod export_as;
mod export_for_screens;
pub mod eyedropper;
pub mod file_info;
pub mod find_links;
pub mod flare_options;
pub mod flatten;
pub mod flattener_presets;
pub(crate) mod form;
pub(crate) mod free_distort;
pub mod freehand;
mod gradient_stop;
pub mod graph_data;
pub mod graph_design;
pub mod graphic_style_options;
pub mod halftone;
pub mod hyphenation;
pub mod import_pdf;
pub mod javascript_files;
pub mod justification;
pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod live_paint_gaps;
pub mod live_paint_selection;
pub mod missing_fonts;
pub mod missing_links;
pub(crate) mod modal;
pub mod new_brush;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
pub mod new_text_style;
pub mod office_export;
pub mod package;
pub(crate) mod path_ops;
pub mod pathfinder_options;
pub mod pdf_presets;
pub mod perspective_grid;
pub mod perspective_options;
pub mod perspective_plane;
pub mod perspective_presets;
pub mod place;
pub mod placement_options;
pub mod plugin;
mod png_options;
pub mod print;
pub mod print_presets;
mod psd_options;
pub mod raster_effects;
pub mod rearrange_artboards;
pub mod recolor;
mod recovery;
mod revolve;
pub(crate) mod revolve_gizmo;
pub mod saturate;
pub mod save_brush_library;
mod save_changes;
pub mod save_for_web;
pub(crate) mod save_options;
mod save_pdf;
pub mod save_style_library;
pub mod save_swatch_library;
pub mod save_symbol_library;
pub mod script_check;
pub mod shape_builder_options;
pub(crate) mod shapes;
pub mod shortcut_import;
pub(crate) mod simplify_slider;
pub mod slices;
pub(crate) mod smooth_slider;
pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_conflict;
pub mod swatch_options;
pub mod symbol_options;
pub mod symbolism_options;
mod text_export;
pub mod text_import;
mod tiff_bmp_tga;
pub mod tile_edge_color;
mod tools;
pub mod trace_presets;
pub(crate) mod transform;
pub mod transform_each;
pub mod variables;
mod warp_effect;
pub mod width_point;
use serde_json::{Value, json};
pub use color_picker::open as open_color_picker;
pub use document_setup::open as open_document_setup;
pub use effect::open as open_effect_dialog;
pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices};
pub use export_as::open as open_export_as;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_options as screen_saved_options,
saved_rows as screen_saved_rows,
};
pub(crate) use form::{humanize, number_list_text, read_number_list, unit_suffix};
pub(crate) use new_document::preset_name;
pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card};
pub use png_options::open as open_raster_options;
pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
type DialogResult = Result<Value, String>;
/// The shared dialog frame's inner margin, and the least room it leaves at the window's edges.
const MARGIN: i8 = 22;
const EDGE_GAP: f32 = 8.0;
/// An action requested by a dialog that draws its buttons inside its body.
#[derive(Default)]
pub(crate) enum DialogAction {
#[default]
None,
Confirm,
Cancel,
}
/// 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), in the UI language: the spec translates its own text and
/// leaves names in it (a document's, a plug-in's) as they are.
pub heading: fn(&Dialog) -> String,
/// Optional heading band inside the shared window; normal dialogs use `modal::heading`.
pub header: Option<fn(&mut egui::Ui, &str)>,
/// Draws the fields. Returns true to close the dialog as Cancel would.
pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool,
/// Optional body with inline buttons; replaces `body` and the shared footer.
pub body_with_actions: Option<fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> DialogAction>,
/// 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>,
/// A button left of Cancel, as (label, field), that sets `field: true` and confirms, as OK
/// does otherwise (Transform Each's Copy).
pub alternate: Option<(&'static str, &'static str)>,
/// Puts the fields back to their defaults. While Alt (Option on macOS) is held, the Cancel
/// button of a dialog with an `ok` reads Reset; Reset (or `ui.dialogReset`) runs this and the
/// dialog stays open.
pub reset: Option<fn(&VectorcraftApp, &mut Dialog)>,
pub min_width: f32,
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>,
/// Can OK (or Enter) apply the dialog as it is? None: always. The body says why not.
pub ok_enabled: Option<fn(&VectorcraftApp, &Dialog) -> 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: |_| tl!("Dialog").into(),
header: None,
body: |app, ui, d| {
form::grid(ui, d, app.session.general_unit());
false
},
body_with_actions: None,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: Some("OK"),
discard: None,
alternate: None,
reset: None,
min_width: 320.0,
max_width: None,
preview: false,
inputs: |_| &[],
passes: |_, _| false,
ok_label: None,
ok_enabled: None,
};
/// 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,
}
}
/// Every variant, in registry order.
pub const ALL: &[DialogKind] = &[$(Self::$variant,)+];
fn spec(self) -> &'static DialogSpec {
match self {
$(Self::$variant => {
static SPEC: DialogSpec = $spec;
&SPEC
})+
}
}
}
};
}
registry! {
DimensionOptions: [dimension::KIND] => dimension::SPEC,
NewDocument: [new_document::KIND] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "arc", "spiral", "rectangularGrid", "polarGrid"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "averageJoin", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
Command: ["command"] => command::SPEC,
Variables: [variables::KIND] => variables::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)),
SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
Confirm: [confirm::KIND] => confirm::SPEC,
Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC,
Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GraphData: [graph_data::KIND] => graph_data::SPEC,
GraphDesign: [graph_design::DESIGN] => graph_design::DESIGN_SPEC,
GraphColumn: [graph_design::COLUMN] => graph_design::COLUMN_SPEC,
GraphMarker: [graph_design::MARKER] => graph_design::MARKER_SPEC,
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,
NewTextStyle: [new_text_style::KIND] => new_text_style::SPEC,
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC,
ColorBalance: [color_balance::KIND] => color_balance::SPEC,
Saturate: [saturate::KIND] => saturate::SPEC,
SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC,
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
FlattenTransparency: [flatten::KIND] => flatten::SPEC,
FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC,
ImageTracePreset: [trace_presets::KIND] => trace_presets::SPEC,
SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC,
SaveSymbolLibrary: [save_symbol_library::KIND] => save_symbol_library::SPEC,
SaveBrushLibrary: [save_brush_library::KIND] => save_brush_library::SPEC,
Expand: [expand::KIND] => expand::SPEC,
SpotColors: [spot_colors::KIND] => spot_colors::SPEC,
TransformEach: [transform_each::KIND] => transform_each::SPEC,
WidthPoint: [width_point::KIND] => width_point::SPEC,
Corners: [corners::KIND] => corners::SPEC,
Hyphenation: [hyphenation::KIND] => hyphenation::SPEC,
Justification: [justification::KIND] => justification::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
Place: [place::KIND] => place::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
ExportAs: ["exportAs"] => export_as::SPEC,
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
FileInfo: [file_info::KIND] => file_info::SPEC,
RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC,
EffectGallery: [effect_gallery::KIND] => effect_gallery::SPEC,
MissingLinks: [missing_links::KIND] => missing_links::SPEC,
MissingFonts: [missing_fonts::KIND] => missing_fonts::SPEC,
FindLinks: [find_links::KIND] => find_links::SPEC,
TextImport: [text_import::KIND] => text_import::SPEC,
PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC,
PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC,
SaveOptions: [save_options::KIND] => save_options::SPEC,
TextExport: [text_export::KIND] => text_export::SPEC,
OfficeExport: [office_export::KIND] => office_export::SPEC,
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
PlacementOptions: [placement_options::KIND] => placement_options::SPEC,
Package: [package::KIND] => package::SPEC,
ActionBatch: [action_batch::KIND] => action_batch::SPEC,
ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC,
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
Recovery: [crate::recovery::KIND] => recovery::SPEC,
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC,
PsdOptions: ["psdOptions"] => png_options::SPEC,
Print: [print::KIND] => print::SPEC,
PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC,
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
Plugin: [plugin::KIND] => plugin::SPEC,
PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC,
ScriptCheck: [script_check::KIND] => script_check::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
SmoothSlider: [smooth_slider::KIND] => smooth_slider::SPEC,
PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC,
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
FreehandOptions: [freehand::KIND] => freehand::SPEC,
ShapeBuilderOptions: [shape_builder_options::KIND] => shape_builder_options::SPEC,
FlareOptions: [flare_options::KIND] => flare_options::SPEC,
SymbolOptions: [symbol_options::KIND] => symbol_options::SPEC,
SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC,
LivePaintSelectionOptions: [live_paint_selection::KIND] => live_paint_selection::SPEC,
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
NewBrush: [new_brush::KIND] => new_brush::SPEC,
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
EditViews: [edit_views::KIND] => edit_views::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC,
ArrangeTools: [crate::addons::arrange::GRID, crate::addons::arrange::REPLACE, crate::addons::arrange::SIZE] => crate::addons::arrange::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
/// The button labels the shared dialog frame can show (OK, discard, the fixed Cancel/Close and
/// Reset), so the catalog tests can insist they are translated.
pub fn button_labels() -> Vec<&'static str> {
let mut v = vec!["Cancel", "Close"];
for spec in std::iter::once(&DialogSpec::FORM).chain(DialogKind::ALL.iter().map(|k| k.spec())) {
v.extend(spec.ok);
v.extend(spec.discard);
v.extend(spec.alternate.map(|(label, _)| label));
v.extend(spec.reset.map(|_| "Reset"));
}
v.sort_unstable();
v.dedup();
v
}
/// 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)
}
/// 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)
}
/// Set `field` of `d` to `value` for `ui.dialog.set`, as the dialog's own control for it does: a
/// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is.
pub fn set_field(d: &mut Dialog, field: String, value: Value) {
if d.kind == brush_options::KIND {
brush_art_options::note_set(d, &field);
}
d.fields.insert(field, value);
}
/// 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);
app.ui.dialog = None;
r
}
/// The field of a dialog opened over another (Import Shortcut Set over Keyboard Shortcuts) that
/// holds the dialog under it, which Cancel and Escape show again.
pub(crate) const PARENT: &str = "__parent";
/// The dialog `d` was opened over, as it was.
pub(crate) fn parent(d: &Dialog) -> Option<Dialog> {
d.fields.get(PARENT).and_then(|v| serde::Deserialize::deserialize(v).ok())
}
/// Close the open dialog as Cancel does, rolling back a live preview and showing the dialog it was
/// opened over (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
if let Some(d) = app.ui.dialog.take() {
// Toolbar-only Flare options start no preview and must not cancel a gesture
// that was already active before opening them. The generic parameter dialog has a spec
// without a preview, yet previews some commands (Grid Repeat Options) when asked to.
let owns_preview = d.kind != flare_options::KIND || d.fields.contains_key(form::PREVIEWED);
if (spec(&d.kind).preview && owns_preview) || d.fields.contains_key(form::PREVIEWED) {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = parent(&d);
}
settle(app);
}
/// Whether the open dialog has a Reset ([`DialogSpec::reset`]).
pub(crate) fn can_reset(app: &VectorcraftApp) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| spec(&d.kind).reset.is_some())
}
/// Reset (`ui.dialogReset`, or Cancel while Alt or Option is held): the open dialog's fields go
/// back to their defaults and it stays open → the dialog; an error when no dialog is open or the
/// open one has no Reset.
pub(crate) fn reset(app: &mut VectorcraftApp) -> DialogResult {
let Some(mut d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
let Some(reset) = spec(&d.kind).reset else { return Err("the open dialog has no Reset".into()) };
reset(app, &mut d);
let out = serde_json::to_value(&d).unwrap_or_default();
app.ui.dialog = Some(d);
Ok(out)
}
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
missing_fonts::requeue_replaced(app);
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
// A file dialog it shows off the UI thread confirms the dialog as it is again.
let r = crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d));
settle(app);
r
}
/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits
/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once
/// that dialog is gone, and with no dialog open the next document's Missing Fonts dialog opens
/// (one dialog at a time).
pub(crate) fn settle(app: &mut VectorcraftApp) {
missing_fonts::requeue_replaced(app);
missing_fonts::stop_when_closed(app);
find_links::stop_when_closed(app);
missing_fonts::open_next(app);
}
/// The field of a dialog that a save panel opened (its format's options): Enter doesn't confirm it
/// until Enter has been let go.
pub(crate) const AFTER_PANEL: &str = "__afterPanel";
/// Marks the open dialog, if any, as one a save panel opened ([`AFTER_PANEL`]): the Enter that
/// closed the panel, still held, doesn't confirm it.
pub(crate) fn opened_by_panel(app: &mut VectorcraftApp) {
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert(AFTER_PANEL.into(), json!(true));
}
}
/// Draws the active dialog and handles shared initial focus, keyboard shortcuts and actions.
/// Inline actions and footer buttons use the same confirmation and preview rollback paths.
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
settle(app);
confirm::show_waiting(app);
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
let (shown_id, focus_id) = (egui::Id::unique("dialog-shown"), egui::Id::unique("dialog-focus-pending"));
let Some(mut d) = app.ui.dialog.clone() else {
revolve_gizmo::clear(ctx);
app.ui.dialog_file = None;
ctx.data_mut(|m| {
m.remove::<String>(shown_id);
m.remove::<bool>(focus_id);
});
return;
};
if !revolve_gizmo::active(&d) {
revolve_gizmo::clear(ctx);
}
let focus_first = ctx.data_mut(|m| {
if m.get_temp::<String>(shown_id).as_deref() != Some(d.kind.as_str()) {
m.insert_temp(shown_id, d.kind.clone());
m.insert_temp(focus_id, true);
}
m.get_temp::<bool>(focus_id).unwrap_or(false)
});
// Not in the frame of a click (the one on the menu item that opened it): egui takes the focus
// from a field the click missed, so the field takes it a frame later (#1367).
let clicked = ctx.input(|i| i.pointer.any_click());
if focus_first && clicked {
ctx.request_repaint();
}
let focus_first = focus_first && !clicked;
let spec = spec(&d.kind);
if let Some(window) = spec.window {
return window(app, ctx);
}
let mut ok = false;
let mut ok_enabled = true;
let mut cancel = false;
let mut reset = false;
let mut discard = false;
let mut alternate = false;
// Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go.
let after_panel = d.bool(AFTER_PANEL);
if after_panel && !ctx.input(|i| i.key_down(egui::Key::Enter) || i.key_pressed(egui::Key::Enter)) {
d.fields.remove(AFTER_PANEL);
}
let heading = (spec.heading)(&d);
let revolves = revolve_gizmo::active(&d);
if revolves {
revolve_gizmo::show(app, ctx, &mut d);
}
let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) };
let id = egui::Id::unique(("dialog", d.kind.as_str()));
let id = if revolves { id.with("revolve") } else { id };
modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| {
// Never wider than the window (a large UI scale in a small window): the text wraps.
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
ui.set_min_width(spec.min_width.min(room));
ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room)));
if let Some(header) = spec.header {
header(ui, &heading);
} else {
modal::heading(ui, &heading);
}
ui.add_space(12.0);
// Just opened: its first enabled field takes the keyboard focus, as in the reference app
// (type a value, press Enter). Only the body's fields can take it.
if focus_first {
ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true));
}
if let Some(body) = spec.body_with_actions {
match body(app, ui, &mut d) {
DialogAction::None => {}
DialogAction::Confirm => ok = true,
DialogAction::Cancel => cancel = true,
}
} else {
cancel = (spec.body)(app, ui, &mut d);
}
// Taken (the flag is gone): done. Still there (the window's measuring frame): next frame.
if focus_first && ui.data_mut(|m| m.remove_temp::<bool>(widgets::dialog_focus_flag())).is_none() {
ui.data_mut(|m| m.remove::<bool>(focus_id));
}
if spec.body_with_actions.is_some() {
return;
}
ui.add_space(16.0);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ok_enabled = spec.ok_enabled.is_none_or(|f| f(app, &d));
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button_enabled(ui, label, ok_enabled).clicked()
{
ok = true;
}
ui.add_space(8.0);
// While Alt (Option) is held, Cancel reads Reset on a dialog that has one.
let resets = spec.ok.is_some() && spec.reset.is_some() && ui.input(|i| i.modifiers.alt);
let label = if resets {
"Reset"
} else if spec.ok.is_some() {
"Cancel"
} else {
"Close"
};
if widgets::secondary_button(ui, label).clicked() {
if resets {
reset = true;
} else {
cancel = true;
}
}
if let Some((label, _)) = spec.alternate {
ui.add_space(8.0);
alternate = widgets::secondary_button(ui, label).clicked() && ok_enabled;
}
if let Some(label) = spec.discard {
ui.add_space(28.0);
discard = widgets::secondary_button(ui, label).clicked();
}
});
});
let rejected = ctx.data_mut(|d| d.remove_temp::<bool>(widgets::dialog_reject_commit_flag())).unwrap_or(false);
if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
if discard {
d.fields.insert("discard".into(), json!(true));
ok = true;
}
if alternate && let Some((_, field)) = spec.alternate {
d.fields.insert(field.into(), json!(true));
ok = true;
}
app.ui.dialog = Some(d);
if cancel {
self::cancel(app);
} else if reset {
if let Err(e) = self::reset(app) {
app.status(e);
}
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
/// What a dropdown shows for `names`, a list mixing built-in labels (`builtin(index)`: translated
/// into `lang`) with names the user saved or a file or the system supplied (shown as they are).
pub(crate) fn shown_names<'a>(lang: crate::i18n::Lang, names: &[&'a str], builtin: impl Fn(usize) -> bool) -> Vec<&'a str> {
names.iter().enumerate().map(|(k, n)| crate::i18n::label_or_name(lang, n, builtin(k))).collect()
}
/// [`widgets::dropdown_names`] over such a mixed list ([`shown_names`] in the UI language), showing
/// `current` as its entry reads; a `current` that isn't among `names` is shown as given (a caller
/// translates its own [Custom]). Returns the index chosen in `names`.
pub(crate) fn mixed_dropdown(
ui: &mut egui::Ui,
id: impl std::hash::Hash + std::fmt::Debug,
current: &str,
names: &[&str],
width: f32,
builtin: impl Fn(usize) -> bool,
) -> Option<usize> {
let shown = shown_names(crate::i18n::current(), names, builtin);
let current = names.iter().position(|n| *n == current).and_then(|i| shown.get(i).copied()).unwrap_or(current);
widgets::dropdown_names(ui, id, current, &shown, width)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_accessibility;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_plugin_export;
#[cfg(test)]
mod tests_plugin_fields;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]
mod tests_svg_options_range;
#[cfg(test)]
mod tests_dxf;
#[cfg(test)]
mod tests_screen_assets;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]
mod tests_eps;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_tiff_bmp_tga;
#[cfg(test)]
mod tests_save_for_web;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_print_presets;
#[cfg(test)]
mod tests_print_advanced;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_perspective;
#[cfg(test)]
mod tests_modal;
#[cfg(test)]
mod tests_rectangular_grid;
#[cfg(test)]
mod tests_shortcut_import;
#[cfg(test)]
mod tests_warp;