M4.17: File menu details: converted files, 30 recent files, one colour-mode command, Show in Folder, saved view
Older native files (.drawcraft or an older format version) open as "<name> [Converted]" and Save asks for a .vectorcraft name instead of overwriting them (preference appendConverted). Open Recent Files honours the 0-30 preference; empty slots are hidden (the native menu rebuilds when they, saved views or User Defined libraries change). File > Document Color Mode is one command, file.documentColorMode, converting through the colour settings; choosing the current mode is not an edit, and object.convertDocumentColorMode stays as an alias the command palette leaves out. New File > Show in Folder (file.reveal; paths with spaces work on Windows). Native files store the view they were saved with (Document.last_view, written at save time so it never dirties the document) and reopen there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
70e2726321
commit
489bb84755
|
|
@ -107,6 +107,36 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
|
|||
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
|
||||
}
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.args(["-R", path]);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
|
||||
// and needs backslashes.
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
fn reveal_command(path: &str) -> std::process::Command {
|
||||
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(folder);
|
||||
c
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
|
|
@ -134,6 +164,7 @@ fn services() -> Services {
|
|||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
})),
|
||||
reveal: Some(Box::new(reveal)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
|
||||
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second.
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
|
||||
//! when saved views or recent files come and go.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
|
@ -22,6 +23,8 @@ pub struct NativeMenu {
|
|||
last_refresh: f64,
|
||||
/// Shortcut-override / workspace-list generation the menu was built for.
|
||||
generation: u64,
|
||||
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
|
||||
slots: usize,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
|
|
@ -53,7 +56,7 @@ impl NativeMenu {
|
|||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation }
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
|
|
@ -69,8 +72,11 @@ impl NativeMenu {
|
|||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
|
||||
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
|
||||
// accelerators and lists are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,6 +143,8 @@ fn build(
|
|||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
|
||||
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
|
||||
Item::Cmd(label, cmd, params) => {
|
||||
*counter += 1;
|
||||
let id = format!("dc{counter}");
|
||||
|
|
|
|||
|
|
@ -493,6 +493,10 @@ pub struct Document {
|
|||
/// ([`Document::raster_effects_ppi`]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub raster_effects: RasterEffectsSettings,
|
||||
/// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at
|
||||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
|
|
@ -552,6 +556,7 @@ impl Document {
|
|||
paste_remembers_layers: false,
|
||||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
|
|
|||
|
|
@ -51,9 +51,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(
|
||||
"object.convertDocumentColorMode",
|
||||
"Convert Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
[],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}",
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
has_doc,
|
||||
convert_mode
|
||||
),
|
||||
|
|
@ -235,54 +235,75 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
|
||||
fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.convertDocumentColorMode";
|
||||
/// File → Document Color Mode (`file.documentColorMode` and its alias
|
||||
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
|
||||
/// colour through the colour settings.
|
||||
pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let c = cms::active();
|
||||
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
// definitions) are device independent and fit either mode.
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let intent = intent_param(p, C)?;
|
||||
// Choosing the mode the document already has (the checked menu item) is not an edit.
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
}
|
||||
let convert = bool_or(p, "convert", true);
|
||||
let mut changed = 0usize;
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
d.color_mode = mode;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed = set_color_mode(d, mode, convert, intent);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
|
||||
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
|
||||
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
/// definitions) are device independent and fit either mode. Document Color Mode and
|
||||
/// `document.open {colorMode}` both convert this way.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return 0;
|
||||
}
|
||||
let c = cms::active();
|
||||
let intent = intent.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let mut changed = 0usize;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
fn values(c: &Color) -> Vec<f32> {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ use std::collections::BTreeSet;
|
|||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
|
||||
use super::colorcmds::{Recolor, Scope, to_cmyk};
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
|
|
@ -149,9 +148,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
|
||||
has_doc,
|
||||
color_mode
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
cmd!(
|
||||
"file.info",
|
||||
|
|
@ -444,43 +443,3 @@ fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
Ok(json!({ "closed": n }))
|
||||
}
|
||||
|
||||
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let convert = bool_or(p, "convert", true);
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
set_color_mode(d, mode, convert);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return;
|
||||
}
|
||||
let f = move |c: vectorcraft_color::Color| match mode {
|
||||
ColorMode::Cmyk => to_cmyk(c),
|
||||
ColorMode::Rgb => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
vectorcraft_color::Color::rgb(r, g, b)
|
||||
}
|
||||
};
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
|
||||
for sw in d.swatches_iter_mut() {
|
||||
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
|
||||
*color = f(*color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ pub struct Loaded {
|
|||
pub warnings: Vec<String>,
|
||||
/// The document is the native one the file carries (SVG or PDF saved with Preserve Editing).
|
||||
pub restored: bool,
|
||||
/// An older native file (the former `.drawcraft` name or format version): saving it again
|
||||
/// rewrites it in today's format.
|
||||
pub converted: bool,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
|
|
@ -113,8 +116,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
}
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
"vectorcraft" | "template" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false),
|
||||
"vectorcraft" | "template" => {
|
||||
let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
|
||||
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
|
||||
(d, vec![], false)
|
||||
}
|
||||
"svg" | "svgz" => {
|
||||
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
|
|
@ -129,14 +137,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::docmenu::set_color_mode(&mut doc, mode, true);
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
|
||||
}
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings, restored })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted })
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
|
|
@ -162,7 +170,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
|||
|
||||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored } = loaded;
|
||||
let Loaded { mut doc, format, warnings, restored, converted } = loaded;
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
|
|
@ -178,10 +186,13 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &Loa
|
|||
let lossy_ai = format.id == "ai" && !restored;
|
||||
let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id));
|
||||
let saves_back = path.is_some();
|
||||
let converted = saves_back && converted && s.prefs.append_converted;
|
||||
let index = s.add_document(doc, path);
|
||||
let st = s.doc_mut()?;
|
||||
if saves_back {
|
||||
s.doc_mut()?.format = format.id;
|
||||
st.format = format.id;
|
||||
}
|
||||
st.converted = converted;
|
||||
let title = s.documents()[index].title();
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
|
||||
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
|
|
@ -62,8 +64,8 @@ impl SaveMode {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct SavePlan {
|
||||
pub mode: SaveMode,
|
||||
/// Where to write. `None` when no path is known (never saved, or a format other than the
|
||||
/// document's own): the caller asks for one or takes the bytes.
|
||||
/// Where to write. `None` when no path is known (never saved, converted from an older version,
|
||||
/// or a format other than the document's own): the caller asks for one or takes the bytes.
|
||||
pub path: Option<String>,
|
||||
pub format: &'static Format,
|
||||
/// The format's options (only those its encoder reads).
|
||||
|
|
@ -159,7 +161,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
|||
// None given: the ones the document was last saved with in this format.
|
||||
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
|
||||
// Save writes the document's own file; the other modes only where they are told to.
|
||||
let path = path.or_else(|| (mode == SaveMode::Save && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title));
|
||||
let ext = format.extensions[0];
|
||||
let name = match mode {
|
||||
|
|
@ -197,6 +199,17 @@ fn is_svg(f: &Format) -> bool {
|
|||
matches!(f.id, "svg" | "svgz")
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at.
|
||||
fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> {
|
||||
if is_native(f) && st.doc.last_view != st.view {
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
Cow::Owned(d)
|
||||
} else {
|
||||
Cow::Borrowed(&st.doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a format loses against a native file (reported whenever a save writes it).
|
||||
fn fidelity_warning(f: &Format) -> Option<String> {
|
||||
(!is_native(f)).then(|| {
|
||||
|
|
@ -218,10 +231,10 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
|||
if plan.path.is_some() && retargets {
|
||||
stamp_save_dates(s.doc_mut()?);
|
||||
}
|
||||
let own: &Document = &s.doc()?.doc;
|
||||
let own = doc_to_save(s.doc()?, plan.format);
|
||||
// A native file records its links' paths relative to where it is written.
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(own, p));
|
||||
let doc = relative.as_ref().unwrap_or(own);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
|
||||
let doc: &Document = relative.as_ref().unwrap_or(&own);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
|
|
@ -269,6 +282,7 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
|||
st.path = Some(path.clone());
|
||||
st.format = plan.format.id;
|
||||
st.save_options = plan.options;
|
||||
st.converted = false;
|
||||
st.mark_saved();
|
||||
}
|
||||
Ok(out)
|
||||
|
|
@ -325,7 +339,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
|||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ fn color_mode_opens_the_file_in_the_mode_asked_for() {
|
|||
s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
let f = fills(&s);
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}");
|
||||
// Converted as Document Color Mode does: no CMYK left, greys stay grey.
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. } | Color::Gray { .. }))), "{f:?}");
|
||||
assert!(matches!(f[0].color(), Some(Color::Rgb { .. })) && matches!(f[1].color(), Some(Color::Gray { .. })), "{f:?}");
|
||||
// Any format opens in the mode asked for.
|
||||
let mut svg = Session::new();
|
||||
svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
|
||||
|
|
|
|||
|
|
@ -394,3 +394,12 @@ pub use flatten::FlattenOptions;
|
|||
|
||||
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
|
||||
pub use flatten::FlattenerPreset;
|
||||
|
||||
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
|
||||
/// They run as before but the command palette leaves them out (it lists the command once).
|
||||
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
|
||||
|
||||
/// Is `id` an [`ALIASES`] entry?
|
||||
pub fn is_alias(id: &str) -> bool {
|
||||
ALIASES.iter().any(|(a, _)| *a == id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
|||
p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool),
|
||||
// File Handling (continued)
|
||||
p!("templatesFolder", "File Handling", "Files", "Templates Folder", text),
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
|
|
|||
|
|
@ -118,13 +118,21 @@ pub struct DocState {
|
|||
/// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF;
|
||||
/// empty for native files): Save reuses them and `file.formatOptions` reads them back.
|
||||
pub save_options: serde_json::Map<String, Value>,
|
||||
/// Opened from an older native file (former name or format version): the title says
|
||||
/// "[Converted]" and Save asks for a new name instead of overwriting it.
|
||||
pub converted: bool,
|
||||
/// The view saved into native files (`Document::last_view`); the UI keeps it current before a
|
||||
/// save and restores it when the document opens.
|
||||
pub view: Option<vectorcraft_doc::SavedView>,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
impl DocState {
|
||||
pub fn new(doc: Document, path: Option<String>) -> Self {
|
||||
pub fn new(mut doc: Document, path: Option<String>) -> Self {
|
||||
let active_layer = doc.default_layer();
|
||||
// The saved view lives here while the document is open (saves write it back).
|
||||
let view = doc.last_view.take();
|
||||
let doc = Arc::new(doc);
|
||||
Self {
|
||||
saved_doc: doc.clone(),
|
||||
|
|
@ -143,6 +151,8 @@ impl DocState {
|
|||
transparency_grid: false,
|
||||
format: "vectorcraft",
|
||||
save_options: Default::default(),
|
||||
converted: false,
|
||||
view,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
|
|
@ -154,11 +164,13 @@ impl DocState {
|
|||
self.saved_doc = self.doc.clone();
|
||||
}
|
||||
pub fn title(&self) -> String {
|
||||
self.path
|
||||
let name = self
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| self.doc.title.clone())
|
||||
.unwrap_or_else(|| self.doc.title.clone());
|
||||
if self.converted { format!("{} [Converted]", cmd::fileio::file_stem(&name)) } else { name }
|
||||
}
|
||||
/// Where new art is inserted: the isolation container, else the active layer.
|
||||
/// The current layer, if the remembered id still names a layer (ids are reused after undo).
|
||||
|
|
@ -375,6 +387,8 @@ pub struct Prefs {
|
|||
// File Handling (continued)
|
||||
/// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`).
|
||||
pub templates_folder: String,
|
||||
/// Open older native files as "<name> [Converted]" so Save asks for a new name.
|
||||
pub append_converted: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -502,6 +516,7 @@ impl Default for Prefs {
|
|||
recent_new_docs: vec![],
|
||||
pdf_presets: vec![],
|
||||
templates_folder: String::new(),
|
||||
append_converted: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -676,7 +691,7 @@ impl Session {
|
|||
i
|
||||
}
|
||||
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
|
||||
/// selection, saved state. The tab keeps its place, path and format.
|
||||
/// selection, saved state. The tab keeps its place, path, format and view.
|
||||
pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
|
|
@ -694,6 +709,8 @@ impl Session {
|
|||
st.revision = old.revision + 1;
|
||||
st.format = old.format;
|
||||
st.save_options = old.save_options.clone();
|
||||
st.converted = old.converted;
|
||||
st.view = old.view.clone();
|
||||
self.docs[index] = st;
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Menu long-tail commands (Object/Edit/Select/Type/View/File), driven through `Session::execute`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
|
|
@ -672,7 +672,9 @@ fn document_color_mode_converts_colours() {
|
|||
fill(&mut s, a, "#ff0000");
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk);
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
// Converted through the colour settings: a press red, mostly magenta and yellow.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = node(&s, a).appearance.fill_paint().color() else { panic!("not CMYK") };
|
||||
assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
assert!(s.execute("file.documentColorMode", &json!({"mode": "lab"})).is_err());
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert
|
||||
//! and templates.
|
||||
//! The save pipeline: Save writes a document's own format, Save As / a Copy / as Template, Revert,
|
||||
//! templates, converted legacy files, the saved view and the single Document Color Mode command.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SavedView;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::fileio::{SaveMode, save_plan};
|
||||
|
|
@ -195,6 +197,69 @@ fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() {
|
|||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A native file as an older version wrote it: under the former name, or format version 1.
|
||||
fn old_file(legacy: bool) -> Vec<u8> {
|
||||
let text = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(50.0, 50.0), false)).unwrap();
|
||||
let text = if legacy {
|
||||
text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1)
|
||||
} else {
|
||||
text.replacen(&format!("\"version\":{}", vectorcraft_format::VERSION), "\"version\":1", 1)
|
||||
};
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_native_files_open_converted_and_save_under_a_new_name() {
|
||||
let d = dir("converted");
|
||||
let legacy = path(&d, "logo.drawcraft");
|
||||
std::fs::write(&legacy, old_file(true)).unwrap();
|
||||
let v1 = path(&d, "badge.vectorcraft");
|
||||
std::fs::write(&v1, old_file(false)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": legacy})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "logo [Converted]");
|
||||
// Save doesn't overwrite the old file: it asks for a .vectorcraft name.
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r.get("path")), (Some("logo.vectorcraft"), None));
|
||||
assert_eq!(std::fs::read(&legacy).unwrap(), old_file(true));
|
||||
let r = s.execute("document.open", &json!({"path": v1})).unwrap();
|
||||
assert_eq!(r["title"], "badge [Converted]");
|
||||
let saved = path(&d, "badge2.vectorcraft");
|
||||
s.execute("file.saveAs", &json!({"path": saved})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft");
|
||||
assert!(!s.doc().unwrap().converted);
|
||||
// A current file isn't converted; with the preference off old files open as they are.
|
||||
s.execute("document.open", &json!({"path": saved})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "badge2.vectorcraft");
|
||||
s.prefs.append_converted = false;
|
||||
s.execute("document.open", &json!({"path": legacy})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().title(), "logo.drawcraft");
|
||||
assert_eq!(s.execute("document.save", &json!({})).unwrap()["path"].as_str(), Some(legacy.as_str()));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_view_round_trips_without_dirtying() {
|
||||
let d = dir("view");
|
||||
let mut s = session();
|
||||
let view = SavedView { name: String::new(), center: Point::new(40.0, 25.0), zoom: 3.0, rotation: 15.0 };
|
||||
s.doc_mut().unwrap().view = Some(view.clone());
|
||||
assert!(!s.doc().unwrap().is_dirty(), "the view isn't an edit");
|
||||
let native = path(&d, "v.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.last_view, None, "only the written file carries it");
|
||||
s.execute("document.open", &json!({"path": native})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().view.as_ref(), Some(&view));
|
||||
// Files without a view (older ones) open with none; SVG doesn't carry it.
|
||||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
|
||||
assert_eq!(doc.last_view, None);
|
||||
let svg = path(&d, "v.svg");
|
||||
s.execute("document.save", &json!({"path": svg})).unwrap();
|
||||
s.execute("document.open", &json!({"path": svg})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().view, None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_plans_suggest_names_and_folders() {
|
||||
let mut s = session();
|
||||
|
|
@ -208,6 +273,39 @@ fn save_plans_suggest_names_and_folders() {
|
|||
assert_eq!(crate::cmd::fileio::save_filters("svg")[0], ("SVG", &["svg"][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_color_mode_is_one_command_with_a_hidden_alias() {
|
||||
let mut s = session();
|
||||
let ids: Vec<&str> = s.commands().iter().filter(|c| c.menu.first() == Some(&"File") && c.label.contains("Color Mode")).map(|c| c.id).collect();
|
||||
assert_eq!(ids, ["file.documentColorMode"], "one File menu entry");
|
||||
assert!(crate::cmd::is_alias("object.convertDocumentColorMode") && !crate::cmd::is_alias("file.documentColorMode"));
|
||||
// Both ids run the same conversion; `convert: false` only switches the mode.
|
||||
rect(&mut s);
|
||||
assert!(s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap()["changed"].as_u64().unwrap() >= 1);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("file.documentColorMode", &json!({"mode": "rgb", "convert": false})).unwrap();
|
||||
assert_eq!(r["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "colours untouched");
|
||||
let e = s.execute("file.documentColorMode", &json!({"mode": "lab"})).unwrap_err().to_string();
|
||||
assert!(e.contains("file.documentColorMode"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_the_current_color_mode_is_not_an_edit() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.mark_saved();
|
||||
let undo = st.history.undo.len();
|
||||
for id in ["file.documentColorMode", "object.convertDocumentColorMode"] {
|
||||
assert_eq!(s.execute(id, &json!({"mode": "rgb"})).unwrap()["changed"], 0);
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert!(!st.is_dirty(), "the document stays saved");
|
||||
assert_eq!(st.history.undo.len(), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_opened_in_part_is_not_saved_back_over_the_whole_file() {
|
||||
let d = dir("pdfpart");
|
||||
|
|
|
|||
|
|
@ -100,6 +100,27 @@ pub fn save_file(doc: &Document) -> Vec<u8> {
|
|||
}
|
||||
|
||||
pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
load_info(bytes).map(|(doc, _)| doc)
|
||||
}
|
||||
|
||||
/// What a native file says about itself besides its document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct FileInfo {
|
||||
/// The file's format version (see [`VERSION`]).
|
||||
pub version: u32,
|
||||
/// Written under the project's former name (`"format": "drawcraft"`).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
/// Written by an older version or under the former name: saving rewrites it in today's format.
|
||||
pub fn is_old(&self) -> bool {
|
||||
self.legacy || self.version < VERSION
|
||||
}
|
||||
}
|
||||
|
||||
/// [`load`], also telling the file's version and name.
|
||||
pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
let f: File = serde_json::from_slice(bytes).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
||||
|
|
@ -122,7 +143,7 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
// Saved before per-fill/stroke overprint: the Overprint Black list becomes overprint flags.
|
||||
doc.migrate_overprint_black();
|
||||
doc.fix_next_id();
|
||||
Ok(doc)
|
||||
Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }))
|
||||
}
|
||||
|
||||
/// Does this look like a `.vectorcraft` file?
|
||||
|
|
@ -188,6 +209,20 @@ mod tests {
|
|||
assert!(is_native_name("old/Poster.DrawCraft") && is_native_name("new.vectorcraft") && !is_native_name("x.svg"));
|
||||
assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_info_tells_old_files() {
|
||||
let d = Document::new(10.0, 10.0);
|
||||
let current = save(&d, false);
|
||||
assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false });
|
||||
assert!(!load_info(¤t).unwrap().1.is_old());
|
||||
let text = String::from_utf8(current).unwrap();
|
||||
let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
|
||||
assert!(load_info(legacy.as_bytes()).unwrap().1.is_old());
|
||||
let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1);
|
||||
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
|
||||
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! File I/O through the injected services (pick, read, write, download); the engine's
|
||||
//! File I/O through the injected services (pick, read, write, download, reveal); the engine's
|
||||
//! `fileio` decodes, encodes and saves every format.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SavedView;
|
||||
use vectorcraft_engine::EngineError;
|
||||
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
|
||||
|
||||
|
|
@ -141,6 +142,14 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &
|
|||
pick_path(app, &FilePick { name, folder, filters })
|
||||
}
|
||||
|
||||
/// Keep the active document's saved view current before a save (native files reopen at it).
|
||||
pub fn remember_view(app: &mut VectorcraftApp) {
|
||||
let Some(v) = app.view().copied().filter(|v| v.fitted) else { return };
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation });
|
||||
}
|
||||
}
|
||||
|
||||
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
|
||||
/// type), else `f`'s extension is added.
|
||||
fn with_save_extension(path: &str, f: &Format) -> String {
|
||||
|
|
@ -174,6 +183,7 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
|
|||
/// to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
|
||||
/// path?}` while a dialog is open.
|
||||
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
|
||||
remember_view(app);
|
||||
let first = plan(app, mode, p)?;
|
||||
let ask = ask_options && !has_options(p);
|
||||
if let Some(path) = first.path.clone() {
|
||||
|
|
@ -288,12 +298,22 @@ fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str,
|
|||
json!({ "pending": dialogs::save_options::KIND })
|
||||
}
|
||||
|
||||
/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files).
|
||||
/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display
|
||||
/// shows 0 to this many of them.
|
||||
pub const MAX_RECENT_FILES: usize = 30;
|
||||
|
||||
/// Put `path` at the top of File → Open Recent Files.
|
||||
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
||||
let r = &mut app.ui.recent_files;
|
||||
r.retain(|p| p != path);
|
||||
r.insert(0, path.to_string());
|
||||
r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10));
|
||||
r.truncate(MAX_RECENT_FILES);
|
||||
}
|
||||
|
||||
/// The recent files File → Open Recent Files lists.
|
||||
pub fn recent_files(app: &VectorcraftApp) -> &[String] {
|
||||
let r = &app.ui.recent_files;
|
||||
&r[..r.len().min(app.session.prefs.recent_files_count as usize)]
|
||||
}
|
||||
|
||||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||||
|
|
@ -407,6 +427,13 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|||
Ok(json!({ "pending": dialogs::confirm::KIND }))
|
||||
}
|
||||
|
||||
/// File → Show in Folder: the document's file in the system file manager.
|
||||
pub fn reveal(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?;
|
||||
app.services.reveal.as_mut().ok_or("no file manager here")?(&path)?;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`.
|
||||
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ use std::sync::mpsc::{Receiver, Sender};
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
|
|
@ -98,6 +99,9 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
|||
/// Opens a URL in the system browser.
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
|
||||
|
||||
/// Shows a file in the system file manager.
|
||||
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
|
|
@ -129,6 +133,8 @@ pub struct Services {
|
|||
/// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through
|
||||
/// egui and `clipboard_read`).
|
||||
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
|
||||
/// File → Show in Folder: select a file in the system file manager (desktop).
|
||||
pub reveal: Option<RevealFn>,
|
||||
}
|
||||
|
||||
/// Cached canvas raster.
|
||||
|
|
@ -221,7 +227,7 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
|
|||
|
||||
impl VectorcraftApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
let views = session.documents().iter().map(|_| View::default()).collect();
|
||||
let views = session.documents().iter().map(View::of).collect();
|
||||
Self {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
|
|
@ -268,13 +274,11 @@ impl VectorcraftApp {
|
|||
self
|
||||
}
|
||||
|
||||
/// Keep `views` aligned with the session's documents.
|
||||
/// Keep `views` aligned with the session's documents (a new one starts at its saved view).
|
||||
pub fn sync_views(&mut self) {
|
||||
let n = self.session.documents().len();
|
||||
while self.views.len() < n {
|
||||
self.views.push(View::default());
|
||||
}
|
||||
self.views.truncate(n);
|
||||
let docs = self.session.documents();
|
||||
self.views.truncate(docs.len());
|
||||
self.views.extend(docs.iter().skip(self.views.len()).map(View::of));
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Option<&View> {
|
||||
|
|
@ -322,6 +326,10 @@ impl VectorcraftApp {
|
|||
return r;
|
||||
}
|
||||
}
|
||||
// Native files carry the view they reopen at.
|
||||
if fileio::SaveMode::of(id).is_some() {
|
||||
io::remember_view(self);
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") {
|
||||
self.publish_clipboard();
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"file.save",
|
||||
"Save",
|
||||
"Cmd+S",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved) the Save As panel asks first",
|
||||
"{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first",
|
||||
),
|
||||
(
|
||||
"file.place",
|
||||
|
|
@ -404,6 +404,27 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets",
|
||||
),
|
||||
("file.openRecent11", "Open Recent File 11", "", "{}"),
|
||||
("file.openRecent12", "Open Recent File 12", "", "{}"),
|
||||
("file.openRecent13", "Open Recent File 13", "", "{}"),
|
||||
("file.openRecent14", "Open Recent File 14", "", "{}"),
|
||||
("file.openRecent15", "Open Recent File 15", "", "{}"),
|
||||
("file.openRecent16", "Open Recent File 16", "", "{}"),
|
||||
("file.openRecent17", "Open Recent File 17", "", "{}"),
|
||||
("file.openRecent18", "Open Recent File 18", "", "{}"),
|
||||
("file.openRecent19", "Open Recent File 19", "", "{}"),
|
||||
("file.openRecent20", "Open Recent File 20", "", "{}"),
|
||||
("file.openRecent21", "Open Recent File 21", "", "{}"),
|
||||
("file.openRecent22", "Open Recent File 22", "", "{}"),
|
||||
("file.openRecent23", "Open Recent File 23", "", "{}"),
|
||||
("file.openRecent24", "Open Recent File 24", "", "{}"),
|
||||
("file.openRecent25", "Open Recent File 25", "", "{}"),
|
||||
("file.openRecent26", "Open Recent File 26", "", "{}"),
|
||||
("file.openRecent27", "Open Recent File 27", "", "{}"),
|
||||
("file.openRecent28", "Open Recent File 28", "", "{}"),
|
||||
("file.openRecent29", "Open Recent File 29", "", "{}"),
|
||||
("file.openRecent30", "Open Recent File 30", "", "{}"),
|
||||
("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -440,13 +461,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
// Bytes sent by an agent go straight to the engine; a path or nothing opens it here.
|
||||
"file.newFromTemplate" if p.get("dataBase64").is_none() => io::new_from_template(app, s("path")),
|
||||
"file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app),
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0);
|
||||
match n.checked_sub(1).and_then(|i| app.ui.recent_files.get(i)).cloned() {
|
||||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => Err("no such recent file".into()),
|
||||
}
|
||||
}
|
||||
"file.reveal" => io::reveal(app),
|
||||
id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() {
|
||||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => Err("no such recent file".into()),
|
||||
},
|
||||
"type.findFont" => {
|
||||
crate::find_font::open(app);
|
||||
Ok(Value::Null)
|
||||
|
|
@ -455,7 +474,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
app.ui.recent_files.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.recentFiles" => Ok(json!(app.ui.recent_files)),
|
||||
"file.recentFiles" => Ok(json!(io::recent_files(app))),
|
||||
"file.place" => crate::place::run(app, p),
|
||||
"file.place.queue" => crate::place::queue(app, p),
|
||||
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
|
|
@ -856,13 +875,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
|
||||
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
|
||||
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0);
|
||||
n.checked_sub(1)
|
||||
.and_then(|i| app.ui.recent_files.get(i))
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into())
|
||||
}
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id)
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
|
||||
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
|
||||
|
|
@ -875,14 +890,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Menu slots that are left out while they have nothing to show (unused saved views, User
|
||||
/// Defined swatch and graphic style libraries).
|
||||
/// Menu slots that are left out while they have nothing to show (unused saved views, recent files,
|
||||
/// User Defined swatch and graphic style libraries).
|
||||
fn hidden_when_disabled(id: &str) -> bool {
|
||||
id.starts_with("view.goto") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT)
|
||||
id.starts_with("view.goto")
|
||||
|| id.starts_with("file.openRecent")
|
||||
|| id.starts_with(crate::panels::swatches::USER_SLOT)
|
||||
|| id.starts_with(crate::panels::graphic_styles::USER_SLOT)
|
||||
}
|
||||
|
||||
/// File → Open Recent Files slots.
|
||||
const RECENT_IDS: [&str; 10] = [
|
||||
/// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them).
|
||||
const RECENT_IDS: [&str; io::MAX_RECENT_FILES] = [
|
||||
"file.openRecent1",
|
||||
"file.openRecent2",
|
||||
"file.openRecent3",
|
||||
|
|
@ -893,8 +911,49 @@ const RECENT_IDS: [&str; 10] = [
|
|||
"file.openRecent8",
|
||||
"file.openRecent9",
|
||||
"file.openRecent10",
|
||||
"file.openRecent11",
|
||||
"file.openRecent12",
|
||||
"file.openRecent13",
|
||||
"file.openRecent14",
|
||||
"file.openRecent15",
|
||||
"file.openRecent16",
|
||||
"file.openRecent17",
|
||||
"file.openRecent18",
|
||||
"file.openRecent19",
|
||||
"file.openRecent20",
|
||||
"file.openRecent21",
|
||||
"file.openRecent22",
|
||||
"file.openRecent23",
|
||||
"file.openRecent24",
|
||||
"file.openRecent25",
|
||||
"file.openRecent26",
|
||||
"file.openRecent27",
|
||||
"file.openRecent28",
|
||||
"file.openRecent29",
|
||||
"file.openRecent30",
|
||||
];
|
||||
|
||||
/// The recent file a `file.openRecentN` slot names (none past the preference's count).
|
||||
fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> {
|
||||
let n: usize = id.strip_prefix("file.openRecent")?.parse().ok()?;
|
||||
io::recent_files(app).get(n.checked_sub(1)?)
|
||||
}
|
||||
|
||||
/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the
|
||||
/// native menu leaves it out).
|
||||
pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool {
|
||||
hidden_when_disabled(id) && !enabled(app, id)
|
||||
}
|
||||
|
||||
/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't
|
||||
/// hide items (the native one) is rebuilt when this changes.
|
||||
pub fn listed_slots(app: &VectorcraftApp) -> usize {
|
||||
use vectorcraft_engine::cmd::{stylelib, swatchlib};
|
||||
let user = |libs: Vec<swatchlib::LibraryInfo>| libs.iter().filter(|l| l.category == "user").count().min(10);
|
||||
let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10));
|
||||
views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session))
|
||||
}
|
||||
|
||||
/// Type → Recent Fonts slots.
|
||||
const RECENT_FONT_IDS: [&str; 10] = [
|
||||
"type.recentFont1",
|
||||
|
|
@ -922,7 +981,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
}
|
||||
match id {
|
||||
// Save is off for a clean document that already has its own file.
|
||||
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.is_dirty()),
|
||||
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
|
||||
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
|
||||
"file.place"
|
||||
| "file.export.svg"
|
||||
| "file.export.png"
|
||||
|
|
@ -934,9 +994,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
| "view.fitArtboard"
|
||||
| "view.fitAll"
|
||||
| "view.actualSize" => app.session.active().is_some(),
|
||||
id if id.starts_with("file.openRecent") => {
|
||||
id["file.openRecent".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_files.len())
|
||||
}
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(),
|
||||
"file.clearRecent" => !app.ui.recent_files.is_empty(),
|
||||
id if id.starts_with("type.recentFont") => {
|
||||
id["type.recentFont".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some()
|
||||
|
|
@ -990,6 +1048,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
v.push(c("Clear Recent Files", "file.clearRecent"));
|
||||
v
|
||||
}),
|
||||
c("Show in Folder", "file.reveal"),
|
||||
Sep,
|
||||
c("Close", "file.close"),
|
||||
c("Close All", "file.closeAll"),
|
||||
|
|
@ -1021,8 +1080,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
sub(
|
||||
"Document Color Mode",
|
||||
vec![
|
||||
cp("CMYK Color", "object.convertDocumentColorMode", json!({"mode": "cmyk"})),
|
||||
cp("RGB Color", "object.convertDocumentColorMode", json!({"mode": "rgb"})),
|
||||
cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})),
|
||||
cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})),
|
||||
],
|
||||
),
|
||||
c("File Info…", "file.info"),
|
||||
|
|
@ -1635,7 +1694,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
|||
}
|
||||
Item::Cmd(label, id, p) => {
|
||||
let en = enabled(app, id);
|
||||
// Unused saved-view slots are hidden (Illustrator lists only the saved views).
|
||||
// Unused saved-view and recent-file slots are hidden (only the real ones are listed).
|
||||
if !en && hidden_when_disabled(id) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ use serde_json::json;
|
|||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, menus};
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette_open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let q = app.ui.palette_query.to_lowercase();
|
||||
let mut items: Vec<(String, String, String)> = vec![]; // (label, kind/id, shortcut)
|
||||
for c in vectorcraft_engine::command_specs() {
|
||||
/// Everything the palette searches: (label, command id or `tool:<id>`, shortcut).
|
||||
pub fn items() -> Vec<(String, String, String)> {
|
||||
let mut items = vec![];
|
||||
// Aliases kept for older scripts run a command that is listed already.
|
||||
for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)) {
|
||||
let path = c.menu.join(" › ");
|
||||
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
|
||||
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
|
||||
|
|
@ -23,6 +20,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
for tool in vectorcraft_tools::catalog::all_tools() {
|
||||
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.palette_open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let q = app.ui.palette_query.to_lowercase();
|
||||
let items = items();
|
||||
let matches: Vec<&(String, String, String)> = items
|
||||
.iter()
|
||||
.filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w)))
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ impl Default for View {
|
|||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// The view a document opens at: the one it was saved with, else fitted on first display.
|
||||
pub fn of(st: &vectorcraft_engine::DocState) -> Self {
|
||||
match &st.view {
|
||||
Some(v) if v.zoom.is_finite() && v.zoom > 0.0 && v.center.x.is_finite() && v.center.y.is_finite() => Self {
|
||||
zoom: v.zoom.clamp(0.0313, 640.0),
|
||||
center: v.center,
|
||||
fitted: true,
|
||||
rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 },
|
||||
},
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Illustrator's preset zoom stops, in percent.
|
||||
pub const ZOOM_STOPS: [f64; 26] = [
|
||||
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
//! The File menu around saving, in the app: Revert's confirmation.
|
||||
//! The File menu around saving, in the app: Revert's confirmation, recent files, Show in Folder,
|
||||
//! the single Document Color Mode entry and documents reopening at their saved view.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Services, VectorcraftApp, dialogs, menus, theme};
|
||||
use crate::{Services, VectorcraftApp, dialogs, io, menus, palette, theme};
|
||||
|
||||
/// An app that reads and writes real files.
|
||||
fn app() -> VectorcraftApp {
|
||||
|
|
@ -85,3 +90,79 @@ fn new_from_template_takes_bytes_from_agents() {
|
|||
assert_eq!((app.session.documents().len(), app.views.len()), (2, 2));
|
||||
assert_eq!(app.session.active().unwrap().path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_show_as_many_as_the_preference_keeps() {
|
||||
let mut app = app();
|
||||
for i in 0..35 {
|
||||
io::note_recent(&mut app, &format!("/art/{i}.svg"));
|
||||
}
|
||||
let listed = |app: &VectorcraftApp| {
|
||||
menus::menu_entries(app).iter().filter(|e| e.command.as_deref().is_some_and(|c| c.starts_with("file.openRecent"))).count()
|
||||
};
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (20, 20), "the default");
|
||||
assert_eq!(menus::dynamic_label(&app, "file.openRecent1", ""), "34.svg");
|
||||
app.session.prefs.recent_files_count = 30;
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (30, 30));
|
||||
assert!(menus::enabled(&app, "file.openRecent30"));
|
||||
app.session.prefs.recent_files_count = 0;
|
||||
assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list");
|
||||
assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them");
|
||||
assert!(app.run("file.openRecent1", json!({})).is_err());
|
||||
app.session.prefs.recent_files_count = 5;
|
||||
assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_in_folder_needs_a_saved_document_and_a_file_manager() {
|
||||
let shown = Rc::new(RefCell::new(vec![]));
|
||||
let s = shown.clone();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!menus::enabled(&app, "file.reveal"));
|
||||
app.session.doc_mut().unwrap().path = Some("/art/a.vectorcraft".into());
|
||||
assert!(!menus::enabled(&app, "file.reveal"), "no file manager (web)");
|
||||
app.services.reveal = Some(Box::new(move |p: &str| {
|
||||
s.borrow_mut().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
assert!(menus::enabled(&app, "file.reveal"));
|
||||
assert_eq!(app.run("file.reveal", json!({})).unwrap()["path"], "/art/a.vectorcraft");
|
||||
assert_eq!(*shown.borrow(), ["/art/a.vectorcraft"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_lists_document_color_mode_once() {
|
||||
let items = palette::items();
|
||||
let ids: Vec<&str> = items.iter().map(|(_, id, _)| id.as_str()).filter(|id| id.to_ascii_lowercase().contains("documentcolormode")).collect();
|
||||
assert_eq!(ids, ["file.documentColorMode"]);
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let modes: Vec<&str> =
|
||||
entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Document Color Mode")).filter_map(|e| e.command.as_deref()).collect();
|
||||
assert_eq!(modes, ["file.documentColorMode", "file.documentColorMode"], "CMYK and RGB run the one command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_reopens_at_its_saved_view() {
|
||||
let d = dir("view");
|
||||
let path = d.join("v.vectorcraft").to_string_lossy().to_string();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
{
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.zoom, v.center, v.rotation, v.fitted) = (3.0, Point::new(70.0, 40.0), 30.0, true);
|
||||
}
|
||||
app.run("file.saveAs", json!({"path": path})).unwrap();
|
||||
// Moving around afterwards isn't an edit.
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
io::open_path(&mut app, &path).unwrap();
|
||||
let v = *app.view().unwrap();
|
||||
assert_eq!((v.zoom, v.center, v.rotation, v.fitted), (3.0, Point::new(70.0, 40.0), 30.0, true));
|
||||
// A document without a saved view is fitted on first display.
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
assert!(!app.view().unwrap().fitted);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
| `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 |
|
||||
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
|
||||
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
|
||||
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
|
||||
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
|
||||
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
|
||||
|
|
@ -284,3 +284,10 @@ lists to the system clipboard, and every Paste command first loads what another
|
|||
`importPdf`, `importText` or `importImage`, centred in the view); an import error answers the Paste with that error
|
||||
and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no
|
||||
Paste event for it).
|
||||
|
||||
File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference
|
||||
lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the
|
||||
system file manager (desktop only; answers `{path}`), and `file.newFromTemplate` without params opens a file picker
|
||||
in the Templates folder (preference `templatesFolder`). Older native files open as "<name> [Converted]" (preference
|
||||
`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved
|
||||
with.
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOp
|
|||
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
|
||||
remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part
|
||||
of the file, so Save asks for a new name instead of writing it back.
|
||||
`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours
|
||||
through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts).
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue