diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f10e008a1..b3e1461ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,22 @@ use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::i18n::{self, tr, tr_noop, trf, trn}; use crate::theme::{Tokens, regular, semibold}; + +mod batch_rename; +mod color_profile; +mod export; +mod menu_input; + +pub use batch_rename::BatchRenameUi; +use batch_rename::batch_rename_dialog; +#[cfg(test)] +pub(crate) use batch_rename::batch_rename_params; +pub(crate) use batch_rename::open_batch_rename; +use color_profile::color_profile_dialog; +use export::export_dialog; +pub(crate) use export::file_saved; +pub use menu_input::MenuInput; +use menu_input::menu_input_dialog; pub mod branch_ui; pub mod credits; pub mod import_report; @@ -79,14 +95,6 @@ pub enum AboutTab { Models, } -/// Values collected by a menu or Actions command before dispatching it to the engine. -#[derive(Clone, Debug, PartialEq)] -pub enum MenuInput { - Link(String), - Offset(f64), - MoveToPage, -} - impl AboutTab { pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models]; @@ -390,108 +398,6 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::windows::confirm_close(app, ctx); } -/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular -/// command so the same validation and undo behaviour apply as for programmatic callers. -fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { - let Some(mut input) = app.ui.menu_input.take() else { return }; - let t = Tokens::get(ctx); - let title = match &input { - MenuInput::Link(_) => i18n::command("text.link", "Create link"), - MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), - MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), - }; - let mut open = true; - let mut action: Option<(&str, serde_json::Value)> = None; - window(&title, ctx).open(&mut open).show(ctx, |ui| { - ui.set_width(320.0); - match &mut input { - MenuInput::Link(url) => { - ui.horizontal(|ui| { - let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://"); - let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - let ok = !url.trim().is_empty(); - if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { - action = Some(("text.link", json!({"url": url.trim()}))); - } - }); - } - MenuInput::Offset(offset) => { - ui.horizontal(|ui| { - let id = egui::Id::unique("menu_input_offset"); - if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = - crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) - { - *offset = v.clamp(-100_000.0, 100_000.0); - } - if crate::widgets::button(ui, tr("Offset"), true, true).clicked() { - action = Some(("vector.offset", json!({"offset": *offset}))); - } - }); - } - MenuInput::MoveToPage => { - let mut choices = 0; - if let Ok(ds) = app.session.ds() { - for &page in &ds.doc.pages { - if page == ds.page || ds.doc.is_divider(page) { - continue; - } - if let Some(node) = ds.doc.node(page) - && crate::widgets::button(ui, &node.name, false, true).clicked() - { - action = Some(("object.moveToPage", json!({"page": page}))); - } - choices += 1; - } - } - if choices == 0 { - crate::widgets::label(ui, tr("No other pages"), t.text_secondary); - } - } - } - }); - if let Some((id, params)) = action { - app.run(id, params); - } else if open { - app.ui.menu_input = Some(input); - } -} - -/// The Rename layers dialog's fields. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct BatchRenameUi { - /// The layers, in stacking order (bottom first), as numbered. - pub ids: Vec, - /// Match: empty renames the whole name. - pub find: String, - pub regex: bool, - /// Rename to. - pub to: String, - /// Where up numbers start and down numbers stop. - pub start: u32, -} - -/// Layers whose new names the dialog previews. -const PREVIEW_ROWS: usize = 5; - -/// Open the Rename layers dialog for `ids` (several layers: Cmd/Ctrl+R, F2, the context menu). -pub(crate) fn open_batch_rename(app: &mut FigApp, ids: &[NodeId]) { - let Ok(doc) = app.session.doc() else { return }; - let ids = figcraft_engine::cmd::edit::z_sorted(doc, ids); - app.ui.batch_rename = Some(BatchRenameUi { ids, start: 1, ..Default::default() }); -} - -/// The rename the fields describe: a match replaced, or (no match) the whole name. -pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { - let mut p = json!({"ids": b.ids, "regex": b.regex, "start": b.start}); - if b.find.is_empty() { - p["name"] = json!(b.to); - } else { - p["find"] = json!(b.find); - p["replace"] = json!(b.to); - } - p -} - /// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the /// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there /// the row only says so. @@ -561,296 +467,3 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { crate::widgets::label(ui, ¬e, t.text_secondary); } } - -/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers -/// start, a preview of the first new names, then one `object.batchRename`. -fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { - let Some(mut b) = app.ui.batch_rename.clone() else { return }; - let t = Tokens::get(ctx); - let find = (!b.find.is_empty()).then_some(b.find.as_str()); - let rename = figcraft_engine::rename::BatchRename::new(find, b.regex, &b.to, find.is_none().then_some(b.to.as_str()), Some(f64::from(b.start))); - let n = b.ids.len(); - let ready = rename.is_ok() && !(b.find.is_empty() && b.to.is_empty()); - let preview: Vec<(String, String)> = match (&rename, app.session.doc()) { - (Ok(r), Ok(doc)) if ready => b - .ids - .iter() - .enumerate() - .take(PREVIEW_ROWS) - .filter_map(|(k, i)| doc.node(*i).map(|node| (node.name.clone(), r.apply(&node.name, k, n)))) - .collect(), - _ => Vec::new(), - }; - let mut open = true; - let mut cancel = false; - let mut apply = false; - window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| { - ui.set_width(320.0); - ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); - ui.add_space(6.0); - let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); - crate::widgets::select_all_on_focus(ui, &r, &b.find); - if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) { - b.regex = !b.regex; - } - let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0)); - crate::widgets::select_all_on_focus(ui, &to, &b.to); - ui.horizontal(|ui| { - for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] { - if ui.button(label).clicked() { - b.to.push_str(code); - to.request_focus(); - } - } - }); - ui.horizontal(|ui| { - ui.label(tr("Start ascending from / stop descending at")); - ui.add(egui::DragValue::new(&mut b.start).range(0..=1_000_000)); - }); - ui.add_space(8.0); - match &rename { - Err(e) => { - ui.label(RichText::new(e).color(t.danger)); - } - Ok(_) => { - for (old, new) in &preview { - ui.horizontal(|ui| { - ui.label(RichText::new(old).color(t.text_secondary)); - ui.label("→"); - ui.label(new); - }); - } - if ready && n > PREVIEW_ROWS { - ui.label(RichText::new(trn("and {n} more", n - PREVIEW_ROWS, &[])).color(t.text_secondary)); - } - } - } - ui.add_space(8.0); - ui.horizontal(|ui| { - cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); - let enter = to.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - apply = (crate::widgets::button(ui, tr("Rename"), true, ready).clicked() || enter) && ready; - }); - }); - if apply { - app.run("object.batchRename", batch_rename_params(&b)); - } - app.ui.batch_rename = (open && !cancel && !apply).then_some(b); -} - -/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the -/// values) or keep the values (reinterpret them in the new space). -fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { - let Some(mut chosen) = app.ui.color_profile else { return }; - let Ok(current) = app.session.doc().map(|d| d.color_profile) else { - app.ui.color_profile = None; - return; - }; - let t = Tokens::get(ctx); - let mut open = true; - let mut apply = None; - window(tr("Color profile"), ctx).open(&mut open).show(ctx, |ui| { - ui.set_width(340.0); - ui.label(RichText::new(tr("Color values in this file are in this color space.")).color(t.text_secondary).font(regular(11.0))); - ui.add_space(8.0); - for p in ColorProfile::ALL { - let label = if p == current { trf("{profile} (current)", &[("profile", &p.label())]) } else { p.label().to_string() }; - if crate::widgets::radio(ui, chosen == p, &label) { - chosen = p; - } - } - ui.add_space(8.0); - if chosen == current { - ui.label(RichText::new(tr("Choose another profile to change it.")).color(t.text_secondary).font(regular(11.0))); - return; - } - ui.label( - RichText::new(trf( - "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift.", - &[("profile", &chosen.label())], - )) - .font(regular(11.0)), - ); - if !chosen.is_srgb() { - ui.add_space(4.0); - ui.label( - RichText::new(tr("The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3.")) - .color(t.text_secondary) - .font(regular(11.0)), - ); - } - ui.add_space(12.0); - ui.horizontal(|ui| { - if crate::widgets::button(ui, tr("Keep appearance"), true, true).clicked() { - apply = Some(true); - } - if crate::widgets::button(ui, tr("Keep values"), false, true).clicked() { - apply = Some(false); - } - }); - }); - app.ui.color_profile = open.then_some(chosen); - if let Some(convert) = apply { - app.run("file.colorProfile", json!({"profile": chosen.id(), "convert": convert})); - app.ui.color_profile = None; - } -} - -/// A `.fig` Save as result is an export; only native saves report that the document was saved. -pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { - if result["format"] == "fig" { - fig_exported(app, result); - } else { - app.toast(trf("Saved {name}", &[("name", &result_file_name(result))])); - } -} - -fn result_file_name(result: &Value) -> String { - match result["path"].as_str() { - Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), - // A download (#969). - None => result["name"].as_str().unwrap_or_default().to_owned(), - } -} - -/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. -pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { - let file = result_file_name(result); - let left_out = result["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); -} - -/// Save the whole file as `.fig`; the toast says how much was left out. -fn save_fig(app: &mut FigApp, name: &str) { - let filename = format!("{name}.fig"); - if app.services.downloads() { - if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) - && app.download(&filename, &r) - { - r["name"] = json!(filename); - fig_exported(app, &r); - } - return; - } - app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { - if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - fig_exported(app, &r); - } - }); -} - -/// What the Export dialog was asked to do. -enum ExportChoice { - /// Every layer with export settings, with those settings. - Layers, - /// The layers together as one file. - Once(ExportSetting), - /// The whole file as `.fig` (experimental). - Fig, -} - -/// The Export dialog: the layers' own export settings when they have some, and one-off presets for -/// the selection (or the page) as one file. -fn export_dialog(app: &mut FigApp, ctx: &Context) { - let t = Tokens::get(ctx); - let mut open = true; - let sel = app.session.selection(); - let Ok(ds) = app.session.ds() else { - app.ui.export_preview = false; - return; - }; - let (doc, page) = (ds.doc.clone(), ds.page); - let ids = if sel.is_empty() { doc.node(page).map(|p| p.children.clone()).unwrap_or_default() } else { sel.clone() }; - // With nothing selected, every layer on the page that has export settings. - let with_settings = if sel.is_empty() { exportable_layers(&doc, page) } else { sel }; - let jobs = export_jobs(&doc, &with_settings); - let print = app.ui.pdf_print; - let preset = |size: f64, format| ExportSetting { size: ExportSize::Scale { value: size }, format, print, ..Default::default() }; - let presets = [ - preset(1.0, ExportFormat::Png), - preset(2.0, ExportFormat::Png), - preset(3.0, ExportFormat::Png), - preset(2.0, ExportFormat::Jpg), - preset(2.0, ExportFormat::Webp), - preset(1.0, ExportFormat::Svg), - preset(1.0, ExportFormat::Pdf), - preset(1.0, ExportFormat::VectorDrawable), - ]; - let mut choice = None; - window(tr("Export"), ctx).open(&mut open).show(ctx, |ui| { - ui.set_width(320.0); - let caption = |ui: &mut egui::Ui, s: &str| { - ui.label(RichText::new(s).font(regular(11.0)).color(t.text_secondary)); - }; - let divider = |ui: &mut egui::Ui| { - ui.add_space(10.0); - let (r, _) = ui.allocate_exact_size(vec2(320.0, 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); - ui.add_space(10.0); - }; - let row_tip = |ui: &mut egui::Ui, label: &str, tip: Option<&str>| { - let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text); - match tip { - Some(tip) => resp.on_hover_text(tip).clicked(), - None => resp.clicked(), - } - }; - let row = |ui: &mut egui::Ui, label: &str| row_tip(ui, label, None); - if !jobs.is_empty() { - caption(ui, tr("With the layers' export settings")); - const SHOWN: usize = 6; - for j in jobs.iter().take(SHOWN) { - ui.label(RichText::new(&j.file).font(regular(12.0)).color(t.text)); - } - if jobs.len() > SHOWN { - caption(ui, &trn("and {n} more", jobs.len() - SHOWN, &[])); - } - ui.add_space(6.0); - let layers = jobs.iter().map(|j| j.id).collect::>().len(); - let label = trn("Export {n} layers", layers, &[]); - if crate::widgets::button(ui, &label, true, true).clicked() { - choice = Some(ExportChoice::Layers); - } - divider(ui); - } - caption(ui, &trn("{n} layers as one file", ids.len(), &[])); - ui.add_space(4.0); - for p in &presets { - let label = if p.format.is_raster() { format!("{} @{}", p.format.label(), p.size.label()) } else { p.format.label().to_string() }; - if row_tip(ui, &label, crate::export_panel::format_tip(p.format)) { - choice = Some(ExportChoice::Once(p.clone())); - } - } - // The PDF preset's print options. - ui.add_space(6.0); - caption(ui, tr("PDF print options")); - if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) { - app.ui.pdf_print = next; - } - divider(ui); - caption(ui, tr("The whole file")); - ui.add_space(4.0); - if row(ui, tr(".fig (experimental)")) { - choice = Some(ExportChoice::Fig); - } - caption(ui, tr("Other apps may not open every .fig file exported here.")); - }); - let close = choice.is_some() || !open; - match choice { - Some(ExportChoice::Layers) => export_layers(app, &with_settings, false), - Some(ExportChoice::Once(s)) if !ids.is_empty() => save_one(app, &ids, &s, &suggested_name(&doc, &ids, &s, "Export")), - Some(ExportChoice::Once(_)) => app.toast(tr("Nothing to export")), - Some(ExportChoice::Fig) => save_fig(app, &doc.name), - None => {} - } - if close { - app.ui.export_preview = false; - } -} diff --git a/crates/ui-egui/src/dialogs/batch_rename.rs b/crates/ui-egui/src/dialogs/batch_rename.rs new file mode 100644 index 000000000..b28055f2e --- /dev/null +++ b/crates/ui-egui/src/dialogs/batch_rename.rs @@ -0,0 +1,113 @@ +use super::*; + +/// The Rename layers dialog's fields. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BatchRenameUi { + /// The layers, in stacking order (bottom first), as numbered. + pub ids: Vec, + /// Match: empty renames the whole name. + pub find: String, + pub regex: bool, + /// Rename to. + pub to: String, + /// Where up numbers start and down numbers stop. + pub start: u32, +} + +/// Layers whose new names the dialog previews. +const PREVIEW_ROWS: usize = 5; + +/// Open the Rename layers dialog for `ids` (several layers: Cmd/Ctrl+R, F2, the context menu). +pub(crate) fn open_batch_rename(app: &mut FigApp, ids: &[NodeId]) { + let Ok(doc) = app.session.doc() else { return }; + let ids = figcraft_engine::cmd::edit::z_sorted(doc, ids); + app.ui.batch_rename = Some(BatchRenameUi { ids, start: 1, ..Default::default() }); +} + +/// The rename the fields describe: a match replaced, or (no match) the whole name. +pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { + let mut p = json!({"ids": b.ids, "regex": b.regex, "start": b.start}); + if b.find.is_empty() { + p["name"] = json!(b.to); + } else { + p["find"] = json!(b.find); + p["replace"] = json!(b.to); + } + p +} + +/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers +/// start, a preview of the first new names, then one `object.batchRename`. +pub(super) fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut b) = app.ui.batch_rename.clone() else { return }; + let t = Tokens::get(ctx); + let find = (!b.find.is_empty()).then_some(b.find.as_str()); + let rename = figcraft_engine::rename::BatchRename::new(find, b.regex, &b.to, find.is_none().then_some(b.to.as_str()), Some(f64::from(b.start))); + let n = b.ids.len(); + let ready = rename.is_ok() && !(b.find.is_empty() && b.to.is_empty()); + let preview: Vec<(String, String)> = match (&rename, app.session.doc()) { + (Ok(r), Ok(doc)) if ready => b + .ids + .iter() + .enumerate() + .take(PREVIEW_ROWS) + .filter_map(|(k, i)| doc.node(*i).map(|node| (node.name.clone(), r.apply(&node.name, k, n)))) + .collect(), + _ => Vec::new(), + }; + let mut open = true; + let mut cancel = false; + let mut apply = false; + window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); + ui.add_space(6.0); + let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &r, &b.find); + if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) { + b.regex = !b.regex; + } + let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &to, &b.to); + ui.horizontal(|ui| { + for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] { + if ui.button(label).clicked() { + b.to.push_str(code); + to.request_focus(); + } + } + }); + ui.horizontal(|ui| { + ui.label(tr("Start ascending from / stop descending at")); + ui.add(egui::DragValue::new(&mut b.start).range(0..=1_000_000)); + }); + ui.add_space(8.0); + match &rename { + Err(e) => { + ui.label(RichText::new(e).color(t.danger)); + } + Ok(_) => { + for (old, new) in &preview { + ui.horizontal(|ui| { + ui.label(RichText::new(old).color(t.text_secondary)); + ui.label("→"); + ui.label(new); + }); + } + if ready && n > PREVIEW_ROWS { + ui.label(RichText::new(trn("and {n} more", n - PREVIEW_ROWS, &[])).color(t.text_secondary)); + } + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + let enter = to.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + apply = (crate::widgets::button(ui, tr("Rename"), true, ready).clicked() || enter) && ready; + }); + }); + if apply { + app.run("object.batchRename", batch_rename_params(&b)); + } + app.ui.batch_rename = (open && !cancel && !apply).then_some(b); +} diff --git a/crates/ui-egui/src/dialogs/color_profile.rs b/crates/ui-egui/src/dialogs/color_profile.rs new file mode 100644 index 000000000..f818c50dc --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_profile.rs @@ -0,0 +1,59 @@ +use super::*; + +/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the +/// values) or keep the values (reinterpret them in the new space). +pub(super) fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut chosen) = app.ui.color_profile else { return }; + let Ok(current) = app.session.doc().map(|d| d.color_profile) else { + app.ui.color_profile = None; + return; + }; + let t = Tokens::get(ctx); + let mut open = true; + let mut apply = None; + window(tr("Color profile"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(340.0); + ui.label(RichText::new(tr("Color values in this file are in this color space.")).color(t.text_secondary).font(regular(11.0))); + ui.add_space(8.0); + for p in ColorProfile::ALL { + let label = if p == current { trf("{profile} (current)", &[("profile", &p.label())]) } else { p.label().to_string() }; + if crate::widgets::radio(ui, chosen == p, &label) { + chosen = p; + } + } + ui.add_space(8.0); + if chosen == current { + ui.label(RichText::new(tr("Choose another profile to change it.")).color(t.text_secondary).font(regular(11.0))); + return; + } + ui.label( + RichText::new(trf( + "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift.", + &[("profile", &chosen.label())], + )) + .font(regular(11.0)), + ); + if !chosen.is_srgb() { + ui.add_space(4.0); + ui.label( + RichText::new(tr("The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3.")) + .color(t.text_secondary) + .font(regular(11.0)), + ); + } + ui.add_space(12.0); + ui.horizontal(|ui| { + if crate::widgets::button(ui, tr("Keep appearance"), true, true).clicked() { + apply = Some(true); + } + if crate::widgets::button(ui, tr("Keep values"), false, true).clicked() { + apply = Some(false); + } + }); + }); + app.ui.color_profile = open.then_some(chosen); + if let Some(convert) = apply { + app.run("file.colorProfile", json!({"profile": chosen.id(), "convert": convert})); + app.ui.color_profile = None; + } +} diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs new file mode 100644 index 000000000..e3a96e59b --- /dev/null +++ b/crates/ui-egui/src/dialogs/export.rs @@ -0,0 +1,160 @@ +use super::*; + +/// A `.fig` Save as result is an export; only native saves report that the document was saved. +pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { + if result["format"] == "fig" { + fig_exported(app, result); + } else { + app.toast(trf("Saved {name}", &[("name", &result_file_name(result))])); + } +} + +fn result_file_name(result: &Value) -> String { + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } +} + +/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. +pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { + let file = result_file_name(result); + let left_out = result["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); +} + +/// Save the whole file as `.fig`; the toast says how much was left out. +fn save_fig(app: &mut FigApp, name: &str) { + let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } + app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { + if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { + fig_exported(app, &r); + } + }); +} + +/// What the Export dialog was asked to do. +enum ExportChoice { + /// Every layer with export settings, with those settings. + Layers, + /// The layers together as one file. + Once(ExportSetting), + /// The whole file as `.fig` (experimental). + Fig, +} + +/// The Export dialog: the layers' own export settings when they have some, and one-off presets for +/// the selection (or the page) as one file. +pub(super) fn export_dialog(app: &mut FigApp, ctx: &Context) { + let t = Tokens::get(ctx); + let mut open = true; + let sel = app.session.selection(); + let Ok(ds) = app.session.ds() else { + app.ui.export_preview = false; + return; + }; + let (doc, page) = (ds.doc.clone(), ds.page); + let ids = if sel.is_empty() { doc.node(page).map(|p| p.children.clone()).unwrap_or_default() } else { sel.clone() }; + // With nothing selected, every layer on the page that has export settings. + let with_settings = if sel.is_empty() { exportable_layers(&doc, page) } else { sel }; + let jobs = export_jobs(&doc, &with_settings); + let print = app.ui.pdf_print; + let preset = |size: f64, format| ExportSetting { size: ExportSize::Scale { value: size }, format, print, ..Default::default() }; + let presets = [ + preset(1.0, ExportFormat::Png), + preset(2.0, ExportFormat::Png), + preset(3.0, ExportFormat::Png), + preset(2.0, ExportFormat::Jpg), + preset(2.0, ExportFormat::Webp), + preset(1.0, ExportFormat::Svg), + preset(1.0, ExportFormat::Pdf), + preset(1.0, ExportFormat::VectorDrawable), + ]; + let mut choice = None; + window(tr("Export"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + let caption = |ui: &mut egui::Ui, s: &str| { + ui.label(RichText::new(s).font(regular(11.0)).color(t.text_secondary)); + }; + let divider = |ui: &mut egui::Ui| { + ui.add_space(10.0); + let (r, _) = ui.allocate_exact_size(vec2(320.0, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); + ui.add_space(10.0); + }; + let row_tip = |ui: &mut egui::Ui, label: &str, tip: Option<&str>| { + let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text); + match tip { + Some(tip) => resp.on_hover_text(tip).clicked(), + None => resp.clicked(), + } + }; + let row = |ui: &mut egui::Ui, label: &str| row_tip(ui, label, None); + if !jobs.is_empty() { + caption(ui, tr("With the layers' export settings")); + const SHOWN: usize = 6; + for j in jobs.iter().take(SHOWN) { + ui.label(RichText::new(&j.file).font(regular(12.0)).color(t.text)); + } + if jobs.len() > SHOWN { + caption(ui, &trn("and {n} more", jobs.len() - SHOWN, &[])); + } + ui.add_space(6.0); + let layers = jobs.iter().map(|j| j.id).collect::>().len(); + let label = trn("Export {n} layers", layers, &[]); + if crate::widgets::button(ui, &label, true, true).clicked() { + choice = Some(ExportChoice::Layers); + } + divider(ui); + } + caption(ui, &trn("{n} layers as one file", ids.len(), &[])); + ui.add_space(4.0); + for p in &presets { + let label = if p.format.is_raster() { format!("{} @{}", p.format.label(), p.size.label()) } else { p.format.label().to_string() }; + if row_tip(ui, &label, crate::export_panel::format_tip(p.format)) { + choice = Some(ExportChoice::Once(p.clone())); + } + } + // The PDF preset's print options. + ui.add_space(6.0); + caption(ui, tr("PDF print options")); + if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) { + app.ui.pdf_print = next; + } + divider(ui); + caption(ui, tr("The whole file")); + ui.add_space(4.0); + if row(ui, tr(".fig (experimental)")) { + choice = Some(ExportChoice::Fig); + } + caption(ui, tr("Other apps may not open every .fig file exported here.")); + }); + let close = choice.is_some() || !open; + match choice { + Some(ExportChoice::Layers) => export_layers(app, &with_settings, false), + Some(ExportChoice::Once(s)) if !ids.is_empty() => save_one(app, &ids, &s, &suggested_name(&doc, &ids, &s, "Export")), + Some(ExportChoice::Once(_)) => app.toast(tr("Nothing to export")), + Some(ExportChoice::Fig) => save_fig(app, &doc.name), + None => {} + } + if close { + app.ui.export_preview = false; + } +} diff --git a/crates/ui-egui/src/dialogs/menu_input.rs b/crates/ui-egui/src/dialogs/menu_input.rs new file mode 100644 index 000000000..e815c532a --- /dev/null +++ b/crates/ui-egui/src/dialogs/menu_input.rs @@ -0,0 +1,75 @@ +use super::*; + +/// Values collected by a menu or Actions command before dispatching it to the engine. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuInput { + Link(String), + Offset(f64), + MoveToPage, +} + +/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular +/// command so the same validation and undo behaviour apply as for programmatic callers. +pub(super) fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut input) = app.ui.menu_input.take() else { return }; + let t = Tokens::get(ctx); + let title = match &input { + MenuInput::Link(_) => i18n::command("text.link", "Create link"), + MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), + MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), + }; + let mut open = true; + let mut action: Option<(&str, serde_json::Value)> = None; + window(&title, ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + match &mut input { + MenuInput::Link(url) => { + ui.horizontal(|ui| { + let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://"); + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let ok = !url.trim().is_empty(); + if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { + action = Some(("text.link", json!({"url": url.trim()}))); + } + }); + } + MenuInput::Offset(offset) => { + ui.horizontal(|ui| { + let id = egui::Id::unique("menu_input_offset"); + if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = + crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) + { + *offset = v.clamp(-100_000.0, 100_000.0); + } + if crate::widgets::button(ui, tr("Offset"), true, true).clicked() { + action = Some(("vector.offset", json!({"offset": *offset}))); + } + }); + } + MenuInput::MoveToPage => { + let mut choices = 0; + if let Ok(ds) = app.session.ds() { + for &page in &ds.doc.pages { + if page == ds.page || ds.doc.is_divider(page) { + continue; + } + if let Some(node) = ds.doc.node(page) + && crate::widgets::button(ui, &node.name, false, true).clicked() + { + action = Some(("object.moveToPage", json!({"page": page}))); + } + choices += 1; + } + } + if choices == 0 { + crate::widgets::label(ui, tr("No other pages"), t.text_secondary); + } + } + } + }); + if let Some((id, params)) = action { + app.run(id, params); + } else if open { + app.ui.menu_input = Some(input); + } +}