refactor(ui-egui): split dialogs.rs into dialogs/

This commit is contained in:
aiongg
2026-10-26 20:17:48 +00:00
parent a9d93f8310
commit d8de0fdea7
5 changed files with 423 additions and 403 deletions
+16 -403
View File
@@ -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<NodeId>,
/// 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, &note, 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::<std::collections::BTreeSet<_>>().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;
}
}
+113
View File
@@ -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<NodeId>,
/// 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);
}
@@ -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;
}
}
+160
View File
@@ -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::<std::collections::BTreeSet<_>>().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;
}
}
+75
View File
@@ -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);
}
}