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

234 lines
8.8 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;
mod all_tools;
mod artboard_options;
mod command;
pub mod confirm;
mod document_setup;
mod effect;
mod export_for_screens;
mod form;
pub mod new_color_group;
mod gradient_stop;
mod new_document;
pub mod new_swatch;
mod path_ops;
mod recolor;
mod save_changes;
mod shapes;
pub mod swatch_options;
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)),
SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
Confirm: [confirm::KIND] => confirm::SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
}
/// 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
}
/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`).
pub fn cancel(app: &mut VectorcraftApp) {
if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) {
let _ = app.session.cancel_interaction();
}
}
/// 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 {
self::cancel(app);
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
#[cfg(test)]
mod tests;