Files
vectorcraft/crates/ui-egui/src/dialogs/tests_screens.rs

451 lines
20 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last
//! settings), the artboard choice, Format Settings, desktop folders and web downloads.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use super::tests_export::{frame, kind, set};
use super::*;
use crate::Services;
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a
/// download service (recorded in the log) instead of a file manager (whose reveals are recorded).
fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
let log = Log::default();
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
Ok(())
}));
services.pick_folder = Some(Box::new(|| None));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap();
app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap();
(app, log)
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null)
}
fn open(app: &mut VectorcraftApp) {
app.run("file.exportForScreens", json!({})).unwrap();
assert_eq!(kind(app), Some(export_for_screens::KIND));
}
fn temp_folder(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vc-efs-{tag}-{}", std::process::id()))
}
#[test]
fn opens_on_defaults_and_draws_every_view() {
let (mut app, _) = app(3, false);
open(&mut app);
assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true])));
assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}]));
assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home");
assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!("")));
let settings = field(&app, "settings");
for f in ["png", "png8", "jpg", "webp", "gif", "tiff", "svg", "pdf"] {
assert!(settings[f].is_object(), "{f} has settings");
}
assert!(settings["jpg"].get("quality").is_none() && settings["jpg"].get("imageMap").is_none(), "rows choose quality; no maps");
assert!(settings["svg"].get("images").is_none(), "no linked images");
assert_eq!(settings["pdf"]["preset"], json!(fileio::pdf::DEFAULT_PRESET));
frame(&mut app);
// Assets, Full Document, each format's Format Settings: all draw.
set(&mut app, "tab", json!("assets"));
frame(&mut app);
set(&mut app, "tab", json!("artboards"));
set(&mut app, "select", json!("full"));
for f in ["png", "png8", "jpg", "webp", "gif", "tiff", "svg", "pdf"] {
set(&mut app, "__settings", json!(f));
frame(&mut app);
assert_eq!(field(&app, "__settings"), json!(f));
}
assert_eq!(kind(&app), Some(export_for_screens::KIND));
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
open(&mut app);
set(&mut app, "boards", json!([true, false, true, true, false]));
frame(&mut app);
// Nothing clicked: the fields stay as set.
assert_eq!(field(&app, "boards"), json!([true, false, true, true, false]));
for (boards, text) in [
(vec![true, true, true], "1-3"),
(vec![false, true, false, true], "2, 4"),
(vec![true, false, true, true, false, true], "1, 3-4, 6"),
(vec![false], ""),
] {
assert_eq!(export_for_screens::range_text(&boards), text);
}
}
#[test]
fn exports_into_the_folder_and_shows_it() {
let (mut app, log) = app(2, false);
let dir = temp_folder("desk");
let folder = dir.to_string_lossy().replace('\\', "/");
open(&mut app);
set(&mut app, "folder", json!(folder));
set(&mut app, "subfolders", json!(true));
set(
&mut app,
"formats",
json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "120w", "suffix": "@120w"}, {"format": "svg", "scale": "1x", "suffix": ""}]),
);
// A bad range keeps the dialog open; so does no folder.
set(&mut app, "select", json!("range"));
set(&mut app, "range", json!("7"));
assert!(confirm(&mut app).is_err());
assert_eq!(kind(&app), Some(export_for_screens::KIND));
set(&mut app, "range", json!("2"));
set(&mut app, "folder", json!(" "));
assert!(confirm(&mut app).is_err());
set(&mut app, "folder", json!(folder));
let r = confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, [format!("{folder}/1x/Artboard-2.png"), format!("{folder}/120w/Artboard-2@120w.png"), format!("{folder}/SVG/Artboard-2.svg")]);
let png = image::load_from_memory(&std::fs::read(dir.join("120w").join("Artboard-2@120w.png")).unwrap()).unwrap();
assert_eq!((png.width(), png.height()), (120, 80));
assert_eq!(log.borrow().iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), [files[0]], "Open Location shows the first file");
// It reopens on those settings.
open(&mut app);
assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("range"), json!("2"), json!([false, true])));
assert_eq!(field(&app, "formats")[1]["scale"], json!("120w"));
assert_eq!((field(&app, "subfolders"), field(&app, "folder")), (json!(true), json!(folder)));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_web_downloads_one_zip_or_the_file() {
let (mut app, log) = app(2, true);
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download");
open(&mut app);
frame(&mut app);
set(
&mut app,
"formats",
json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "jpg", "quality": 80, "scale": "2x", "suffix": "@2x"}]),
);
set(&mut app, "settings", json!({"png": {"background": "black"}}));
let r = confirm(&mut app).unwrap();
assert!(r.get("dataBase64").is_some(), "four files: one zip");
{
let log = log.borrow();
let [(name, zip)] = log.as_slice() else { panic!("one download: {:?}", log.iter().map(|(n, _)| n).collect::<Vec<_>>()) };
assert_eq!(name, "Untitled-1.zip");
assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip);
}
assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"]));
// One file downloads as itself, with the format's settings.
log.borrow_mut().clear();
open(&mut app);
assert_eq!(field(&app, "settings")["png"]["background"], json!("black"), "remembered");
set(&mut app, "select", json!("range"));
set(&mut app, "range", json!("1"));
set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "suffix": ""}]));
confirm(&mut app).unwrap();
let log = log.borrow();
assert_eq!(log.len(), 1);
assert_eq!(log[0].0, "Artboard-1.png");
assert_eq!(image::load_from_memory(&log[0].1).unwrap().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background");
}
#[test]
fn a_preset_export_reopens_on_its_rows() {
let (mut app, _) = app(1, true);
app.run("file.exportForScreens", json!({"preset": "density", "fullDocument": true})).unwrap();
open(&mut app);
assert_eq!(field(&app, "preset"), json!("density"));
assert_eq!(field(&app, "select"), json!("full"));
assert_eq!(field(&app, "subfolders"), json!(true));
let rows = field(&app, "formats");
assert_eq!(rows.as_array().unwrap().len(), 6);
assert_eq!((rows[0]["scale"].clone(), rows[0]["folder"].clone()), (json!("0.75x"), json!("ldpi")));
frame(&mut app);
// Agents' numeric sizes show as text.
app.run(
"file.exportForScreens",
json!({"formats": [{"format": "png", "width": 64}, {"format": "png", "scale": 3}, {"format": "png", "ppi": 144}]}),
)
.unwrap();
open(&mut app);
let scales: Vec<Value> = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect();
assert_eq!(scales, [json!("64w"), json!("3x"), json!("144ppi")]);
}
/// Persistent frames keep dropdowns open between the pointer press and release.
struct ScreensFrame {
ctx: egui::Context,
time: f64,
}
impl ScreensFrame {
fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Self { ctx, time: 0.0 }
}
fn frame(&mut self, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
self.time += 0.1;
let input = egui::RawInput {
events,
time: Some(self.time),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1100.0, 900.0))),
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| super::show(app, ui.ctx()));
out.textures_delta.clear();
out
}
fn settle(&mut self, app: &mut VectorcraftApp) -> egui::FullOutput {
self.frame(app, vec![]);
self.frame(app, vec![])
}
fn asset_panel(&mut self, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
self.time += 0.1;
let input = egui::RawInput {
events,
time: Some(self.time),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(236.0, 700.0))),
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| crate::panels::show_icon_panel(app, ui, "assetExport"));
out.textures_delta.clear();
out
}
fn click(&mut self, app: &mut VectorcraftApp, at: egui::Pos2) -> egui::FullOutput {
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
self.frame(app, vec![egui::Event::PointerMoved(at)]);
self.frame(app, vec![button(true)]);
self.frame(app, vec![button(false)]);
self.settle(app)
}
fn click_label(&mut self, app: &mut VectorcraftApp, label: &str) -> egui::FullOutput {
let out = self.settle(app);
self.click(app, screen_text_at(&out, label))
}
fn choose(&mut self, app: &mut VectorcraftApp, current: &str, next: &str) {
let out = self.click_label(app, current);
self.click(app, screen_text_at(&out, next));
}
}
fn screen_text_at(out: &egui::FullOutput, label: &str) -> egui::Pos2 {
fn find(shape: &egui::Shape, label: &str) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(t) if t.galley.text().trim() == label => Some(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, label)),
_ => None,
}
}
out.shapes.iter().find_map(|s| find(&s.shape, label)).unwrap_or_else(|| panic!("missing {label}: {}", crate::tests_labels::shapes_text(out)))
}
/// First-directory entries of the big-endian TIFF chosen below: tag, type, count, inline value.
fn big_tiff_tags(bytes: &[u8]) -> Vec<(u16, u16, u32, u16)> {
assert_eq!(&bytes[..4], b"MM\0*");
let short = |at| u16::from_be_bytes(bytes[at..at + 2].try_into().unwrap());
let long = |at| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
let dir = long(4) as usize;
(0..usize::from(short(dir)))
.map(|i| {
let at = dir + 2 + 12 * i;
(short(at), short(at + 2), long(at + 4), short(at + 8))
})
.collect()
}
/// Locate the gear from the painted Formats label and its production column width.
fn screen_format_gear(out: &egui::FullOutput, width: f32) -> egui::Pos2 {
fn label(shape: &egui::Shape) -> Option<egui::Rect> {
match shape {
egui::Shape::Text(t) if t.galley.text().trim() == "Formats:" => Some(t.visual_bounding_rect()),
egui::Shape::Vec(shapes) => shapes.iter().find_map(label),
_ => None,
}
}
let label = out.shapes.iter().find_map(|s| label(&s.shape)).expect("painted Formats row");
// The existing 24 pt gear is right-aligned in the 350 pt dialog or 236 pt panel.
egui::pos2(label.left() + width - 12.0, label.center().y)
}
fn saved_tiff_alias_gear(alias: &str, asset_panel: bool) {
let (mut app, log) = app(1, true);
app.run("paint.setStroke", json!({"none": true})).unwrap();
app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap();
if asset_panel {
app.run("assets.add", json!({"name": "Badge"})).unwrap();
}
let rows = json!([{"format": alias, "scale": "2x", "suffix": "@2x", "antiAlias": "none"}]);
app.run(
"assets.settings.set",
json!({
"formats": rows.clone(),
"settings": {
"tiff": {"colorModel": "rgb", "byteOrder": "little", "lzw": false, "embedIcc": false, "antiAlias": "type"},
"png": {"interlaced": true}
}
}),
)
.unwrap();
let saved_before = app.session.active().unwrap().doc.export_settings.clone();
if !asset_panel {
open(&mut app);
}
let mut h = ScreensFrame::new();
let draw = |h: &mut ScreensFrame, app: &mut VectorcraftApp, events| {
if asset_panel { h.asset_panel(app, events) } else { h.frame(app, events) }
};
draw(&mut h, &mut app, vec![]);
let out = draw(&mut h, &mut app, vec![]);
let gear = screen_format_gear(&out, if asset_panel { 236.0 } else { 350.0 });
draw(&mut h, &mut app, vec![egui::Event::PointerMoved(gear)]);
for pressed in [true, false] {
draw(
&mut h,
&mut app,
vec![egui::Event::PointerButton { pos: gear, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }],
);
}
assert_eq!(field(&app, "__settings"), "tiff", "gear for {alias}, asset panel={asset_panel}");
assert_eq!(field(&app, "tab"), if asset_panel { "assets" } else { "artboards" });
let out = h.settle(&mut app);
for label in ["Format Settings", "TIFF", "Color Model:", "RGB", "Byte Order:", "Little-endian (II)", "LZW Compression", "Embed ICC Profile"] {
screen_text_at(&out, label);
}
assert_eq!(field(&app, "formats"), rows, "opening the gear preserves the saved spelling and row options");
assert_eq!(app.session.active().unwrap().doc.export_settings, saved_before, "opening settings does not save them");
let mut expected = field(&app, "settings");
assert_eq!(expected["tiff"]["colorModel"], "rgb", "saved RGB overrides the CMYK document default");
assert_eq!(expected["tiff"]["lzw"], false);
assert_eq!(expected["tiff"]["embedIcc"], false);
assert_eq!(expected["tiff"]["antiAlias"], "type");
assert_eq!(expected["png"]["interlaced"], true);
h.choose(&mut app, "Little-endian (II)", "Big-endian (MM)");
expected["tiff"]["byteOrder"] = json!("big");
assert_eq!(field(&app, "settings"), expected, "only the chosen TIFF option changes");
h.click_label(&mut app, "Done");
assert_eq!(field(&app, "formats"), rows);
h.click_label(&mut app, "Download");
assert!(app.ui.dialog.is_none(), "{}", app.ui.status);
{
let log = log.borrow();
let [(name, bytes)] = log.as_slice() else { panic!("expected one TIFF download for {alias}") };
assert_eq!(name, if asset_panel { "Badge@2x.tif" } else { "Artboard-1@2x.tif" });
let tags = big_tiff_tags(bytes); // The gear's edited canonical settings reach the aliased row.
assert!(tags.contains(&(259, 3, 1, 1)), "saved uncompressed option survives");
assert!(tags.contains(&(262, 3, 1, 2)), "saved RGB survives");
assert!(!tags.iter().any(|t| t.0 == 34675), "saved profile-off option survives");
let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Tiff).unwrap();
assert_eq!((img.width(), img.height()), if asset_panel { (60, 40) } else { (120, 80) });
}
let saved = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone();
assert_eq!(saved["formats"], rows, "export preserves the alias, scale, suffix and row override");
assert_eq!(saved["settings"], expected, "canonical TIFF edits and other format settings persist");
open(&mut app);
assert_eq!(field(&app, "formats"), rows);
assert_eq!(field(&app, "settings"), expected);
}
#[test]
fn tiff_saved_alias_gear_opens_and_edits_settings_in_screens() {
for alias in ["tiff", "tif", "TIFF", ".tif"] {
saved_tiff_alias_gear(alias, false);
}
}
#[test]
fn tiff_saved_alias_gear_opens_and_edits_settings_in_asset_export() {
for alias in ["tiff", "tif", "TIFF", ".tif"] {
saved_tiff_alias_gear(alias, true);
}
}
#[test]
fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() {
let (mut app, log) = app(2, true);
app.run("paint.setStroke", json!({"none": true})).unwrap();
app.run("paint.setFill", json!({"color": "#c03020"})).unwrap();
open(&mut app);
// Begin with an existing PNG row; TIFF must be chosen from the actual popup.
set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}]));
set(&mut app, "prefix", json!("proof-"));
let mut h = ScreensFrame::new();
h.choose(&mut app, "PNG", "TIFF");
assert_eq!(field(&app, "formats"), json!([{"format": "tiff", "scale": "2x", "suffix": "@2x"}]));
// Leave only the first of two boards checked.
h.click_label(&mut app, "Artboard 2");
assert_eq!(field(&app, "boards"), json!([true, false]));
assert_eq!(field(&app, "range"), json!("1"));
// Exercise the settings dropdown independently of the row's format choice.
set(&mut app, "__settings", json!("png"));
h.choose(&mut app, "PNG", "TIFF");
assert_eq!(field(&app, "__settings"), json!("tiff"));
let settings = field(&app, "settings");
assert_eq!(settings["tiff"]["colorModel"], "rgb");
assert_eq!(settings["tiff"]["byteOrder"], "little");
assert_eq!(settings["tiff"]["lzw"], true);
assert_eq!(settings["tiff"]["embedIcc"], true);
h.choose(&mut app, "Little-endian (II)", "Big-endian (MM)");
h.click_label(&mut app, "LZW Compression");
h.click_label(&mut app, "Embed ICC Profile");
let settings = field(&app, "settings");
assert_eq!(settings["tiff"]["byteOrder"], "big");
assert_eq!(settings["tiff"]["lzw"], false);
assert_eq!(settings["tiff"]["embedIcc"], false);
h.click_label(&mut app, "Done");
h.click_label(&mut app, "Download");
assert!(app.ui.dialog.is_none(), "{}", app.ui.status);
{
let log = log.borrow();
let [(name, bytes)] = log.as_slice() else { panic!("expected one TIFF download") };
assert_eq!(name, "proof-Artboard-1@2x.tif");
let tags = big_tiff_tags(bytes);
assert!(tags.contains(&(259, 3, 1, 1)), "uncompressed as selected");
assert!(tags.contains(&(262, 3, 1, 2)), "RGB");
assert!(!tags.iter().any(|t| t.0 == 34675), "ICC profile disabled");
let img = image::load_from_memory_with_format(bytes, image::ImageFormat::Tiff).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (120, 80));
assert_eq!(img.get_pixel(0, 0).0[3], 0, "transparent board corner");
let inside = img.get_pixel(40, 28).0;
assert!(inside[0] > inside[1] && inside[3] == 255, "ellipse at the scaled position: {inside:?}");
}
open(&mut app);
assert_eq!(field(&app, "formats"), json!([{"format": "tiff", "scale": "2x", "suffix": "@2x"}]));
assert_eq!(field(&app, "boards"), json!([true, false]));
assert_eq!(field(&app, "prefix"), "proof-");
assert_eq!(field(&app, "settings")["tiff"], settings["tiff"]);
// Existing vector and raster choices remain selectable.
h.choose(&mut app, "TIFF", "SVG");
assert_eq!(field(&app, "formats")[0]["format"], "svg");
h.choose(&mut app, "SVG", "PNG");
assert_eq!(field(&app, "formats")[0]["format"], "png");
}