Merge pull request #6786 from dotCooCoo/fix/save-export-show-every-note

Save and Export: the status bar's hover lists every note
This commit is contained in:
BFlat
2026-11-12 15:13:51 +07:00
committed by GitHub
15 changed files with 141 additions and 40 deletions
+1 -1
View File
@@ -1492,7 +1492,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"css.exportFile",
"Export CSS…",
"",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}",
),
(
"file.print",
+3 -2
View File
@@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
/// Most notes the status message's hover and access information list.
const MAX_LISTED_NOTES: usize = 30;
/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when
/// there are several and the status bar still shows the message they came with, else none.
/// The notes behind the status message (an open's, a save's or an export's,
/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows
/// the message they came with, else none.
fn status_notes(ui: &crate::state::UiState) -> &[String] {
let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1);
notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default()
+5 -5
View File
@@ -133,8 +133,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// `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.
/// output, else the image); the web downloads them. The status bar shows the export's warnings as
/// [`io::report_saved`] shows a save's notes.
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)?;
@@ -170,7 +170,7 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
}
/// 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.
/// [`io::report_saved`] shows them in the status bar as a save's notes.
fn has_warnings(r: &Value) -> bool {
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
}
@@ -183,8 +183,8 @@ pub(super) fn preview_name(stem: &str) -> &str {
}
/// `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 …".
/// default browser (desktop). When the export has warnings, the status bar shows them as
/// [`io::report_saved`] shows a save's notes.
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());
@@ -508,6 +508,35 @@ fn the_status_bar_says_when_an_image_could_not_be_decoded() {
let _ = std::fs::remove_dir_all(dir);
}
/// An export into a folder with several notes, from Export for Screens and from the Asset Export
/// panel: the status bar names the first, counts them, and keeps every one of them for its hover.
#[test]
fn an_export_into_a_folder_keeps_every_note_for_the_hover() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let dir = temp_folder("notes");
let folder = dir.to_string_lossy().replace('\\', "/");
let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}]);
let (mut app, _) = app(1, false);
vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [10, 10]}));
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap();
let notes = crate::io::notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert!(app.ui.status.starts_with(&format!("Saved {folder} with 2 notes: {}", notes[0])), "{}", app.ui.status);
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
app.ui.status_notes = None;
app.run("select.all", json!({})).unwrap();
app.run("assets.add", json!({"multiple": false})).unwrap();
let r = crate::io::export_files(&mut app, "assets.export", json!({"folder": folder, "formats": rows})).unwrap();
let notes = crate::io::notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)), "assets.export");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_preset_export_reopens_on_its_rows() {
let (mut app, _) = app(1, true);
+32 -2
View File
@@ -30,7 +30,7 @@ pub use open::{
#[cfg(test)]
pub(crate) use open::{open_file, opened_with_notes};
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to};
pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to};
#[cfg(test)]
pub(crate) use save::{report_write_error, saved_status};
@@ -90,7 +90,7 @@ mod tests {
use vectorcraft_testkit::ai;
let (mut app, _) = app();
let damaged = b"%AI12_CompressedData not zlib";
let notes = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
let notes = notes_of;
let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f");
let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap();
let [page, layers] = &notes(&r)[..] else { panic!("two notes: {r}") };
@@ -125,6 +125,36 @@ mod tests {
assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown);
}
/// A save and an export with several notes: the status bar names the first, counts them, and
/// keeps every one of them, in the reply's order, for its hover. A save without notes keeps
/// none.
#[test]
fn a_save_and_an_export_keep_every_note_for_the_hover() {
let (mut app, _) = app();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap();
let notes = notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert!(notes[0].starts_with("SVG keeps the artwork but not everything"), "{r}");
assert_eq!(app.ui.status, saved_status("/out/a.svg", &r));
assert!(app.ui.status.contains("with 2 notes"), "{}", app.ui.status);
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap();
assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None));
// A PDF export of a knockout group and of type in a font that isn't installed.
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("transparency.set", json!({"knockout": "on"})).unwrap();
let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap();
let notes = notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), &notes[0]));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
}
/// #1685: a save or an export that can't write its file says so in a dialog that names the
/// file and the reason and stays until it is closed; the document stays modified and keeps
/// the file it had. An agent's request only gets the error, and a message that arrives while
+11 -9
View File
@@ -1,6 +1,6 @@
use super::save::{
ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path,
target_path_keeping, view_file, write_encoded, write_named, write_reporting,
ask_before_losing, export_path, is_web, notes_of, remember_active_artboard, report_saved, report_write_error, saved_status, show_with_notes,
target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting,
};
use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress};
@@ -94,11 +94,12 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(),
};
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
app.status(match warnings.first() {
let warnings = notes_of(&r);
let status = match warnings.first() {
Some(first) => exported_with_notes(&what, warnings.len(), first),
None => format!("Exported {what}"),
});
};
show_with_notes(app, status, warnings);
Ok(r)
})?;
if r.get("path").is_none() {
@@ -115,7 +116,7 @@ pub(crate) fn exported_with_notes(what: &str, n: usize, first: &str) -> String {
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs'
/// Export…), reported with the first of the command's warnings ([`report_saved`]).
/// Export…), reported with the command's warnings ([`report_saved`]).
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result<String, String> {
let (path, v) = run_to_file(app, id, ext, params)?;
report_saved(app, &path, &v);
@@ -241,8 +242,9 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(),
/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`;
/// without one the web downloads them (the ZIP, or each file) and the other frontends get them
/// back as base64. With `openLocation`, the file manager shows the first file written. When an
/// export into a folder or a download has warnings, the status bar shows the first as
/// [`report_saved`] shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …".
/// export into a folder or a download has warnings, the status bar shows them as [`report_saved`]
/// shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …", and hovering the
/// message lists up to 30 of them, fewer when the window is small, with how many were left out.
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
export_files(app, "document.exportForScreens", params)
}
@@ -312,7 +314,7 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati
{
status = format!("{status} ({e})");
}
app.status(status);
show_with_notes(app, status, notes_of(&r));
} else if let Some(download) = app.services.download.as_mut() {
// A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone.
let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect();
+4 -4
View File
@@ -1,7 +1,7 @@
use vectorcraft_engine::cmd::plugin::InstallApproval;
use vectorcraft_plugins::permissions;
use super::save::suggested;
use super::save::{notes_of, show_with_notes, suggested};
use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json};
/// Template extensions New from Template's open dialog lists first.
@@ -144,8 +144,8 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) {
app.sync_views();
let notes = open_notes(app, r);
if let Some(first) = notes.first() {
app.status(opened_with_notes(notes.len(), first));
app.ui.status_notes = Some((app.ui.status.clone(), notes));
let status = opened_with_notes(notes.len(), first);
show_with_notes(app, status, notes);
}
crate::dialogs::missing_links::after_open(app, r);
}
@@ -153,7 +153,7 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) {
/// The notes of the open `r`, those of what reading the file left out (its document's
/// `import_losses`) first, each group in the order the import gave them.
fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> {
let notes: Vec<String> = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let notes = notes_of(r);
let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok());
let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default();
let (mut first, rest): (Vec<String>, Vec<String>) = notes.into_iter().partition(|n| losses.contains(n));
+19 -4
View File
@@ -394,20 +394,35 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) {
}
}
/// "Saved <path>", with the first of the result's warnings.
/// "Saved <path>", with the first of the result's warnings and how many there are; hovering the
/// message lists up to 30 of them, fewer when the window is small, with how many were left out
/// ([`show_with_notes`]).
pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
app.status(saved_status(path, r));
show_with_notes(app, saved_status(path, r), notes_of(r));
}
/// The warnings of a reply `r`, in the reply's order (a save's notes start with what the format
/// loses).
pub(crate) fn notes_of(r: &Value) -> Vec<String> {
r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
}
/// Show `status`, which names the first of `notes`, in the status bar and keep `notes` for its
/// hover ([`crate::state::UiState::status_notes`]). Without notes nothing is kept.
pub(crate) fn show_with_notes(app: &mut VectorcraftApp, status: String, notes: Vec<String>) {
app.status(status);
app.ui.status_notes = (!notes.is_empty()).then(|| (app.ui.status.clone(), notes));
}
/// [`report_saved`]'s message.
pub(crate) fn saved_status(path: &str, r: &Value) -> String {
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let warnings = notes_of(r);
match warnings.first() {
Some(first) => crate::i18n::msg_n(
warnings.len() as u64,
"Saved {path} with {n} note: {first}",
"Saved {path} with {n} notes: {first}",
&[("path", path), ("first", first)],
&[("path", path), ("first", first.as_str())],
),
None => format!("Saved {path}"),
}
+1 -1
View File
@@ -1493,7 +1493,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"css.exportFile",
"Export CSS…",
"",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}",
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}",
),
(
"file.print",
+6 -5
View File
@@ -86,7 +86,7 @@ fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Opt
let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") };
let out = app.session.execute(id, &params(opts, scope)).unwrap_or_default();
let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect();
let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let warnings = io::notes_of(&out);
let job = (!rules.is_empty())
.then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style()))));
let skipped = out["skipped"].as_u64().unwrap_or(0);
@@ -204,13 +204,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result<Value,
io::write_to(&mut app.services, &at, &bytes)?;
images.push(at);
}
// The status bar shows the first warning in Export As's message.
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
// The status bar shows the warnings as Export As shows them.
let warnings = io::notes_of(&r);
let what = &path;
app.status(match warnings.first() {
let status = match warnings.first() {
Some(first) => io::exported_with_notes(what, warnings.len(), first),
None => format!("Exported {path}"),
});
};
io::show_with_notes(app, status, warnings);
Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] }))
}
@@ -146,3 +146,23 @@ fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note: {WARNING}"));
assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written");
}
/// A CSS export with several notes: the status bar names the first, counts them, and keeps every
/// one of them for its hover.
#[test]
fn export_keeps_every_note_for_the_hover() {
let (mut app, _) = app();
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({}));
// Type painted with a gradient is rasterized, and its picture is drawn in the fallback font.
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
app.run("paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap();
let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap();
let notes = crate::io::notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 2 notes: {}", notes[0]));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
}
+3 -2
View File
@@ -537,8 +537,9 @@ pub struct UiState {
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
#[serde(skip)]
pub replace_font_warning: Option<ReplaceFontWarning>,
/// The notes of the last open that gave any, with the status text that names the first of
/// them: hovering that text lists them when there are several. Not saved.
/// The notes of the last open that gave any, or of the last save or export, with the status
/// text that names the first of them: hovering that text lists them when there are several.
/// Not saved.
#[serde(skip)]
pub status_notes: Option<(String, Vec<String>)>,
pub palette_open: bool,
+4 -4
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
@@ -118,13 +118,13 @@ the two can still win that race.
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` |
| Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written |
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes |
| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. |
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes |
| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
@@ -824,7 +824,7 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit`
the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers
the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser`
writes the HTML page to a temporary folder and opens it in the default browser (desktop). Both return the export's
`warnings`, and the status bar shows the first one as Save As shows a save's notes.
`warnings`, and the status bar shows them as Save As shows a save's notes.
PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`:
the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK
+2 -1
View File
@@ -6527,7 +6527,8 @@ point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out
writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its
`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the
fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web
downloads it) and shows the first note in the status bar.
downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the
message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out.
```json
{"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}}
+1
View File
@@ -579,6 +579,7 @@ since 2026-10-01.
- **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change.
- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged.
- **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged.
- **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged.
- **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged.
- **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.