mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
1000 lines
45 KiB
Rust
1000 lines
45 KiB
Rust
//! File → Export → Save for Web (Legacy) (`saveForWeb`, Ctrl/Cmd+Alt+Shift+S): the art previewed
|
||
//! as the web image Save writes, beside the original (Original, Optimized, 2-Up and 4-Up views,
|
||
//! each with its file size and download time), and the settings: format (GIF, JPEG, PNG-8,
|
||
//! PNG-24), colour reduction, colours, dither, transparency and matte, interlacing, web snap,
|
||
//! lossy, JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size
|
||
//! and anti-aliasing, Clip to Artboard, sRGB, metadata, slices and HTML output, and presets.
|
||
//!
|
||
//! Fields are the `document.exportForWeb` settings (`webExport.settings` gives them; set one with
|
||
//! `ui.dialog.set`, or `preset` to load a preset), plus the dialog's own: `__view` (`original`,
|
||
//! `optimized`, `2up`, `4up`), `__zoom` (`fit` or a percentage), `__kbps` (connection speed),
|
||
//! `__color` (the colour table's selected colour, by source), `__presetName` (shown while saving a
|
||
//! preset) and `__hasSlices`. Save… remembers
|
||
//! the settings and writes the files (`file.saveForWeb`); Done only remembers them; Preview in
|
||
//! Browser opens the HTML page (`file.saveForWeb.browser`). The previews are what Save writes:
|
||
//! [`webexport::preview_image`], decoded back, cached until the art or a setting changes.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Map, Value, json};
|
||
use vectorcraft_engine::cmd::webexport::{
|
||
self, SPEEDS, SliceScope, TableColor, WebFormat, WebMetadata, WebOutput, WebRender, WebSettings, download_seconds, hex,
|
||
};
|
||
use vectorcraft_render::AntiAlias;
|
||
use vectorcraft_render::encode::quantize::{Dither, Reduction};
|
||
use vectorcraft_render::encode::web::ColorSort;
|
||
|
||
use super::png_options::color_button;
|
||
use super::{DialogSpec, form};
|
||
use crate::state::Dialog;
|
||
use crate::theme::Tokens;
|
||
use crate::{VectorcraftApp, io, widgets};
|
||
|
||
pub const KIND: &str = "saveForWeb";
|
||
|
||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||
heading: |_| tl!("Save for Web").into(),
|
||
body,
|
||
confirm,
|
||
ok: Some("Save…"),
|
||
ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Save…" }),
|
||
discard: Some("Done"),
|
||
min_width: PREVIEW_W + GAP + SIDE_W,
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// The preview area (panes and their captions) and the settings column.
|
||
const PREVIEW_W: f32 = 620.0;
|
||
const PREVIEW_H: f32 = 470.0;
|
||
const SIDE_W: f32 = 296.0;
|
||
const GAP: f32 = 16.0;
|
||
/// Label column and field widths of the settings column.
|
||
const LABEL_W: f32 = 96.0;
|
||
const FIELD_W: f32 = 190.0;
|
||
/// A pane's caption height.
|
||
const CAPTION_H: f32 = 34.0;
|
||
|
||
/// The views (`__view`) and their tabs.
|
||
const VIEWS: [&str; 4] = ["original", "optimized", "2up", "4up"];
|
||
const VIEW_LABELS: [&str; 4] = ["Original", "Optimized", "2-Up", "4-Up"];
|
||
/// Zoom choices (`__zoom`: `fit` or a percentage).
|
||
const ZOOMS: [&str; 5] = ["fit", "50", "100", "200", "400"];
|
||
const ZOOM_LABELS: [&str; 5] = ["Fit in View", "50%", "100%", "200%", "400%"];
|
||
/// Palette sizes offered (any 2–256 can be typed).
|
||
const COLOR_COUNTS: [&str; 8] = ["2", "4", "8", "16", "32", "64", "128", "256"];
|
||
/// Matte values and their labels (`Other`: a colour).
|
||
const MATTES: [&str; 3] = ["none", "white", "black"];
|
||
const MATTE_LABELS: [&str; 4] = ["None", "White", "Black", "Other"];
|
||
/// JPEG quality steps: (label, quality).
|
||
const QUALITIES: [(&str, u8); 5] = [("Low", 10), ("Medium", 30), ("High", 60), ("Very High", 80), ("Maximum", 100)];
|
||
|
||
/// Open on remembered settings; with the Artboard tool, use its active artboard (also in a multiselection).
|
||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||
let n = app.session.active().ok_or("no document")?.doc.artboards.len();
|
||
let mut s = app.session.last_web_settings();
|
||
if app.session.tool_id() == "artboard" {
|
||
s.artboard = Some(crate::panels::artboards::selected(app, n));
|
||
}
|
||
// A remembered artboard this document doesn't have: its first.
|
||
if s.artboard.is_some_and(|a| a >= n) {
|
||
s.artboard = None;
|
||
}
|
||
let mut fields = settings_fields(&s);
|
||
let slices = app.session.active().is_some_and(|st| !st.doc.slice_layout().is_empty());
|
||
fields.extend([
|
||
("__view".into(), json!("2up")),
|
||
("__zoom".into(), json!("100")),
|
||
("__kbps".into(), json!(56.6)),
|
||
("__hasSlices".into(), json!(slices)),
|
||
]);
|
||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// The settings as dialog fields.
|
||
fn settings_fields(s: &WebSettings) -> Map<String, Value> {
|
||
s.to_json().as_object().cloned().unwrap_or_default()
|
||
}
|
||
|
||
/// The settings fields of the dialog (the `document.exportForWeb` params).
|
||
fn params(d: &Dialog) -> Value {
|
||
let mut p = form::params(d);
|
||
if let Some(o) = p.as_object_mut() {
|
||
o.remove("discard");
|
||
}
|
||
p
|
||
}
|
||
|
||
/// A `preset` field (set by an agent, or the preset list) replaces the settings with the preset's.
|
||
fn apply_preset(app: &VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||
let Some(name) = d.fields.remove("preset") else { return Ok(()) };
|
||
let s = app.session.web_settings(&json!({ "preset": name }))?;
|
||
d.fields.extend(settings_fields(&s));
|
||
Ok(())
|
||
}
|
||
|
||
/// The settings the fields describe.
|
||
fn settings_of(app: &VectorcraftApp, d: &Dialog) -> Result<WebSettings, String> {
|
||
app.session.web_settings(¶ms(d))
|
||
}
|
||
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let mut d = d.clone();
|
||
apply_preset(app, &mut d)?;
|
||
let p = params(&d);
|
||
// Done remembers the settings; Save… remembers them and writes the files.
|
||
app.run("webExport.settings", p.clone())?;
|
||
if d.bool("discard") {
|
||
app.ui.dialog = None;
|
||
return Ok(json!({ "remembered": true }));
|
||
}
|
||
let r = save(app, p)?;
|
||
app.ui.dialog = None;
|
||
Ok(r)
|
||
}
|
||
|
||
/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML
|
||
/// output, else the image); the web downloads them. The status bar shows the export's first
|
||
/// warning.
|
||
pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
|
||
// Bad settings never open a save panel.
|
||
let s = app.session.web_settings(&p)?;
|
||
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
|
||
if path.is_none() && io::is_web(app) {
|
||
let r = app.run("document.exportForWeb", p)?;
|
||
let mut names = vec![];
|
||
for f in r["files"].as_array().into_iter().flatten() {
|
||
let (Some(name), Some(data)) = (f["name"].as_str(), f["dataBase64"].as_str()) else { continue };
|
||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
|
||
io::write_to(&mut app.services, name, &bytes)?;
|
||
names.push(json!(name));
|
||
}
|
||
match names.first().and_then(Value::as_str).filter(|_| has_warnings(&r)) {
|
||
Some(first) => io::report_saved(app, first, &r),
|
||
None => app.status(format!("Downloaded {} file(s)", names.len())),
|
||
}
|
||
return Ok(json!({ "files": names, "bytes": r["bytes"], "warnings": r["warnings"] }));
|
||
}
|
||
let ext = if s.output == WebOutput::Html { "html" } else { s.format.ext() };
|
||
let path = io::target_path(app, path, ext)?;
|
||
if let Some(o) = p.as_object_mut() {
|
||
o.insert("path".into(), json!(path));
|
||
}
|
||
let r = app.run("document.exportForWeb", p)?;
|
||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||
if n > 1 && !has_warnings(&r) {
|
||
app.status(format!("Saved {n} files beside {path}"));
|
||
} else {
|
||
io::report_saved(app, &path, &r);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Whether an export's reply `r` has warnings (an image that couldn't be decoded):
|
||
/// [`io::report_saved`] puts the first in the status bar as a save's note.
|
||
fn has_warnings(r: &Value) -> bool {
|
||
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
|
||
}
|
||
|
||
/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the
|
||
/// default browser (desktop). When the export has warnings, the status bar shows the first in
|
||
/// place of "Opened …".
|
||
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||
if io::is_web(app) {
|
||
return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into());
|
||
}
|
||
let stem = app.session.active().map(|st| vectorcraft_engine::cmd::fileio::file_stem(&st.doc.title)).ok_or("no document")?;
|
||
let dir = temp_folder()?;
|
||
let path = format!("{dir}/{}.html", if stem.trim().is_empty() { "Untitled" } else { stem.as_str() });
|
||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||
if let Some(o) = q.as_object_mut() {
|
||
o.insert("path".into(), json!(path));
|
||
o.insert("output".into(), json!("html"));
|
||
}
|
||
let r = app.run("document.exportForWeb", q)?;
|
||
app.open_url(&io::file_url(&path));
|
||
if has_warnings(&r) {
|
||
io::report_saved(app, &path, &r);
|
||
}
|
||
Ok(json!({ "path": path, "warnings": r["warnings"] }))
|
||
}
|
||
|
||
/// The age at which a later browser preview removes an earlier preview's folder.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const PREVIEW_KEEP: std::time::Duration = std::time::Duration::from_secs(3600);
|
||
|
||
/// The folder a browser preview goes to: a new one for each preview, in the system's temporary
|
||
/// folder ([`preview_folder_in`]).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn temp_folder() -> Result<String, String> {
|
||
preview_folder_in(&std::env::temp_dir(), PREVIEW_KEEP)
|
||
}
|
||
|
||
/// The name of a folder that a preview made in the temporary folder, beside a `VectorCraft Save
|
||
/// for Web` it couldn't use, before the process id and the count.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const BESIDE: &str = "VectorCraft Save for Web ";
|
||
|
||
/// A new folder for a browser preview in the temporary folder `tmp`: inside `VectorCraft Save for
|
||
/// Web` ([`preview_folder`]), or, when that folder can't be used, directly in `tmp`, named
|
||
/// [`BESIDE`] followed by the process id and a count. The log says why the shared folder wasn't
|
||
/// used. Each preview first removes the folders of that name in `tmp` last changed `keep` or longer
|
||
/// ago ([`remove_old`]).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn preview_folder_in(tmp: &std::path::Path, keep: std::time::Duration) -> Result<String, String> {
|
||
remove_old(tmp, BESIDE, keep);
|
||
preview_folder(&tmp.join("VectorCraft Save for Web"), keep).or_else(|e| {
|
||
log::warn!("vectorcraft: Preview in Browser can't use {e}, so the preview goes to a new folder of its own");
|
||
new_folder(tmp, BESIDE)
|
||
})
|
||
}
|
||
|
||
/// A new folder in `root` ([`new_folder`]). `root` is made when it is missing. An existing `root`
|
||
/// is refused when it is a link or not a folder, or, on unix, when it belongs to another user or
|
||
/// users other than its owner can write to it. Before the new folder is made, the preview folders
|
||
/// in `root` last changed `keep` or longer ago are removed ([`remove_old`]).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result<String, String> {
|
||
match private_folder().recursive(true).create(root) {
|
||
Ok(()) => {}
|
||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||
Err(e) => return Err(format!("{}: {e}", root.display())),
|
||
}
|
||
let m = std::fs::symlink_metadata(root).map_err(|e| format!("{}: {e}", root.display()))?;
|
||
if let Some(why) = refused(&m) {
|
||
return Err(format!("{}: {}", root.display(), why));
|
||
}
|
||
remove_old(root, "", keep);
|
||
new_folder(root, "")
|
||
}
|
||
|
||
/// Removes the preview folders in `dir` last changed `keep` or longer ago: folders (not links)
|
||
/// named `prefix` followed by `<digits>-<digits>`, on unix only those the current user owns. Other
|
||
/// entries are left alone.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn remove_old(dir: &std::path::Path, prefix: &str, keep: std::time::Duration) {
|
||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||
if !is_preview_name(&e.file_name(), prefix) {
|
||
continue;
|
||
}
|
||
// `DirEntry::metadata` doesn't follow a link, so a link is never a folder here.
|
||
let Ok(m) = e.metadata() else { continue };
|
||
let old = m.modified().ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep);
|
||
if old && m.is_dir() && mine(&m) {
|
||
// Best effort: one that can't be removed now is tried again by the next preview.
|
||
let _ = std::fs::remove_dir_all(e.path());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A new folder in `parent` named `prefix` followed by the process id, `-` and a count of the
|
||
/// process's previews; on unix only its owner can open it. A name that is already taken, by an
|
||
/// earlier process with the same id or by anyone else, is skipped for the next count.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn new_folder(parent: &std::path::Path, prefix: &str) -> Result<String, String> {
|
||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
loop {
|
||
let dir = parent.join(format!("{prefix}{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||
match private_folder().create(&dir) {
|
||
Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")),
|
||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A builder for folders that only their owner can read, write or open (mode 0o700).
|
||
#[cfg(unix)]
|
||
fn private_folder() -> std::fs::DirBuilder {
|
||
let mut b = std::fs::DirBuilder::new();
|
||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||
b
|
||
}
|
||
|
||
/// A builder for folders: a plain one outside unix.
|
||
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
|
||
fn private_folder() -> std::fs::DirBuilder {
|
||
std::fs::DirBuilder::new()
|
||
}
|
||
|
||
/// Why a preview root whose metadata (of the root itself, not what a link points to) is `m` can't
|
||
/// be used, or `None` when it can.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn refused(m: &std::fs::Metadata) -> Option<&'static str> {
|
||
if m.file_type().is_symlink() {
|
||
Some("it is a link")
|
||
} else if !m.is_dir() {
|
||
Some("it is not a folder")
|
||
} else if !mine(m) {
|
||
Some("it belongs to another user")
|
||
} else if others_can_write(m) {
|
||
Some("other users can write to it")
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Whether the current user owns the file with metadata `m`.
|
||
#[cfg(unix)]
|
||
fn mine(m: &std::fs::Metadata) -> bool {
|
||
std::os::unix::fs::MetadataExt::uid(m) == rustix::process::geteuid().as_raw()
|
||
}
|
||
|
||
/// Whether the current user owns the file with metadata `m`: always true outside unix.
|
||
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
|
||
fn mine(_: &std::fs::Metadata) -> bool {
|
||
true
|
||
}
|
||
|
||
/// Whether users other than the owner of the file with metadata `m` can write to it.
|
||
#[cfg(unix)]
|
||
fn others_can_write(m: &std::fs::Metadata) -> bool {
|
||
std::os::unix::fs::MetadataExt::mode(m) & 0o022 != 0
|
||
}
|
||
|
||
/// Whether users other than the owner of the file with metadata `m` can write to it: always false
|
||
/// outside unix.
|
||
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
|
||
fn others_can_write(_: &std::fs::Metadata) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Whether `name` is a preview folder's name: `prefix`, then digits, `-` and digits.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn is_preview_name(name: &std::ffi::OsStr, prefix: &str) -> bool {
|
||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||
name.to_str().and_then(|n| n.strip_prefix(prefix)).and_then(|n| n.split_once('-')).is_some_and(|(a, b)| digits(a) && digits(b))
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn temp_folder() -> Result<String, String> {
|
||
Err("no file system here".into())
|
||
}
|
||
|
||
// ---------- previews ----------
|
||
|
||
/// What a pane shows.
|
||
#[derive(Clone)]
|
||
struct Shot {
|
||
texture: egui::TextureHandle,
|
||
size: [u32; 2],
|
||
/// The file's size (bytes); `None` for the original.
|
||
bytes: Option<usize>,
|
||
colors: Arc<Vec<TableColor>>,
|
||
mapped: Arc<Vec<[u8; 3]>>,
|
||
}
|
||
|
||
/// The previews of the open dialog: the rendering (until the art or the image size changes) and
|
||
/// the optimised panes by settings.
|
||
#[derive(Clone, Default)]
|
||
struct Cache {
|
||
render_key: String,
|
||
render: Option<Result<Arc<WebRender>, String>>,
|
||
original: Option<Shot>,
|
||
shots: Vec<(String, Result<Shot, String>)>,
|
||
}
|
||
|
||
fn cache_id() -> egui::Id {
|
||
egui::Id::new("save-for-web-previews")
|
||
}
|
||
|
||
/// The texture of straight RGBA pixels.
|
||
fn texture(ctx: &egui::Context, name: &str, px: &[u8], [w, h]: [u32; 2]) -> egui::TextureHandle {
|
||
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], px);
|
||
let opts = egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR };
|
||
ctx.load_texture(name, img, opts)
|
||
}
|
||
|
||
/// The rendering for `s` and the optimised image for each of `panes`, from the cache when nothing
|
||
/// they depend on changed.
|
||
fn previews(app: &VectorcraftApp, ctx: &egui::Context, s: &WebSettings, panes: &[WebSettings]) -> (Result<Shot, String>, Vec<Result<Shot, String>>) {
|
||
let Some(st) = app.session.active() else { return (Err("no document".into()), vec![]) };
|
||
let doc = &st.doc;
|
||
let mut cache: Cache = ctx.data(|m| m.get_temp(cache_id())).unwrap_or_default();
|
||
let render_key = format!(
|
||
"{}:{}:{:?}:{:?}:{:?}:{}:{}:{:?}:{:?}",
|
||
st.uid,
|
||
st.revision,
|
||
s.width,
|
||
s.height,
|
||
s.percent,
|
||
s.anti_alias.id(),
|
||
s.clip_to_artboard,
|
||
s.artboard,
|
||
(!s.clip_to_artboard).then_some(s.slices)
|
||
);
|
||
if cache.render_key != render_key || cache.render.is_none() {
|
||
let r = webexport::render(doc, s).map(Arc::new);
|
||
cache.original = r.as_ref().ok().map(|r| Shot {
|
||
texture: texture(ctx, "save-for-web-original", &r.rgba, [r.width, r.height]),
|
||
size: [r.width, r.height],
|
||
bytes: None,
|
||
colors: Arc::default(),
|
||
mapped: Arc::default(),
|
||
});
|
||
cache = Cache { render_key, render: Some(r), original: cache.original, shots: vec![] };
|
||
}
|
||
let original = match (&cache.render, &cache.original) {
|
||
(Some(Ok(_)), Some(o)) => Ok(o.clone()),
|
||
(Some(Err(e)), _) => Err(e.clone()),
|
||
_ => Err("nothing to preview".into()),
|
||
};
|
||
let mut shots = Vec::with_capacity(panes.len());
|
||
let mut kept = Vec::with_capacity(panes.len());
|
||
for (i, p) in panes.iter().enumerate() {
|
||
let key = p.to_json().to_string();
|
||
let shot = match cache.shots.iter().find(|(k, _)| *k == key) {
|
||
Some((_, s)) => s.clone(),
|
||
None => match &cache.render {
|
||
Some(Ok(r)) => optimise(ctx, doc, r, p, i),
|
||
Some(Err(e)) => Err(e.clone()),
|
||
None => Err("nothing to preview".into()),
|
||
},
|
||
};
|
||
kept.push((key, shot.clone()));
|
||
shots.push(shot);
|
||
}
|
||
cache.shots = kept;
|
||
ctx.data_mut(|m| m.insert_temp(cache_id(), cache));
|
||
(original, shots)
|
||
}
|
||
|
||
fn optimise(ctx: &egui::Context, doc: &vectorcraft_doc::Document, r: &WebRender, s: &WebSettings, pane: usize) -> Result<Shot, String> {
|
||
let o = webexport::preview_image(doc, &r.rgba, r.width, r.height, s)?;
|
||
let (px, w, h) = webexport::decode(&o)?;
|
||
Ok(Shot {
|
||
texture: texture(ctx, &format!("save-for-web-{pane}"), &px, [w, h]),
|
||
size: [w, h],
|
||
bytes: Some(o.bytes.len()),
|
||
colors: Arc::new(o.colors),
|
||
mapped: Arc::new(o.mapped),
|
||
})
|
||
}
|
||
|
||
/// The two lighter settings 4-Up offers beside the current ones: lower quality, fewer colours, or
|
||
/// for PNG-24 a palette and a JPEG.
|
||
fn alternates(s: &WebSettings) -> [WebSettings; 2] {
|
||
let with = |f: &dyn Fn(&mut WebSettings)| {
|
||
let mut a = s.clone();
|
||
f(&mut a);
|
||
a
|
||
};
|
||
match s.format {
|
||
WebFormat::Jpg => [with(&|a| a.quality /= 2), with(&|a| a.quality /= 4)],
|
||
WebFormat::Gif | WebFormat::Png8 => [with(&|a| a.colors = (a.colors / 2).max(2)), with(&|a| a.colors = (a.colors / 4).max(2))],
|
||
WebFormat::Png24 => [
|
||
with(&|a| {
|
||
a.format = WebFormat::Png8;
|
||
a.colors = 256;
|
||
}),
|
||
with(&|a| a.format = WebFormat::Jpg),
|
||
],
|
||
}
|
||
}
|
||
|
||
// ---------- the dialog ----------
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
if app.session.active().is_none() {
|
||
return true;
|
||
}
|
||
if let Err(e) = apply_preset(app, d) {
|
||
app.status(e);
|
||
}
|
||
let settings = settings_of(app, d);
|
||
let mut browser = false;
|
||
ui.horizontal_top(|ui| {
|
||
ui.vertical(|ui| {
|
||
ui.set_width(PREVIEW_W);
|
||
browser = preview_area(app, ui, d, &settings);
|
||
});
|
||
ui.add_space(GAP);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(SIDE_W);
|
||
side(app, ui, d, settings.as_ref().ok());
|
||
});
|
||
});
|
||
if browser && let Err(e) = app.run("file.saveForWeb.browser", params(d)) {
|
||
app.status(e);
|
||
}
|
||
false
|
||
}
|
||
|
||
/// The view tabs, the panes and the zoom and speed row. Returns whether Preview in Browser was
|
||
/// clicked.
|
||
fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, settings: &Result<WebSettings, String>) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let view = VIEWS.iter().position(|v| *v == d.str("__view")).unwrap_or(2);
|
||
if let Some(i) = widgets::tab_bar(ui, &VIEW_LABELS, view) {
|
||
d.fields.insert("__view".into(), json!(VIEWS[i]));
|
||
}
|
||
ui.add_space(4.0);
|
||
let (area, resp) = ui.allocate_exact_size(egui::vec2(PREVIEW_W, PREVIEW_H), egui::Sense::click_and_drag());
|
||
let pan_id = egui::Id::new("save-for-web-pan");
|
||
let mut pan: egui::Vec2 = ui.ctx().data(|m| m.get_temp(pan_id)).unwrap_or_default();
|
||
if resp.dragged() {
|
||
pan += resp.drag_delta();
|
||
ui.ctx().data_mut(|m| m.insert_temp(pan_id, pan));
|
||
}
|
||
match settings {
|
||
Err(e) => {
|
||
ui.painter().rect_filled(area, 2.0, t.pasteboard);
|
||
ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, format!("⚠ {e}"), egui::FontId::proportional(12.5), t.text);
|
||
}
|
||
Ok(s) => {
|
||
let alts = alternates(s);
|
||
let panes: Vec<WebSettings> = if view == 3 { vec![s.clone(), alts[0].clone(), alts[1].clone()] } else { vec![s.clone()] };
|
||
// The current settings' image is made in every view: the colour table shows its colours.
|
||
let (original, shots) = previews(app, ui.ctx(), s, &panes);
|
||
let zoom = d.str("__zoom").parse::<f32>().ok().filter(|z| z.is_finite() && *z > 0.0).map(|z| z / 100.0);
|
||
let kbps = d.f64("__kbps", 56.6);
|
||
let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup)) else { return false };
|
||
let cells: Vec<(egui::Rect, Pane)> = match view {
|
||
0 => vec![(area, Pane::Original)],
|
||
1 => vec![(area, Pane::Optimized(0))],
|
||
2 => split(area, 2, 1).into_iter().zip([Pane::Original, Pane::Optimized(0)]).collect(),
|
||
_ => split(area, 2, 2).into_iter().zip([Pane::Original, Pane::Optimized(0), Pane::Optimized(1), Pane::Optimized(2)]).collect(),
|
||
};
|
||
for (rect, pane) in cells {
|
||
let (shot, settings) = match pane {
|
||
Pane::Original => (&original, None),
|
||
Pane::Optimized(i) => (shots.get(i).unwrap_or(&original), panes.get(i)),
|
||
};
|
||
let selected = view == 3 && matches!(pane, Pane::Optimized(0));
|
||
draw_pane(ui, rect, shot, settings, zoom, pan, (checker.0.id(), checker.1), selected, kbps);
|
||
// 4-Up: clicking a lighter alternate takes its settings.
|
||
if let (Pane::Optimized(1..), Some(a)) = (pane, settings)
|
||
&& resp.clicked()
|
||
&& resp.interact_pointer_pos().is_some_and(|p| rect.contains(p))
|
||
{
|
||
d.fields.extend(settings_fields(a));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ui.add_space(6.0);
|
||
let mut browser = false;
|
||
ui.horizontal(|ui| {
|
||
let z = ZOOMS.iter().position(|z| *z == d.str("__zoom")).unwrap_or(2);
|
||
if let Some(i) = widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0) {
|
||
d.fields.insert("__zoom".into(), json!(ZOOMS[i]));
|
||
ui.ctx().data_mut(|m| m.insert_temp(pan_id, egui::Vec2::ZERO));
|
||
}
|
||
let kbps = d.f64("__kbps", 56.6);
|
||
let labels: Vec<String> = SPEEDS.iter().map(|s| speed_label(*s)).collect();
|
||
let names: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||
if let Some(i) = widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0)
|
||
&& let Some(s) = SPEEDS.get(i)
|
||
{
|
||
d.fields.insert("__kbps".into(), json!(s));
|
||
}
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let b = ui.add_enabled(!io::is_web(app), |ui: &mut egui::Ui| widgets::secondary_button(ui, tl!("Preview in Browser")));
|
||
browser = b.on_disabled_hover_text(tl!("Needs the desktop app")).clicked();
|
||
});
|
||
});
|
||
browser
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Pane {
|
||
Original,
|
||
/// The optimised image of the current settings (0) or an alternate (1, 2).
|
||
Optimized(usize),
|
||
}
|
||
|
||
/// `area` cut into `cols` × `rows` panes with a gap.
|
||
fn split(area: egui::Rect, cols: usize, rows: usize) -> Vec<egui::Rect> {
|
||
let gap = 6.0;
|
||
let w = (area.width() - gap * (cols as f32 - 1.0)) / cols as f32;
|
||
let h = (area.height() - gap * (rows as f32 - 1.0)) / rows as f32;
|
||
(0..rows)
|
||
.flat_map(|r| (0..cols).map(move |c| (c, r)))
|
||
.map(|(c, r)| egui::Rect::from_min_size(area.min + egui::vec2(c as f32 * (w + gap), r as f32 * (h + gap)), egui::vec2(w, h)))
|
||
.collect()
|
||
}
|
||
|
||
/// `kbps` as the speed list shows it.
|
||
fn speed_label(kbps: f64) -> String {
|
||
if kbps >= 1024.0 { format!("{} Mbps", kbps / 1024.0) } else { format!("{kbps} Kbps") }
|
||
}
|
||
|
||
/// A file size as the captions show it.
|
||
fn size_label(bytes: usize) -> String {
|
||
if bytes < 1024 {
|
||
format!("{bytes} bytes")
|
||
} else if bytes < 1 << 20 {
|
||
format!("{:.1}K", bytes as f64 / 1024.0)
|
||
} else {
|
||
format!("{:.2}M", bytes as f64 / f64::from(1 << 20))
|
||
}
|
||
}
|
||
|
||
/// One pane: the image over the transparency grid at `zoom` (`None`: fit), moved by `pan`, and
|
||
/// its caption: what it is, its size and download time, and its colours or quality.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_pane(
|
||
ui: &egui::Ui,
|
||
rect: egui::Rect,
|
||
shot: &Result<Shot, String>,
|
||
settings: Option<&WebSettings>,
|
||
zoom: Option<f32>,
|
||
pan: egui::Vec2,
|
||
(checker, cell): (egui::TextureId, f32),
|
||
selected: bool,
|
||
kbps: f64,
|
||
) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let image = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, rect.max.y - CAPTION_H));
|
||
let p = ui.painter().with_clip_rect(image);
|
||
p.rect_filled(image, 0.0, t.pasteboard);
|
||
let font = egui::FontId::proportional(11.5);
|
||
let (first, second, third) = match shot {
|
||
Err(e) => {
|
||
p.text(image.center(), egui::Align2::CENTER_CENTER, format!("⚠ {e}"), font.clone(), t.text);
|
||
("".into(), String::new(), String::new())
|
||
}
|
||
Ok(shot) => {
|
||
let size = egui::vec2(shot.size[0] as f32, shot.size[1] as f32);
|
||
let scale = zoom.unwrap_or_else(|| (image.width() / size.x).min(image.height() / size.y).min(1.0));
|
||
let shown = egui::Rect::from_center_size(image.center() + pan, size * scale);
|
||
let uv = egui::Rect::from_min_max(egui::Pos2::ZERO, (shown.size() / (2.0 * cell)).to_pos2());
|
||
p.image(checker, shown, uv, egui::Color32::WHITE);
|
||
p.image(shot.texture.id(), shown, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||
match (settings, shot.bytes) {
|
||
(Some(s), Some(bytes)) => {
|
||
let secs = download_seconds(bytes, kbps);
|
||
let time =
|
||
if secs < 1.0 { tl!("< 1 sec").into() } else { crate::i18n::fmt(tl!("{secs} sec"), &[("secs", &secs.ceil().to_string())]) };
|
||
let detail = match s.format {
|
||
WebFormat::Jpg => crate::i18n::fmt(tl!("Quality {quality}"), &[("quality", &s.quality.to_string())]),
|
||
WebFormat::Png24 => String::new(),
|
||
_ => crate::i18n::tn(shot.colors.len() as u64, "{n} color", "{n} colors"),
|
||
};
|
||
(s.format.label().to_string(), format!("{} {time} @ {}", size_label(bytes), speed_label(kbps)), detail)
|
||
}
|
||
_ => (tl!("Original").into(), format!("{} × {} px", shot.size[0], shot.size[1]), String::new()),
|
||
}
|
||
}
|
||
};
|
||
let stroke = if selected { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.border) };
|
||
ui.painter().rect_stroke(image, 0.0, stroke, egui::StrokeKind::Inside);
|
||
let caption = egui::Rect::from_min_max(egui::pos2(rect.min.x, image.max.y), rect.max).shrink2(egui::vec2(4.0, 3.0));
|
||
let p = ui.painter().with_clip_rect(caption);
|
||
p.text(caption.left_top(), egui::Align2::LEFT_TOP, first, font.clone(), t.text_strong);
|
||
p.text(caption.left_bottom(), egui::Align2::LEFT_BOTTOM, second, font.clone(), t.text_dim);
|
||
p.text(caption.right_bottom(), egui::Align2::RIGHT_BOTTOM, third, font, t.text_dim);
|
||
}
|
||
|
||
// ---------- the settings column ----------
|
||
|
||
/// `(value, label)` pairs of a keyed enum.
|
||
fn pairs<T: Copy>(all: &[T], key: fn(T) -> &'static str, label: fn(T) -> &'static str) -> Vec<(&'static str, &'static str)> {
|
||
all.iter().map(|v| (key(*v), label(*v))).collect()
|
||
}
|
||
|
||
fn side(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
|
||
presets_row(app, ui, d, s);
|
||
widgets::divider(ui);
|
||
egui::ScrollArea::vertical().id_salt("sfw-settings").max_height(PREVIEW_H - 40.0).auto_shrink([false, true]).show(ui, |ui| {
|
||
let choice = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]| {
|
||
form::choice(ui, d, key, label, (LABEL_W, FIELD_W), options);
|
||
};
|
||
choice(ui, d, "format", tl!("Format:"), &pairs(&WebFormat::ALL, WebFormat::id, WebFormat::label));
|
||
let format = s.map_or(WebFormat::Gif, |s| s.format);
|
||
match format {
|
||
WebFormat::Gif | WebFormat::Png8 => {
|
||
choice(ui, d, "reduction", tl!("Reduction:"), &pairs(&Reduction::ALL, Reduction::id, Reduction::label));
|
||
colors_row(ui, d);
|
||
choice(ui, d, "dither", tl!("Dither:"), &pairs(&Dither::ALL, Dither::id, Dither::label));
|
||
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
|
||
slider_row(ui, d, "ditherAmount", tl!("Amount:"), "%");
|
||
}
|
||
check_pair(ui, d, ("transparency", tl!("Transparency")), ("interlaced", tl!("Interlaced")));
|
||
matte_row(ui, d);
|
||
slider_row(ui, d, "webSnap", tl!("Web Snap:"), "%");
|
||
if format == WebFormat::Gif {
|
||
slider_row(ui, d, "lossy", tl!("Lossy:"), "%");
|
||
}
|
||
}
|
||
WebFormat::Jpg => {
|
||
quality_row(ui, d);
|
||
check_pair(ui, d, ("progressive", tl!("Progressive")), ("optimized", tl!("Optimized")));
|
||
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "embedProfile", tl!("ICC Profile")));
|
||
matte_row(ui, d);
|
||
}
|
||
WebFormat::Png24 => {
|
||
check_pair(ui, d, ("transparency", tl!("Transparency")), ("interlaced", tl!("Interlaced")));
|
||
matte_row(ui, d);
|
||
}
|
||
}
|
||
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "convertToSrgb", tl!("Convert to sRGB")));
|
||
choice(ui, d, "metadata", tl!("Metadata:"), &pairs(&WebMetadata::ALL, WebMetadata::key, WebMetadata::label));
|
||
if format.palette() {
|
||
widgets::divider(ui);
|
||
color_table(ui, d, s);
|
||
}
|
||
widgets::divider(ui);
|
||
image_size(app, ui, d, s);
|
||
widgets::divider(ui);
|
||
if d.bool("__hasSlices") {
|
||
choice(ui, d, "slices", tl!("Slices:"), &pairs(&SliceScope::ALL, SliceScope::key, SliceScope::label));
|
||
}
|
||
choice(ui, d, "output", tl!("Output:"), &pairs(&WebOutput::ALL, WebOutput::key, WebOutput::label));
|
||
});
|
||
}
|
||
|
||
/// The preset list (the settings' preset, else "[Unnamed]") with Save and Delete.
|
||
fn presets_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
|
||
let presets = app.session.web_presets();
|
||
let saved = app.session.prefs.web_export_presets.len();
|
||
let current = s.and_then(|s| presets.iter().rposition(|p| p.settings == *s));
|
||
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
|
||
// The built-in presets come first (translated); the saved ones are names.
|
||
let builtins = presets.len().saturating_sub(saved);
|
||
let labels = super::shown_names(crate::i18n::current(), &names, |k| k < builtins);
|
||
let mut run: Option<(&str, Value)> = None;
|
||
widgets::label_row(ui, tl!("Preset:"), LABEL_W, |ui| {
|
||
let shown = current.and_then(|i| labels.get(i).copied()).unwrap_or(tl!("[Unnamed]"));
|
||
if let Some(i) = widgets::dropdown_names(ui, "sfw-preset", shown, &labels, FIELD_W - 60.0)
|
||
&& let Some(p) = presets.get(i)
|
||
{
|
||
d.fields.extend(settings_fields(&p.settings));
|
||
}
|
||
let editing = d.fields.contains_key("__presetName");
|
||
if widgets::icon_button(ui, "save", tl!("Save the settings as a preset"), editing, 24.0).clicked() {
|
||
match editing {
|
||
true => _ = d.fields.remove("__presetName"),
|
||
false => _ = d.fields.insert("__presetName".into(), json!("")),
|
||
}
|
||
}
|
||
let user = current.filter(|i| *i + saved >= presets.len());
|
||
let del =
|
||
ui.add_enabled(user.is_some(), |ui: &mut egui::Ui| widgets::icon_button(ui, "trash-2", tl!("Delete this saved preset"), false, 24.0));
|
||
if del.clicked()
|
||
&& let Some(name) = user.and_then(|i| names.get(i))
|
||
{
|
||
run = Some(("webExport.presets.delete", json!({ "name": name })));
|
||
}
|
||
});
|
||
if d.fields.contains_key("__presetName") {
|
||
widgets::label_row(ui, tl!("Name:"), LABEL_W, |ui| {
|
||
form::text(ui, d, "__presetName", FIELD_W - 70.0);
|
||
let name = d.str("__presetName");
|
||
if ui.add_enabled(!name.trim().is_empty(), |ui: &mut egui::Ui| widgets::flat_button(ui, tl!("Save"), 52.0)).clicked() {
|
||
let mut p = params(d);
|
||
p["name"] = json!(name.trim());
|
||
run = Some(("webExport.presets.save", p));
|
||
}
|
||
});
|
||
}
|
||
if let Some((id, p)) = run {
|
||
match app.run(id, p) {
|
||
Ok(_) => _ = d.fields.remove("__presetName"),
|
||
Err(e) => app.status(e),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Two checkboxes side by side.
|
||
fn check_pair(ui: &mut egui::Ui, d: &mut Dialog, (a, a_label): (&str, &str), (b, b_label): (&str, &str)) {
|
||
widgets::label_row(ui, "", LABEL_W, |ui| {
|
||
ui.allocate_ui(egui::vec2(FIELD_W / 2.0, 20.0), |ui| {
|
||
ui.set_min_width(FIELD_W / 2.0);
|
||
form::check(ui, d, a, a_label);
|
||
});
|
||
form::check(ui, d, b, b_label);
|
||
});
|
||
}
|
||
|
||
/// A 0–100 slider bound to `d.fields[key]`, its value shown with `suffix`.
|
||
fn slider_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, suffix: &str) {
|
||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||
let mut v = d.f64(key, 0.0).round().clamp(0.0, 100.0) as u8;
|
||
ui.spacing_mut().slider_width = FIELD_W - 60.0;
|
||
if widgets::slider(ui, egui::Slider::new(&mut v, 0..=100).suffix(suffix)).changed() {
|
||
d.fields.insert(key.into(), json!(v));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The palette size: the usual counts, or any 2–256.
|
||
fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
widgets::label_row(ui, tl!("Colors:"), LABEL_W, |ui| {
|
||
let mut n = d.f64("colors", 128.0).round().clamp(2.0, 256.0) as u16;
|
||
if let Some(c) = widgets::dropdown(ui, "sfw-colors", &n.to_string(), &COLOR_COUNTS, 80.0).and_then(|i| COLOR_COUNTS.get(i)) {
|
||
n = c.parse().unwrap_or(n);
|
||
d.fields.insert("colors".into(), json!(n));
|
||
}
|
||
if let Some(n) = widgets::range_field(ui, "sfw-colors-n", f64::from(n), 2.0..=256.0, "", 0, 52.0) {
|
||
d.fields.insert("colors".into(), json!(n as u16));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// JPEG quality: a named step, then the exact value.
|
||
fn quality_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let q = d.f64("quality", 60.0).round().clamp(0.0, 100.0) as u8;
|
||
let step = QUALITIES.iter().rev().find(|(_, v)| q >= *v).map_or("Low", |(l, _)| *l);
|
||
widgets::label_row(ui, tl!("Quality:"), LABEL_W, |ui| {
|
||
let labels = QUALITIES.map(|(l, _)| l);
|
||
if let Some((_, v)) = widgets::dropdown(ui, "sfw-quality", step, &labels, FIELD_W).and_then(|i| QUALITIES.get(i)) {
|
||
d.fields.insert("quality".into(), json!(v));
|
||
}
|
||
});
|
||
slider_row(ui, d, "quality", "", "");
|
||
}
|
||
|
||
/// The matte: none, white, black or a colour.
|
||
fn matte_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||
let matte = d.str("matte");
|
||
let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3);
|
||
widgets::label_row(ui, tl!("Matte:"), LABEL_W, |ui| {
|
||
if let Some(i) = widgets::dropdown(ui, "sfw-matte", MATTE_LABELS[at], &MATTE_LABELS, 120.0) {
|
||
d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080")));
|
||
}
|
||
if at == 3 {
|
||
color_button(ui, d, "matte");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The colour table of the current settings' preview: swatches (the selected one ringed; locked
|
||
/// ones with a square, web-safe ones with a diamond, transparent ones over the grid) and the
|
||
/// buttons that edit the selected colour, plus the sort order.
|
||
fn color_table(ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let cache: Option<Cache> = ui.ctx().data(|m| m.get_temp(cache_id()));
|
||
let key = s.map(|s| s.to_json().to_string());
|
||
let shot = cache.and_then(|c| c.shots.into_iter().find(|(k, _)| Some(k) == key.as_ref())).and_then(|(_, s)| s.ok());
|
||
let Some(shot) = shot else {
|
||
widgets::subheader(ui, tl!("Color Table"));
|
||
widgets::dim_label(ui, tl!("Shown with the optimised preview."));
|
||
return;
|
||
};
|
||
widgets::subheader(ui, &crate::i18n::tn(shot.colors.len() as u64, "Color Table ({n} color)", "Color Table ({n} colors)"));
|
||
let selected = d.str("__color");
|
||
// (colour shown, source, transparent, locked, web-safe, mapped by hand)
|
||
let rows: Vec<(TableColor, bool)> = shot
|
||
.colors
|
||
.iter()
|
||
.map(|c| (*c, false))
|
||
.chain(shot.mapped.iter().map(|c| (TableColor { color: *c, source: *c, transparent: true, locked: false, web_shifted: false }, true)))
|
||
.collect();
|
||
const CELL: f32 = 15.0;
|
||
let per_row = ((SIDE_W - 8.0) / (CELL + 2.0)).floor().max(1.0) as usize;
|
||
let lines = rows.len().div_ceil(per_row).max(1);
|
||
let (area, resp) = ui.allocate_exact_size(egui::vec2(SIDE_W - 8.0, lines as f32 * (CELL + 2.0)), egui::Sense::click());
|
||
let p = ui.painter();
|
||
for (i, (c, mapped)) in rows.iter().enumerate() {
|
||
let r = egui::Rect::from_min_size(
|
||
area.min + egui::vec2((i % per_row) as f32 * (CELL + 2.0), (i / per_row) as f32 * (CELL + 2.0)),
|
||
egui::vec2(CELL, CELL),
|
||
);
|
||
let [red, g, b] = c.color;
|
||
let fill = egui::Color32::from_rgb(red, g, b);
|
||
if c.transparent {
|
||
// Half the colour (a mapped one), half the grid.
|
||
p.rect_filled(r, 0.0, egui::Color32::WHITE);
|
||
p.rect_filled(egui::Rect::from_min_size(r.min, r.size() / 2.0), 0.0, egui::Color32::from_gray(200));
|
||
p.rect_filled(egui::Rect::from_min_size(r.center(), r.size() / 2.0), 0.0, egui::Color32::from_gray(200));
|
||
if *mapped {
|
||
p.add(egui::Shape::convex_polygon(vec![r.left_top(), r.right_top(), r.left_bottom()], fill, egui::Stroke::NONE));
|
||
}
|
||
} else {
|
||
p.rect_filled(r, 0.0, fill);
|
||
}
|
||
let mark = if c.color.iter().map(|v| u32::from(*v)).sum::<u32>() > 380 { egui::Color32::BLACK } else { egui::Color32::WHITE };
|
||
if c.locked {
|
||
p.rect_filled(egui::Rect::from_min_size(r.right_bottom() - egui::vec2(5.0, 5.0), egui::vec2(4.0, 4.0)), 0.0, mark);
|
||
}
|
||
if !c.transparent && vectorcraft_render::encode::quantize::web_safe(c.color) == c.color {
|
||
let m = r.center();
|
||
p.add(egui::Shape::convex_polygon(
|
||
vec![m + egui::vec2(0.0, -2.5), m + egui::vec2(2.5, 0.0), m + egui::vec2(0.0, 2.5), m + egui::vec2(-2.5, 0.0)],
|
||
mark,
|
||
egui::Stroke::NONE,
|
||
));
|
||
}
|
||
// The transparent entry itself isn't a colour to edit.
|
||
let pickable = !c.transparent || *mapped;
|
||
let picked = pickable && hex(c.source) == selected;
|
||
p.rect_stroke(r, 0.0, egui::Stroke::new(if picked { 2.0 } else { 1.0 }, if picked { t.accent } else { t.border }), egui::StrokeKind::Outside);
|
||
if pickable && resp.clicked() && resp.interact_pointer_pos().is_some_and(|pos| r.contains(pos)) {
|
||
d.fields.insert("__color".into(), json!(hex(c.source)));
|
||
}
|
||
}
|
||
ui.add_space(4.0);
|
||
let table = s.map(|s| s.color_table.clone()).unwrap_or_default();
|
||
let on = |list: &[String]| list.iter().any(|h| h.eq_ignore_ascii_case(&selected));
|
||
ui.horizontal(|ui| {
|
||
ui.add_enabled_ui(!selected.is_empty(), |ui| {
|
||
for (list, on_label, off_label) in [
|
||
("transparent", tl!("Restore"), tl!("Transparent")),
|
||
("webShift", tl!("Unshift"), tl!("Web Shift")),
|
||
("locked", tl!("Unlock"), tl!("Lock")),
|
||
] {
|
||
let current = match list {
|
||
"transparent" => &table.transparent,
|
||
"webShift" => &table.web_shift,
|
||
_ => &table.locked,
|
||
};
|
||
let is_on = on(current);
|
||
if widgets::flat_button(ui, if is_on { on_label } else { off_label }, 88.0).clicked() {
|
||
toggle(d, list, &selected, !is_on);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
let sort = table.sort;
|
||
widgets::label_row(ui, tl!("Order:"), LABEL_W, |ui| {
|
||
let labels = ColorSort::ALL.map(ColorSort::label);
|
||
if let Some(i) = widgets::dropdown(ui, "sfw-sort", sort.label(), &labels, FIELD_W)
|
||
&& let Some(o) = ColorSort::ALL.get(i)
|
||
{
|
||
set_table(d, "sort", json!(o.id()));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Add `color` to the colour table's `list`, or take it out.
|
||
pub(super) fn toggle(d: &mut Dialog, list: &str, color: &str, add: bool) {
|
||
let mut v: Vec<Value> = d.fields.get("colorTable").and_then(|t| t.get(list)).and_then(Value::as_array).cloned().unwrap_or_default();
|
||
v.retain(|c| !c.as_str().is_some_and(|c| c.eq_ignore_ascii_case(color)));
|
||
if add {
|
||
v.push(json!(color.to_ascii_lowercase()));
|
||
}
|
||
set_table(d, list, Value::Array(v));
|
||
}
|
||
|
||
/// Set one key of the `colorTable` field.
|
||
pub(super) fn set_table(d: &mut Dialog, key: &str, value: Value) {
|
||
let table = d.fields.entry("colorTable").or_insert_with(|| json!({}));
|
||
if !table.is_object() {
|
||
*table = json!({});
|
||
}
|
||
table[key] = value;
|
||
}
|
||
|
||
/// Image Size: width and height in pixels (proportional) or a percentage, anti-aliasing, Clip to
|
||
/// Artboard and which artboard.
|
||
fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
|
||
widgets::subheader(ui, tl!("Image Size"));
|
||
let Some(st) = app.session.active() else { return };
|
||
let region = s.and_then(|s| webexport::region(&st.doc, s).ok());
|
||
let (w, h) = region.map_or((0, 0), |(r, scale)| vectorcraft_render::region_pixels(r, scale));
|
||
let set = |d: &mut Dialog, key: &str, v: Value| {
|
||
for k in ["width", "height", "percent"] {
|
||
d.fields.insert(k.into(), Value::Null);
|
||
}
|
||
d.fields.insert(key.into(), v);
|
||
};
|
||
widgets::label_row(ui, tl!("W:"), LABEL_W, |ui| {
|
||
if let Some(v) = widgets::range_field(ui, "sfw-w", f64::from(w), 1.0..=f64::INFINITY, " px", 0, 80.0) {
|
||
set(d, "width", json!(v));
|
||
}
|
||
widgets::field_label(ui, tl!("H:"));
|
||
if let Some(v) = widgets::range_field(ui, "sfw-h", f64::from(h), 1.0..=f64::INFINITY, " px", 0, 80.0) {
|
||
set(d, "height", json!(v));
|
||
}
|
||
});
|
||
let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0);
|
||
widgets::label_row(ui, tl!("Percent:"), LABEL_W, |ui| {
|
||
if let Some(v) = widgets::plain_field(ui, "sfw-pct", percent, "%", 2, 80.0) {
|
||
set(d, "percent", json!(v));
|
||
}
|
||
});
|
||
form::choice(ui, d, "antiAlias", tl!("Anti-aliasing:"), (LABEL_W, FIELD_W), &pairs(&AntiAlias::ALL, AntiAlias::id, AntiAlias::label));
|
||
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "clipToArtboard", tl!("Clip to Artboard")));
|
||
let boards = &st.doc.artboards;
|
||
if boards.len() > 1 && d.bool("clipToArtboard") {
|
||
let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect();
|
||
let at = s.and_then(|s| s.artboard).unwrap_or(0).min(names.len() - 1);
|
||
widgets::label_row(ui, tl!("Artboard:"), LABEL_W, |ui| {
|
||
if let Some(i) = widgets::dropdown_names(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) {
|
||
d.fields.insert("artboard".into(), json!(i));
|
||
}
|
||
});
|
||
}
|
||
}
|