Files
figcraft/crates/ui-egui/src/dialogs.rs
T

796 lines
34 KiB
Rust

//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables,
//! export.
use std::path::Path;
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_color::ColorProfile;
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId};
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
use serde_json::{Value, json};
use crate::FigApp;
use crate::export_panel::{export_layers, save_one, suggested_name};
use crate::i18n::{self, tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular, semibold};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Where a report goes.
const ISSUES: &str = "https://github.com/storytold/figcraft/issues";
/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the
/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND`
/// named the graphics API, so the hint is to unset it rather than to try another one.
pub fn window_failed(error: &str, backend_set: bool) -> String {
let hint = if backend_set {
tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string()
} else if cfg!(windows) {
// Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked
// even when its device then fails, so naming OpenGL alone can still help.
format!(
"{}
set WGPU_BACKEND=gl
figcraft.exe",
tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:")
)
} else {
tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string()
};
format!(
"{}
{hint}
{}",
trf("FigCraft couldn't open its window: {error}", &[("error", &error)]),
trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)])
)
}
/// A dialog window, centred.
pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
}
/// A window styled like the dialogs that the user can move.
pub(crate) fn floating<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
let t = Tokens::get(ctx);
egui::Window::new(title)
.collapsible(false)
.resizable(false)
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
}
/// The About window's tabs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AboutTab {
#[default]
About,
Contributors,
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];
pub fn label(self) -> &'static str {
match self {
AboutTab::About => tr("About"),
AboutTab::Contributors => tr("Contributors"),
AboutTab::Models => tr("Models"),
}
}
}
/// The About window's width, the same on every tab (the Contributors table needs the room).
pub const ABOUT_WIDTH: f32 = 660.0;
/// The About window's tab row, drawn like the Design / Prototype tabs of the right panel.
/// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted.
pub(crate) fn tab_strip<T: Copy + PartialEq>(ui: &mut egui::Ui, t: &Tokens, tabs: &[T], label: fn(T) -> &'static str, current: &mut T) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for &tab in tabs {
let label = label(tab);
let active = *current == tab;
let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary });
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label));
if active || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
ui.painter().galley(egui::pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if resp.clicked() {
*current = tab;
}
}
});
}
/// About ▸ About: the logo, version, blurb and links.
fn about_body(ui: &mut egui::Ui, t: &Tokens) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT);
icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE);
ui.vertical(|ui| {
ui.label(RichText::new("FigCraft").font(semibold(18.0)));
ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary));
});
});
ui.add_space(12.0);
ui.label(tr(
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps.",
));
ui.add_space(8.0);
ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft");
ui.hyperlink_to(tr("Join us on Discord"), "https://discord.gg/artcraft");
ui.add_space(8.0);
ui.label(
RichText::new(tr("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.")).color(t.text_secondary).font(regular(11.0)),
);
}
/// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's
/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu
/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone.
pub fn keys(app: &mut FigApp, ctx: &Context) {
if egui::Popup::is_any_open(ctx) {
return;
}
let u = &mut app.ui;
let open = u.color_profile.is_some()
|| u.menu_input.is_some()
|| u.find.is_some()
|| u.batch_rename.is_some()
|| u.import_report.is_some()
|| u.missing_fonts.is_some()
|| u.confirm_close
|| u.export_preview
|| u.shortcuts_panel
|| u.preferences
|| u.about;
if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
return;
}
if u.menu_input.take().is_some()
|| u.color_profile.take().is_some()
|| u.find.take().is_some()
|| u.batch_rename.take().is_some()
|| u.import_report.take().is_some()
|| u.missing_fonts.take().is_some()
{
return;
}
for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] {
if std::mem::take(flag) {
return;
}
}
}
pub fn show(app: &mut FigApp, ctx: &Context) {
// Dropped files waiting to open: their card, then (once it has shown) the files.
crate::pending_open::pending_opens(app, ctx);
crate::import_report::show(app, ctx);
crate::missing_fonts::show(app, ctx);
let t = Tokens::get(ctx);
if app.ui.about {
let mut open = true;
window(tr("About FigCraft"), ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(ABOUT_WIDTH);
tab_strip(ui, &t, &AboutTab::ALL, AboutTab::label, &mut app.ui.about_tab);
ui.add_space(12.0);
match app.ui.about_tab {
AboutTab::About => about_body(ui, &t),
AboutTab::Contributors => crate::credits::contributors_ui(ui),
AboutTab::Models => crate::credits::models_ui(ui),
}
});
if !open {
app.ui.about = false;
}
}
if app.ui.preferences {
let mut open = true;
window(tr("Preferences"), ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(360.0);
let prefs = app.session.prefs.clone();
let mut toggles = vec![];
for (label, val, cmd) in [
(tr("Keep tool selected after use"), prefs.keep_tool, "pref.keepTool"),
(tr("Highlight layers on hover"), prefs.highlight_on_hover, "pref.highlightHover"),
(tr("Rename duplicated layers"), prefs.rename_duplicates, "pref.renameDuplicates"),
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
] {
if crate::widgets::checkbox(ui, val, label) {
toggles.push(cmd);
}
}
if app.services.reduce_motion() {
crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary);
}
// The browser has no files to save to.
if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) {
toggles.push("pref.autoSave");
}
for c in toggles {
app.run(c, json!({}));
}
// Where the app registers itself as the opener of invite links (Windows).
if let Some(on) = app.session.invite_links_on() {
if crate::widgets::checkbox(ui, on, tr("Open invite links in FigCraft")) {
app.run("collab.registerLinks", json!({"on": !on}));
}
crate::widgets::label(
ui,
tr("figcraft:// links from a browser, chat or email open this app and join the session."),
t.text_secondary,
);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Your name in comments"));
if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) {
app.run("pref.userName", json!({"name": name}));
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Server for online sharing"));
// Where Share ▸ Online keeps files and join codes are asked; no key here
// (preferences are saved as text).
let name = tr("Server for online sharing");
if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) {
app.run("pref.publishServer", json!({"url": url}));
}
let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER;
if crate::widgets::button(ui, tr("Reset"), false, !usual).on_hover_text(tr("Back to the FigCraft server")).clicked() {
app.run("pref.publishServer", json!({}));
}
});
if prefs.publish_server.is_empty() {
crate::widgets::label(ui, tr("Online sharing is off: files are shared on the local network only."), t.text_secondary);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Nudge amount"));
let mut small = prefs.nudge_small;
let mut big = prefs.nudge_big;
let a = ui.add(egui::DragValue::new(&mut small).range(0.1..=100.0));
let b = ui.add(egui::DragValue::new(&mut big).range(1.0..=1000.0));
if a.changed() || b.changed() {
app.run("pref.nudge", json!({"small": small, "big": big}));
}
});
ui.add_space(8.0);
interface_size(app, ui, prefs.ui_scale);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Theme"));
for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] {
if ui.selectable_label(app.session.prefs.theme.eq_ignore_ascii_case(l), tr(l)).clicked() {
app.run(c, json!({}));
}
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Language"));
// Each language by its own name; System follows the OS (the browser on the web).
let langs = || {
[("system", tr("System"))]
.into_iter()
.chain(i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)))
};
let on = |code: &str| app.session.prefs.language.eq_ignore_ascii_case(code);
let selected = langs().find(|(code, _)| on(code)).map_or(tr("System"), |(_, name)| name);
let resp = crate::widgets::dropdown(ui, selected, 220.0, None);
let mut pick = None;
crate::right::menu_popup(&resp).show(|ui| {
for (code, name) in langs() {
if crate::right::menu_check(ui, on(code), name, "") {
pick = Some(code);
ui.close();
}
}
});
if let Some(code) = pick {
app.run("pref.language", json!({"language": code}));
ui.ctx().request_repaint();
}
});
if let Some(lang) = i18n::current().filter(|l| l.draft) {
let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]);
crate::widgets::label(ui, &note, t.text_secondary);
}
});
if !open {
app.ui.preferences = false;
}
}
crate::shortcuts_panel::show(app, ctx);
if let Some((mut find, mut replace)) = app.ui.find.clone() {
let mut open = true;
let mut action = None;
egui::Window::new(tr("Find and replace"))
.id(egui::Id::new("Find and replace"))
.open(&mut open)
.anchor(Align2::LEFT_TOP, vec2(260.0, 120.0))
.resizable(false)
.show(ctx, |ui| {
ui.set_width(280.0);
let r = ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0));
crate::widgets::select_all_on_focus(ui, &r, &find);
let r = ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0));
crate::widgets::select_all_on_focus(ui, &r, &replace);
ui.horizontal(|ui| {
if ui.button(tr("Find")).clicked() {
action = Some(false);
}
if ui.button(tr("Replace all")).clicked() {
action = Some(true);
}
});
});
if let Some(rep) = action
&& !find.is_empty()
{
let r = if rep {
app.run("edit.findReplace", json!({"find": find, "replace": replace, "layerNames": false}))
} else {
app.run("edit.findReplace", json!({"find": find, "layerNames": true}))
};
if let Some(v) = r {
let n = v["matches"].as_array().map(|a| a.len()).unwrap_or(0);
app.toast(trn("{n} matches", n, &[]));
}
}
app.ui.find = if open { Some((find, replace)) } else { None };
}
batch_rename_dialog(app, ctx);
menu_input_dialog(app, ctx);
crate::variables_panel::show(app, ctx);
crate::dev_compare::show(app, ctx);
crate::branch_ui::show(app, ctx);
if app.ui.export_preview {
export_dialog(app, ctx);
}
color_profile_dialog(app, ctx);
crate::plugins_ui::show(app, ctx);
crate::live::join_dialog(app, ctx);
crate::live::publish_dialog(app, ctx);
crate::online::dialogs(app, ctx);
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::new("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::new("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.
fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) {
let t = Tokens::get(ui.ctx());
if cfg!(target_arch = "wasm32") {
ui.label(tr("Interface size"));
crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary);
return;
}
let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]);
let shown = percent(chosen);
ui.horizontal(|ui| {
ui.label(tr("Interface size"));
let resp = crate::widgets::dropdown(ui, &shown, 120.0, None);
crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp));
crate::right::menu_popup(&resp).show(|ui| {
for size in crate::ui_scale::SIZES {
if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") {
app.run("pref.uiScale", json!({"scale": size}));
ui.close();
}
}
});
});
crate::widgets::label(
ui,
tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."),
t.text_secondary,
);
// Said in words, not only by the menu's value: the window is too small for the size chosen.
if app.ui.ui_scale.limited {
let note = trf(
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.",
&[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))],
);
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::new("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 {
result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default()
}
/// 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");
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),
];
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 = |ui: &mut egui::Ui, label: &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);
resp.clicked()
};
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(ui, &label) {
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::new("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;
}
}