mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
refactor(ui-egui): split dialogs.rs into dialogs/
This commit is contained in:
+16
-403
@@ -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, ¬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::<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user