Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor a466625c89 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:54 -06:00
BFlat 73f921fa0a Merge pull request #2947 from Hamza0101/reproducible-export-dates
Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
2026-10-19 00:52:49 +07:00
BFlat fb18533e12 Merge pull request #2677 from dotCooCoo/feat/rotate-view-dial
Rotate View: a dial shows the angle while a drag rotates the view
2026-10-19 00:52:33 +07:00
BFlat 56be31fe9b Merge pull request #2670 from dotCooCoo/fix/joiners-stay-with-their-run
Type: a zero width non-joiner is shaped in the face of the letters around it
2026-10-19 00:52:19 +07:00
BFlat a23d7a827f Merge pull request #2948 from dotCooCoo/fix/open-pages-naming-every-page
PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
2026-10-19 00:52:03 +07:00
Hamza 1c80b23745 Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files
record the wall-clock time they were made in, so the same commands run twice
gave different bytes although the pixels matched (#2880):

  PDF   /CreationDate and /ModDate   D:20261018155816 vs D:20261018155817
  PNG   the `Creation Time` tEXt entry
  .vectorcraft  one byte of the stored date
  SVG   carries no time, and already repeated

`metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it
holds a non-negative whole number of seconds, else the clock, as the
reproducible-builds specification defines it. A date a command is given
(`file.new {created}`, a save's `modified`) still wins over the variable, and a
blank, negative or non-numeric value is ignored so that an export is never
refused over the environment.

`metadata::now_unix` keeps the real clock and now has exactly two callers, both
of which must not be pinnable from the environment: the recovery heartbeat, and
the seed PDF encryption draws on.
2026-10-19 00:37:55 +07:00
bflatastic c9a2491ef7 Rotate View: a dial shows the angle while a drag rotates the view
Squashed from #2677 (its merge of main dropped).
2026-10-19 00:37:53 +07:00
bflatastic 2ae992870b Type: a zero width non-joiner is shaped in the face of the letters around it
Squashed from #2670 (its merge of main dropped).
2026-10-19 00:37:50 +07:00
dotCooCoo cec31c5f4a PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
A .ai saved without PDF compatibility has only a placeholder page in
its PDF part. document.open reads such a file from its editing data,
but with `pages` given it failed with "the file holds only a
placeholder page", even for "1" on a file of one page.

When `pages` names every page of the file, document.open now reads
the editing data as it does without `pages`.

Docs: docs/mcp.md.
2026-10-19 00:37:38 +07:00
BFlat 3b527ca339 Merge pull request #2953 from nrq6993-a11y/stream/vc-feat3-1753-workspace-dock-width
#1753: saved workspaces restore dock panel width
2026-10-19 00:37:00 +07:00
BFlat 4f18f1c860 Merge pull request #2952 from nrq6993-a11y/stream/vc-feat-g1-busy-cursor
G1: show the system busy cursor during background open, save and export
2026-10-19 00:36:46 +07:00
bflatastic b529f356cc Tests: the dock-width test builds its UiState with the width, as clippy asks (#2953) 2026-10-19 00:28:48 +07:00
bflatastic 048875cc4e fmt (#2952) 2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 57ddb71d97 #1753: saved workspaces restore dock panel width
Persist the main dock column width in workspace snapshots and reapply it when switching layouts.

Co-authored-by: Cursor <[email protected]>
2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 49f7f5d7d8 G1: busy cursor during background open, save and export
Co-authored-by: Cursor <[email protected]>
2026-10-19 00:26:27 +07:00
BFlat aa9c70a7b1 Merge pull request #2956 from storytold/fix/zh-hant-param-errors
i18n: Traditional Chinese and Georgian rows for the parameter-check and pdfInfo errors (#2930, #2784, #2914)
2026-10-19 00:23:15 +07:00
35 changed files with 483 additions and 26 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/affinity/fuzz"
- "/crates/plugins/example"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1
View File
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
+36
View File
@@ -82,6 +82,25 @@ impl DocMetadata {
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
/// bytes twice (#2880).
///
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
/// encryption: neither may be pinned by the environment.
pub fn output_time() -> Option<i64> {
stamp_from_env(|n| std::env::var(n).ok()).or_else(now_unix)
}
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
@@ -148,4 +167,21 @@ mod tests {
);
assert!(DocMetadata::default().png_text("").is_empty());
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
// Another variable never stands in for it.
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
}
}
+1 -1
View File
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
if transparent && flat.is_some() {
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
}
let created = vectorcraft_doc::metadata::now_unix();
let created = vectorcraft_doc::metadata::output_time();
let mut renderer = vectorcraft_render::Renderer::new();
for (board, region, origin) in pages {
let rect = vectorcraft_eps::preview_rect(region, origin);
+1 -1
View File
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
if boards.len() > 1 {
enc.warnings.push(SEPARATE.to_string());
}
let created = vectorcraft_doc::metadata::now_unix();
let created = vectorcraft_doc::metadata::output_time();
let failed = vectorcraft_render::decode_failures();
for b in boards {
let one = super::save::artboard_doc(doc, b);
+10 -2
View File
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
)
}
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
let Some(range) = &opts.pages else { return true };
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
}
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
}
// Saved without its PDF part: the editing copy is all the file has.
// Saved without its PDF part: the editing copy is all the file has (`pages` that
// name every page read it as the whole file).
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
let whole = LoadOptions { pages: None, ..opts.clone() };
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
}
Err(e) => Err(e),
})?
+1 -1
View File
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
}
+1 -1
View File
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
@@ -229,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
for pages in [json!("1"), json!(1), json!("1-")] {
let mut s = Session::new();
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
check_sample(&s);
}
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
+1 -1
View File
@@ -500,7 +500,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
s.note_journal(key, serde_json::json!(t));
t
}
+1 -1
View File
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
fn dos_time() -> (u16, u16) {
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
+2 -2
View File
@@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified:
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
let before = vectorcraft_doc::metadata::now_unix().unwrap();
let before = vectorcraft_doc::metadata::output_time().unwrap();
let out = f();
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
(out, before..=vectorcraft_doc::metadata::output_time().unwrap())
}
#[test]
+17
View File
@@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() {
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
}
/// A save records the date the document was made in, so the same commands twice gave different
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
#[test]
fn a_pinned_date_makes_saves_byte_identical() {
let once = |t: i64| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
saved(&mut s, json!({}))
};
let at = 1_791_208_980;
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
// The date really is in the bytes, so the comparison above is not vacuous.
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
}
+1 -1
View File
@@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
+1 -1
View File
@@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
let set = &opts.settings;
let job = Job::new(doc, set, opts.postscript)?;
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let created = opts.created.or_else(vectorcraft_doc::metadata::output_time);
let sep = Separator::new(&job.doc, set);
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let marks = set.marks.printer_marks();
+2 -2
View File
@@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() {
fn the_native_documents_modification_date_is_the_time_of_writing() {
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
let before = year(vectorcraft_doc::metadata::now_unix());
let before = year(vectorcraft_doc::metadata::output_time());
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
let after = year(vectorcraft_doc::metadata::now_unix());
let after = year(vectorcraft_doc::metadata::output_time());
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
}
+1 -1
View File
@@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
});
out
};
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
let now = || vectorcraft_doc::metadata::output_time().unwrap();
// Both print the minute they are written in: try again when the minute turns between them.
for _ in 0..3 {
let minute = now() / 60;
+2
View File
@@ -557,6 +557,8 @@ mod tests_fit;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_joiners;
#[cfg(test)]
mod tests_justify;
#[cfg(test)]
mod tests_scripts;
+3 -2
View File
@@ -392,7 +392,8 @@ pub(crate) fn shape_range(
let next_script = shaping_script(c);
let strong_script = !matches!(next_script, Script::Common | Script::Inherited);
let script_change = strong_script && script != Script::Common && script != next_script;
// Combining marks stay with their base.
// Combining marks, zero width joiners and non-joiners, and variation selectors stay
// with the character before them.
if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) {
if i > seg.range.start {
seg.range.end = i;
@@ -580,7 +581,7 @@ struct Shaped {
fn is_mark(c: char) -> bool {
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|| matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
}
/// Shape `seg` and append its glyphs to `out`. Each glyph takes the tracking, kerning, baseline
+93
View File
@@ -0,0 +1,93 @@
//! A zero width joiner or non-joiner between characters that the style's font lacks is shaped with
//! those characters, in their fallback face. The Unicode Standard describes both joiners under
//! Cursive Connection and Ligatures
//! (https://www.unicode.org/versions/latest/core-spec/chapter-23/), and Microsoft's OpenType shaping
//! specifications for Devanagari and Bengali include them in a syllable after a halant
//! (https://learn.microsoft.com/en-us/typography/script-development/devanagari,
//! https://learn.microsoft.com/en-us/typography/script-development/bengali).
//! No font is committed: the tests use installed faces and print SKIPPED when none is found.
use super::*;
use kurbo::Point;
use std::sync::Arc;
use vectorcraft_doc::CharStyle;
const ZWNJ: char = '\u{200C}';
const ZWJ: char = '\u{200D}';
/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`.
/// With `tags`, the fallback face's GSUB script list must contain one of those script tags. Which
/// face the fallback gives depends on the faces loaded so far, so the tests compare with that face.
fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc<FontFace>, Arc<FontFace>)> {
let db = FontDb::global();
let opentype = |face: &FontFace| {
use skrifa::raw::TableProvider;
tags.is_empty()
|| face
.skrifa()
.and_then(|f| f.gsub().ok())
.and_then(|gsub| gsub.script_list().ok())
.is_some_and(|list| list.script_records().iter().any(|r| tags.contains(&&r.script_tag().to_be_bytes())))
};
let faces = ["Source Sans 3", "Helvetica", "Arial", "Times New Roman", "Liberation Sans", "DejaVu Sans"]
.into_iter()
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
.filter(|primary| primary.covers(ZWNJ) && primary.covers(ZWJ) && !primary.covers(c))
.find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| opentype(f)).map(|fallback| (primary, fallback)));
if let Some((primary, fallback)) = &faces {
let path = fallback.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string());
eprintln!("ASSERTIONS RUN: {test}; {} {} with the fallback {} {} ({path})", primary.family, primary.style, fallback.family, fallback.style);
} else {
eprintln!("SKIPPED: {test}; no installed Latin face maps both joiners and falls back to a face for {c} with the lookups it needs");
}
faces
}
fn layout_in(face: &FontFace, text: &str, tracking: f64) -> TextLayout {
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, tracking, ..CharStyle::default() };
layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st))
}
/// `text` set in `primary` is drawn, spaced and stepped through exactly as `text` set in `fallback`.
fn assert_shaped_as_in_the_fallback(primary: &FontFace, fallback: &FontFace, text: &str) {
for tracking in [0.0, 200.0] {
let (mixed, single) = (layout_in(primary, text, tracking), layout_in(fallback, text, tracking));
// The caret stops where it stops in the fallback face.
assert_eq!(caret_horizontal(&mixed, 0, true), caret_horizontal(&single, 0, true), "{text:?} with tracking {tracking}");
// Tracking adds no extra space around the joiner.
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
assert_eq!(width(&mixed), width(&single), "{text:?} with tracking {tracking}");
let glyphs = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.len, g.origin, g.advance)).collect::<Vec<_>>();
assert_eq!(glyphs(&mixed), glyphs(&single), "{text:?} with tracking {tracking}");
assert!(mixed.glyphs.iter().all(|g| g.font_id == fallback.id()), "{text:?}: {:?}", mixed.glyphs);
}
}
#[test]
fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() {
let test = "a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it";
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[]) else { return };
// Ka + halant + ZWNJ + Ssa: Ka keeps its explicit halant instead of forming the KaSsa conjunct.
let s = format!("क्{ZWNJ}ष");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
}
#[test]
fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() {
let test = "a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it";
let Some((primary, fallback)) = joiner_test_faces(test, 'ক', &[]) else { return };
let s = format!("ক্{ZWNJ}ষ");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
}
#[test]
fn a_zero_width_joiner_in_devanagari_asks_for_a_half_form() {
let test = "a_zero_width_joiner_in_devanagari_asks_for_a_half_form";
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[b"dev2", b"deva"]) else { return };
// Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa.
let s = format!("क्{ZWJ}ष");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
let l = layout_in(&primary, &s, 0.0);
let half = l.glyphs.first().map(|g| g.gid);
assert!(half.is_some_and(|gid| gid != fallback.glyph_for('क') && gid != fallback.glyph_for('\u{094D}')), "no half form: {:?}", l.glyphs);
assert_eq!(l.glyphs.last().map(|g| g.gid), Some(fallback.glyph_for('ष')), "{:?}", l.glyphs);
}
+10
View File
@@ -392,6 +392,16 @@ shown_when = [
hotspot = "The centre."
designer = false
[[cursor]]
id = "Busy"
group = "Navigation"
shown_when = [
"A file is opening off the UI thread.",
"A background save or export is in progress (the status bar shows its name).",
]
hotspot = "The centre."
designer = false
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
[[cursor]]
+2
View File
@@ -494,6 +494,8 @@ pub enum Cursor {
ZoomOut,
Eyedropper,
NotAllowed,
/// A long operation (opening a file, a background save or export): the OS wait cursor.
Busy,
/// Over the gradient annotator's bar: a click adds a stop.
AddStop,
/// A gradient stop dragged off the bar: releasing deletes it.
+84
View File
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
use crate::opening;
use crate::state::View;
use crate::theme::{self, Tokens};
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
@@ -518,6 +519,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ime_output(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
// at the canvas center, the point the view turns about.
if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::RotateView { .. })) {
rotate_view_dial(&painter, rect.center(), v.rotation, &t);
}
if app.ui.view.rulers && app.ui.screen_mode < 3 {
if let Some(origin) = origin_preview {
@@ -536,6 +542,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
artboard_name_editor(app, ui, &xf);
// Cursor.
if resp.hovered() {
if opening::long_operation_busy(app) {
ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy));
return;
}
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
@@ -638,6 +648,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
Cursor::Busy => C::Progress,
Cursor::AddStop => C::Copy,
Cursor::RemoveStop => C::NotAllowed,
Cursor::Slice => C::Crosshair,
@@ -2293,6 +2304,26 @@ fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
}
/// The radius of the Rotate View dial, in screen points.
const DIAL_RADIUS: f32 = 40.0;
/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a
/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the
/// angle under the center, rounded to whole degrees as in the status bar.
fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) {
// `dir(deg)` is the screen direction `deg` degrees clockwise from straight up.
let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos());
p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border));
for i in 0..24u8 {
let len = if i % 6 == 0 { 8.0 } else { 4.0 };
let d = dir(f32::from(i) * 15.0);
p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim));
}
p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent));
p.circle_filled(c, 2.5, t.accent);
p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text);
}
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
@@ -6120,6 +6151,59 @@ mod tests {
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
}
#[test]
fn a_rotate_view_drag_shows_a_dial_with_the_angle() {
use egui::{Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("rotateView");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let c = app.canvas_rect.unwrap().center();
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
// `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's
// direction in whole degrees clockwise from straight up, and the angle text.
let dial = |app: &mut VectorcraftApp| {
let s = shapes(app, &ctx);
let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None });
let needle = s.iter().find_map(|s| match s {
Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => {
Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32)
}
_ => None,
});
let text =
s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None });
(circle, needle, text)
};
assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag");
// The Rotate View tool: a drag from 0° to 30° about the canvas center.
let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0));
frame(&mut app, &ctx, vec![Event::PointerMoved(right)]);
frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]);
assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string())));
frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]);
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view());
// A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°.
app.select_tool("rectangle");
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]);
frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(below)]);
assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string())));
assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view());
// Presentation Mode turned on during the drag hides the dial.
let mode = app.ui.screen_mode;
app.ui.screen_mode = 3;
assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode");
app.ui.screen_mode = mode;
frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]);
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]);
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
}
#[test]
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
use egui::{Event, Modifiers};
+1
View File
@@ -797,6 +797,7 @@ mod tests {
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
}
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
assert!(rasterize(Cursor::Busy, 1.0).is_none());
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
+54 -3
View File
@@ -16,6 +16,55 @@ const ICON_COL: f32 = 38.0;
const HEADER: f32 = 14.0;
/// The tabbed group's width until the dock has been laid out (its default size).
const DOCK_WIDTH: f32 = 300.0;
pub(crate) const LEGACY_DOCK_PANEL: &str = "dock";
pub(crate) const SHARED_DOCK_PANEL: &str = "shared-panel-dock";
const LEGACY_DOCK_MIN: f32 = 230.0;
const LEGACY_DOCK_MAX: f32 = 520.0;
const SHARED_DOCK_MIN: f32 = 220.0;
const SHARED_DOCK_MAX: f32 = 720.0;
pub(crate) fn clamp_legacy_dock_width(width: f32) -> f32 {
width.clamp(LEGACY_DOCK_MIN, LEGACY_DOCK_MAX)
}
pub(crate) fn clamp_shared_dock_width(width: f32) -> f32 {
width.clamp(SHARED_DOCK_MIN, SHARED_DOCK_MAX)
}
fn seed_dock_panel_width(ctx: &egui::Context, panel: &str, width: f32) {
let screen = ctx.content_rect();
let w = width.max(1.0);
let outer_rect = egui::Rect::from_min_size(egui::pos2(screen.right() - w, screen.top()), egui::vec2(w, screen.height()));
let state = egui::containers::panel::PanelState { outer_rect };
ctx.data_mut(|data| data.insert_persisted(egui::Id::new(panel), state));
}
pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str, clamp: fn(f32) -> f32) {
if !app.ui.dock_width_pending {
return;
}
if let Some(width) = app.ui.dock_width.filter(|w| w.is_finite() && *w > 0.0) {
seed_dock_panel_width(ctx, panel, clamp(width));
}
app.ui.dock_width_pending = false;
}
pub(crate) fn note_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str) {
if let Some(state) = egui::containers::panel::PanelState::load(ctx, egui::Id::new(panel)) {
let width = state.size().x;
if width.is_finite() && width > 0.0 {
app.ui.dock_width = Some(width);
}
}
}
pub(crate) fn legacy_dock_default_width(ui: &crate::state::UiState) -> f32 {
ui.dock_width.filter(|w| w.is_finite()).map(clamp_legacy_dock_width).unwrap_or(DOCK_WIDTH)
}
pub(crate) fn shared_dock_default_width(ui: &crate::state::UiState) -> f32 {
ui.dock_width.filter(|w| w.is_finite()).map(clamp_shared_dock_width).unwrap_or(310.0)
}
/// Top of a popped-out panel.
const FLYOUT_TOP: f32 = 110.0;
@@ -81,14 +130,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width);
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| {
egui::Panel::right("dock")
egui::Panel::right(LEGACY_DOCK_PANEL)
.resizable(true)
.default_size(DOCK_WIDTH)
.size_range(230.0..=520.0)
.default_size(legacy_dock_default_width(&app.ui))
.size_range(LEGACY_DOCK_MIN..=LEGACY_DOCK_MAX)
.frame(egui::Frame::NONE)
.show(ui, |ui| {
crate::theme::card(ui, &t, egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }, |ui| {
@@ -114,6 +164,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
.response
.rect
});
note_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL);
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
// collapsed group's panels head it, under the « that expands them again. Floating panels leave
// it.
+3
View File
@@ -1471,6 +1471,9 @@ impl VectorcraftApp {
scrub::end_frame(self, &ctx);
native_menu::sync(self, &ctx);
self.drop_formats_after_egui_copy(&ctx);
if opening::long_operation_busy(self) {
ctx.set_cursor_icon(egui::CursorIcon::Progress);
}
self.perf.frame_ms = now_ms() - t0;
let _ = json!(null);
}
+43
View File
@@ -28,6 +28,12 @@ pub struct Opening {
pub pending: Vec<Pending>,
}
/// Is a long operation running that should show the busy cursor and status spinner (background
/// save/export with a label, or a file opening off the UI thread)?
pub fn long_operation_busy(app: &VectorcraftApp) -> bool {
app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled()) || !app.opening.pending.is_empty()
}
/// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look
/// at the document right after asking for it), or with `VECTORCRAFT_SYNC_OPEN` set.
pub fn available() -> bool {
@@ -114,6 +120,43 @@ mod tests {
use super::*;
use crate::Services;
#[test]
fn long_operation_busy_while_a_file_opens_or_a_background_save_runs() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
assert!(!long_operation_busy(&app));
start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false)).unwrap();
assert!(long_operation_busy(&app));
let t0 = std::time::Instant::now();
while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
assert!(!long_operation_busy(&app));
let (release, held) = std::sync::mpsc::channel::<()>();
let services = Services { write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
app = VectorcraftApp::new(Session::new(), services);
crate::background::run(
&mut app,
true,
"Saving test.vectorcraft".into(),
None,
move |_| {
let _ = held.recv_timeout(std::time::Duration::from_secs(10));
Ok(json!({}))
},
|_, r| r,
)
.unwrap();
assert!(long_operation_busy(&app));
let _ = release.send(());
let t0 = std::time::Instant::now();
while long_operation_busy(&app) && t0.elapsed().as_secs() < 30 {
crate::background::poll(&mut app);
std::thread::yield_now();
}
assert!(!long_operation_busy(&app));
}
/// #1694: a file that finishes opening while a dialog is up waits for it to close, so the
/// dialog's document stays the active one.
#[test]
+8 -1
View File
@@ -651,6 +651,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
Some((layer, layout_rect, fill))
})
.collect();
crate::dock::apply_pending_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width);
let shared_dock_default = crate::dock::shared_dock_default_width(&app.ui);
let mut draw = |ui: &mut egui::Ui| {
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
@@ -717,10 +719,15 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
output
};
let output = if layout.root.is_some() {
let panel = egui::Panel::right("shared-panel-dock").default_size(310.0).size_range(220.0..=720.0).resizable(true).frame(egui::Frame::NONE);
let panel = egui::Panel::right(crate::dock::SHARED_DOCK_PANEL)
.default_size(shared_dock_default)
.size_range(220.0..=720.0)
.resizable(true)
.frame(egui::Frame::NONE);
let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel };
let shown = panel.show(ui, |ui| crate::theme::card(ui, &t, egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }, &mut draw));
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect));
crate::dock::note_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL);
shown.inner
} else {
draw(ui)
+8
View File
@@ -354,6 +354,12 @@ pub struct UiState {
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// The main dock column's outer width in screen points (legacy or shared docking).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dock_width: Option<f32>,
/// Next frame should seed egui [`PanelState`] from [`Self::dock_width`] (workspace switch).
#[serde(skip)]
pub dock_width_pending: bool,
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
/// docked at the window's left edge.
#[serde(default)]
@@ -772,6 +778,8 @@ impl Default for UiState {
docking_hidden: Default::default(),
docking_icons: Default::default(),
docking_generation: 0,
dock_width: None,
dock_width_pending: false,
toolbar_pos: None,
status_bar: true,
dock: true,
+2 -2
View File
@@ -398,13 +398,13 @@ fn the_worker_makes_the_formats_the_session_makes() {
// to another second since the Copy, they are compared again after another Copy.
let check = |app: &mut VectorcraftApp, what: &str, mimes: &[&str]| {
for _ in 0..100 {
let second = vectorcraft_doc::metadata::now_unix();
let second = vectorcraft_doc::metadata::output_time();
run(app, "edit.copy", json!({}));
assert_eq!(app.background.jobs.len(), 1, "{what}: made on the worker");
background::wait_all(app);
assert_eq!(written(&board), mimes, "{what}");
let session = app.session.clipboard_flavours();
let dated = mimes.contains(&PDF) && vectorcraft_doc::metadata::now_unix() != second;
let dated = mimes.contains(&PDF) && vectorcraft_doc::metadata::output_time() != second;
if board.borrow().flavours == session || !dated {
assert_eq!(board.borrow().flavours, session, "{what}");
return;
+23
View File
@@ -41,6 +41,9 @@ pub struct Workspace {
pub docking_icons: std::collections::BTreeSet<String>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
/// Main dock column width in screen points (#1753).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dock_width: Option<f32>,
}
impl Default for Workspace {
@@ -140,6 +143,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
docking_hidden: ui.docking_hidden.clone(),
docking_icons: ui.docking_icons.clone(),
toolbar_pos: ui.toolbar_pos,
dock_width: ui.dock_width,
}
}
@@ -165,6 +169,8 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.docking_icons =
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
ui.toolbar_pos = w.toolbar_pos;
ui.dock_width = w.dock_width;
ui.dock_width_pending = true;
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
ui.flyout = None;
ui.workspace = w.name.clone();
@@ -491,4 +497,21 @@ mod tests {
assert!(find(&ui, n).is_some(), "{n}");
}
}
#[test]
fn saved_workspaces_restore_dock_width() {
let mut ui = UiState { dock_width: Some(420.0), ..Default::default() };
save_as(&mut ui, "Wide").unwrap();
ui.dock_width = Some(260.0);
save_as(&mut ui, "Narrow").unwrap();
let wide = find(&ui, "Wide").unwrap();
apply(&mut ui, &wide);
assert_eq!(ui.dock_width, Some(420.0));
assert!(ui.dock_width_pending);
let narrow = find(&ui, "Narrow").unwrap();
apply(&mut ui, &narrow);
assert_eq!(ui.dock_width, Some(260.0));
let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
assert_eq!(back.custom_workspaces[1].dock_width, Some(260.0));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
OS cursor display and Caps Lock remain unverified.
- **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement.
- **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage.
- **Missing:** a busy cursor while a long operation runs (a big file opening, a heavy effect): VectorCraft has no busy state to show it yet. Once it has one, its `Cursor` value goes in the cursor list (`crates/tools/src/cursors.toml`).
- **Done:** a busy cursor while a file opens off the UI thread or a labelled background save or export runs (`Cursor::Busy` in `crates/tools/src/cursors.toml`). Heavy synchronous effect previews remain open.
- **Missing:** Average and Join in one step, with a corner or smooth choice for the joined anchor. `path.average` and `path.join` are separate commands and Join has no such choice (coinciding anchors merge into one since #1533). [ui-parity.md](ui-parity.md) listed "Join with corner/smooth choice" as done until 2026-10-13. About 1–2 h (estimated), inside the estimate below.
- **Implemented (#1760):** Effect › Warp has a shared style selector with original previews, orientation buttons and percent sliders. Existing effects retain their stack slot and target. Focused checks passed and original captures were reviewed; optional Properties integration and remembered defaults remain outside this change.
- **Done (#2120):** Smart Guides line extension: dragging the end of a straight segment with Direct Selection or Curvature, the point snaps to the segment's own direction at any angle and the guide runs along it from the other end. Inside the estimate below.
+21 -1
View File
@@ -666,7 +666,8 @@ page) opens from its editing data alone: its type is made from the file's text d
area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed
PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer,
as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning.
Without editing data it says it can't be opened.
Without editing data it says it can't be opened. `pages` that name every page of such a file also open it from its
editing data.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
@@ -820,6 +821,25 @@ Not remembered: Color Settings, the document, anything else. Nothing is read or
desktop app starts, the presets it kept in its preferences file before this folder existed are moved into it; after
that the folder is the one place they live, so a preset deleted by `vectorcraft-cli` is gone in the app too.
### Reproducible output
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files record the time they were
made in (PDF `CreationDate` and `ModDate`, the PNG `Creation Time` entry, a zip entry's date, a
document's `created` and `modified`), so the same commands run twice gave different bytes though the
pixels matched (#2880). Setting **`SOURCE_DATE_EPOCH`** to a non-negative whole number of seconds
since the epoch records that instant instead of the clock, and the bytes repeat
(<https://reproducible-builds.org/specs/source-date-epoch/>):
```sh
SOURCE_DATE_EPOCH=1791208980 vectorcraft-cli run --cmd file.new --params '{}' --export out.pdf
```
A command that takes its own date (`file.new {created}`, a save's `modified`) still wins over the
variable. A blank, negative or non-numeric value is ignored and the clock decides, so an export is
never refused over the environment. The variable does not reach the recovery heartbeat or the
random seed PDF encryption draws on, which must follow the real clock. SVG output carries no time
and was already reproducible.
### Parameter keys
`command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check
+1
View File
@@ -551,6 +551,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
While a drag rotates the view, a dial at the center of the canvas shows the angle.
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation).
While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854).
Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides.
+1 -1
View File
@@ -55,7 +55,7 @@ tools, never checked side by side) isn't captured row by row.
| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge |
| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it |
| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed |
| Busy cursor during long operations | missing | VectorCraft has no busy state yet (gap G1) |
| Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) |
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
| Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |