Files
vectorcraft/crates/ui-egui/src/dialogs/mod.rs
T

705 lines
28 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;
pub mod dxf_import;
pub mod dxf_options;
pub mod edit_selection;
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;
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 office_export;
pub mod package;
pub(crate) mod path_ops;
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;
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 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(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)>,
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,
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", "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,
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,
SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC,
SaveSymbolLibrary: [save_symbol_library::KIND] => save_symbol_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,
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,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
/// 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.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)
}
/// 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() {
if spec(&d.kind).preview {
let _ = app.session.cancel_interaction();
}
app.ui.dialog = parent(&d);
}
settle(app);
}
/// 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 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);
if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() {
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();
}
});
});
if spec.ok.is_some() && ok_enabled && !after_panel && 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 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;