Merge main into feat/plugin-filter-inputs

This commit is contained in:
dotCooCoo
2026-11-08 19:34:12 -08:00
74 changed files with 2260 additions and 247 deletions
Generated
+1 -14
View File
@@ -1059,7 +1059,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e"
dependencies = [
"chrono",
"dunce",
@@ -8223,7 +8223,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -8238,18 +8237,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
+1 -1
View File
@@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
+3
View File
@@ -92,6 +92,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
// A ready update installs once the process has exited. The macOS exit below skips
// dropping the app, so the helper is started here.
self.app.start_pending_update();
end_before_teardown(self.frames);
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ use titlebar::opted_into_system_title_bar;
#[cfg(windows)]
use titlebar::set_taskbar_icon;
#[cfg(all(test, feature = "wgpu"))]
use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame};
use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies};
use titlebar::{custom_titlebar, native_options, titlebar_from_env};
struct App {
+73
View File
@@ -245,6 +245,79 @@ fn the_start_frame_applies_the_app_title_bar_preference() {
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
}
/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and
/// on. The saved preference still decides while the variable is unset.
#[test]
fn the_titlebar_variable_takes_precedence_over_system_title_bar() {
use craft_window::Frame;
assert!(super::system_title_bar_applies(true, false));
assert!(!super::system_title_bar_applies(true, true));
assert!(!super::system_title_bar_applies(false, false));
assert!(!super::system_title_bar_applies(false, true));
// VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar.
let frame = super::start_frame(Frame::Desktop, Some(true), false, false);
assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos"));
// VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false);
assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true)));
}
}
/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on.
/// The variable is process-wide and read once, so each value runs in a separate test process.
#[test]
fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD";
if std::env::var_os(CHILD).is_none() {
for value in ["app", "system", "desktop", "unknown"] {
// Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a
// copy of a GPU start test's locked start file (see `CHILD_SPAWN`).
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
let spawning = super::spawning();
let child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"])
.env(CHILD, "1")
.env("VECTORCRAFT_TITLEBAR", value)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
drop(spawning);
let output = child.unwrap().wait_with_output().unwrap();
assert!(
output.status.success(),
"VECTORCRAFT_TITLEBAR={value}\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
}
let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap();
let not_read = cfg!(any(target_os = "macos", target_os = "windows"));
assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] {
let custom = super::custom_titlebar(saved);
let expected = match value.as_str() {
_ if not_read => !cfg!(target_os = "macos") && !opted_into_system,
"app" => true,
"system" | "desktop" => false,
// Not a value the variable takes: System Title Bar decides when it is on. Otherwise the
// `craft-window` probe decides, the same with System Title Bar off as with no saved
// preferences.
_ if opted_into_system => false,
_ => super::custom_titlebar(None),
};
assert_eq!(custom, expected, "{value}, preference {opted_into_system}");
assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}");
}
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
+15 -6
View File
@@ -41,7 +41,8 @@ pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
/// `false` (the default). This preference forces the desktop/native frame back on unless
/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]).
pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
@@ -87,12 +88,18 @@ fn probed_choice() -> &'static craft_window::Choice {
}
/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences ›
/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always
/// `false` on macOS and Windows.
/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while
/// it is set. Always `false` on macOS and Windows.
pub(crate) fn titlebar_from_env() -> bool {
cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some()
}
/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR`
/// is set (`from_env`), which chooses the frame for the run it is set in.
pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool {
opted_into_system && !from_env
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
@@ -102,7 +109,8 @@ pub(crate) fn titlebar_from_env() -> bool {
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set
/// ([`system_title_bar_applies`]).
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
@@ -124,7 +132,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
/// Title Bar preference, which does not apply while the variable is set. Call once at startup,
/// before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
@@ -133,7 +142,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = probed_choice();
let opted_into_system = opted_into_system_title_bar(saved);
let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env());
// macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from`
// ignores the frame there).
let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved);
+42 -30
View File
@@ -143,38 +143,11 @@ impl Color {
}
/// HSB with hue in degrees 0..360, saturation and brightness 0..1.
pub fn to_hsb(&self) -> [f32; 3] {
let [r, g, b] = self.to_rgb();
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
rgb_to_hsb(self.to_rgb())
}
pub fn from_hsb(h: f32, s: f32, v: f32) -> Self {
let h = h.rem_euclid(360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
Color::Rgb { r: r + m, g: g + m, b: b + m }
let [r, g, b] = hsb_to_rgb([h, s, v]);
Color::Rgb { r, g, b }
}
/// `#rrggbb` (display RGB).
pub fn to_hex(&self) -> String {
@@ -327,6 +300,45 @@ impl Color {
}
}
/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color
/// conversion ([`Color::to_hsb`] reads the display color first).
pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
}
/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse
/// of [`rgb_to_hsb`].
pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
let h = h.rem_euclid(360.0) / 60.0;
let c = v * s;
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
let (r, g, b) = match h as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
[r + m, g + m, b + m]
}
/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations
/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from.
pub fn keep_model(orig: Color, new: Color) -> Color {
+4 -3
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Preview",
[],
None,
"{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
"{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page; with copies per sheet, the box around every copy's), copies?: [[x0, y0, x1, y1]…] (copies per sheet: each copy's artboard on the page, across then down)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
has_doc,
preview
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print",
[],
Some("Cmd+P"),
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy; with copiesPerSheet, a sheet holds as many copies of an artboard as fit) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
has_doc,
print
),
@@ -228,6 +228,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
view: sh.view,
window: sh.window,
place: sh.place,
copies: &sh.copies,
area: sh.area,
marks: sh.marks.as_ref(),
negative: sh.negative,
+128 -33
View File
@@ -32,7 +32,8 @@ use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::SystemTime;
use vectorcraft_pdf::{PdfPreset, builtin_preset};
use vectorcraft_plugins::safe_mode;
@@ -79,9 +80,9 @@ pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2;
/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose
/// each other's settings.
static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(());
/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder
/// until the plug-in is removed, which sets it and drops it.
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());
/// The state of each plug-in folder's storage, shared by every storage made for the folder until
/// the plug-in is removed, which closes it and drops it.
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<PluginData>>> = Mutex::new(BTreeMap::new());
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
/// file system, then a hash of the name, so that names that differ only in characters a file name
@@ -499,7 +500,7 @@ impl Session {
/// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it.
/// The folder is first renamed out of the way (`<folder>.removed`), and that fails, leaving it
/// as it was, when the rename does; what the deletion then leaves stays under that name, with a
/// log record, and is deleted at the plug-in's next removal.
/// log record. A later call for the id tries to delete it again.
pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> {
vectorcraft_plugins::registry::set_storage(id, None);
let Some(dir) = &self.plugin_dir else { return Ok(()) };
@@ -508,8 +509,8 @@ impl Session {
// Every storage handed out for the folder, including those runs still hold, is closed once
// the folder is out of the way; until then they keep working.
let close = || {
if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) {
closed.store(true, Ordering::Release);
if let Some(data) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) {
data.closed.store(true, Ordering::Release);
}
};
// A `storage` folder that is a link leads out of the store: nothing there is deleted. A link
@@ -568,22 +569,67 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf {
fn plugin_storage(dir: Option<&Path>, id: &str) -> Option<Arc<dyn Storage>> {
let dir = dir?;
let data = plugin_data_dir(dir, id);
let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone();
Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed }))
let state = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone();
Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, state }))
}
/// A file's size and modified time (`None`: there is no file).
type FileStamp = Option<(u64, SystemTime)>;
/// The stamp of the file at `path`.
fn file_stamp(path: &Path) -> Result<FileStamp, StorageError> {
match std::fs::metadata(path) {
Ok(m) => Ok(Some((m.len(), m.modified().map_err(|e| io(&e))?))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(io(&e)),
}
}
/// The modified time of the folder at `dir` (`None`: it can't be read, or there is no folder).
fn folder_stamp(dir: &Path) -> Option<SystemTime> {
std::fs::symlink_metadata(dir).ok()?.modified().ok()
}
/// What every storage made for one plug-in folder shares ([`PLUGIN_DATA_OPEN`]).
#[derive(Default)]
struct PluginData {
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed.
closed: AtomicBool,
kept: Mutex<Kept>,
}
/// What the folder's files last held in this process.
#[derive(Default)]
struct Kept {
settings: Option<KeptSettings>,
cache: Option<KeptCache>,
}
/// The settings by key, and the settings file's stamp when they were read or written.
struct KeptSettings {
stamp: FileStamp,
values: BTreeMap<String, Vec<u8>>,
}
/// The bytes of the cache entries, and the cache folder's stamp when they were added up.
#[derive(Clone, Copy)]
struct KeptCache {
stamp: Option<SystemTime>,
bytes: u64,
}
/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs).
/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as
/// [`case_safe`] writes it, so that names differing only in case get different files. A link
/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a
/// plain file.
/// plain file. The settings stay in memory ([`Kept`]) while the settings file keeps its size and
/// modified time, and every change is written to the file before the call returns. The total
/// `cache_bytes` reports stays in memory while the cache folder keeps its modified time.
pub(crate) struct PluginStorage {
/// The plug-in store, and the plug-in's folder in it.
root: PathBuf,
dir: PathBuf,
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the
/// folder ([`PLUGIN_DATA_OPEN`]).
closed: Arc<AtomicBool>,
state: Arc<PluginData>,
}
/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system.
@@ -657,17 +703,29 @@ impl PluginStorage {
Ok(())
}
/// The settings, by key. A file that is too large or doesn't parse is moved aside
/// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the
/// file's size before it is read.
fn read_settings(&self, meter: &mut Meter) -> Result<BTreeMap<String, Vec<u8>>, StorageError> {
fn kept(&self) -> MutexGuard<'_, Kept> {
self.state.kept.lock().unwrap_or_else(|e| e.into_inner())
}
/// The settings: those in `kept` when the settings file still has their stamp, else the file's,
/// which then replace them.
fn read_settings<'a>(&self, kept: &'a mut Option<KeptSettings>, meter: &mut Meter) -> Result<&'a mut KeptSettings, StorageError> {
let path = self.settings_path();
self.no_links(&path)?;
let size = match std::fs::metadata(&path) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(io(&e)),
let stamp = file_stamp(&path)?;
let held = match kept.take() {
Some(held) if held.stamp == stamp => held,
_ => self.load_settings(stamp, meter)?,
};
Ok(kept.insert(held))
}
/// Reads the settings file, whose stamp is `stamp`. A file that is too large or doesn't parse is
/// moved aside ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is
/// charged the file's size before it is read.
fn load_settings(&self, stamp: FileStamp, meter: &mut Meter) -> Result<KeptSettings, StorageError> {
let path = self.settings_path();
let Some((size, _)) = stamp else { return Ok(KeptSettings { stamp, values: BTreeMap::new() }) };
let readable = size <= storage::MAX_SETTINGS_BYTES as u64;
if readable {
meter.take(size)?;
@@ -683,12 +741,30 @@ impl PluginStorage {
settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES))
});
match parsed {
Some(settings) => Ok(settings),
Some(values) => Ok(KeptSettings { stamp, values }),
None => {
let aside = self.dir.join(BAD_SETTINGS_FILE);
log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display());
std::fs::rename(&path, &aside).map_err(|e| io(&e))?;
Ok(BTreeMap::new())
Ok(KeptSettings { stamp: None, values: BTreeMap::new() })
}
}
}
/// Writes the settings in `kept` to the file and records the file's new stamp. When that fails,
/// or settings were written and the file is gone, `kept` is emptied, so the next call reads the
/// file again.
fn save_settings(&self, kept: &mut Option<KeptSettings>, meter: &mut Meter) -> Result<(), StorageError> {
let Some(held) = kept.as_mut() else { return Ok(()) };
let written = self.write_settings(&held.values, meter);
match (written, file_stamp(&self.settings_path())) {
(Ok(()), Ok(stamp)) if stamp.is_some() || held.values.is_empty() => {
held.stamp = stamp;
Ok(())
}
(written, _) => {
*kept = None;
written
}
}
}
@@ -716,7 +792,7 @@ impl PluginStorage {
/// Fails with [`StorageError::Unavailable`] once the storage is closed.
fn open(&self) -> Result<(), StorageError> {
if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) }
if self.state.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) }
}
/// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which
@@ -753,28 +829,33 @@ impl Storage for PluginStorage {
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
Ok(self.read_settings(meter)?.remove(key))
let mut kept = self.kept();
Ok(self.read_settings(&mut kept.settings, meter)?.values.get(key).cloned())
}
fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
let mut kept = self.kept();
let settings = &mut self.read_settings(&mut kept.settings, meter)?.values;
if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) {
return Err(StorageError::Full);
}
if settings.get(key).is_some_and(|old| old == value) {
return Ok(());
}
settings.insert(key.to_string(), value.to_vec());
self.write_settings(&settings, meter)
self.save_settings(&mut kept.settings, meter)
}
fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result<bool, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
if settings.remove(key).is_none() {
let mut kept = self.kept();
if self.read_settings(&mut kept.settings, meter)?.values.remove(key).is_none() {
return Ok(false);
}
self.write_settings(&settings, meter).map(|()| true)
self.save_settings(&mut kept.settings, meter).map(|()| true)
}
fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
@@ -808,7 +889,10 @@ impl Storage for PluginStorage {
meter.take(value.len() as u64)?;
std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?;
self.no_links(&path)?;
write_atomic(&path, value).map_err(|e| io(&e))
write_atomic(&path, value).map_err(|e| io(&e))?;
let stamp = folder_stamp(&self.cache_dir());
self.kept().cache = stamp.map(|_| KeptCache { stamp, bytes: total });
Ok(())
}
fn remove_cache_entry(&self, name: &str) -> Result<bool, StorageError> {
@@ -817,7 +901,10 @@ impl Storage for PluginStorage {
let path = self.entry_path(name);
self.no_links(&path)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Ok(()) => {
self.kept().cache = None;
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(io(&e)),
}
@@ -845,6 +932,7 @@ impl Storage for PluginStorage {
freed = freed.saturating_add(m.len());
}
}
self.kept().cache = Some(KeptCache { stamp: folder_stamp(&dir), bytes: 0 });
Ok(freed)
}
@@ -852,7 +940,14 @@ impl Storage for PluginStorage {
if self.open().is_err() {
return 0;
}
self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0)
let stamp = folder_stamp(&self.cache_dir());
if let Some(kept) = self.kept().cache.filter(|k| k.stamp == stamp) {
return kept.bytes;
}
let Ok((files, _)) = self.cache_files() else { return 0 };
let bytes = sum_sizes(&files);
self.kept().cache = Some(KeptCache { stamp, bytes });
bytes
}
}
+131 -7
View File
@@ -2451,9 +2451,14 @@ fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Stora
std::fs::remove_file(&entry_copy).unwrap();
}
/// Gives the file at `path` the modified time `when`, whatever the file system's clock says.
fn set_modified(path: &std::path::Path, when: std::time::SystemTime) {
std::fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap();
}
/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the
/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or
/// writes. A call that would pass what its meter has left fails and changes nothing.
/// whole settings file when it reads the file and the whole file when it writes it, a cache call the
/// entry it reads or writes. A call that would pass what its meter has left fails and changes nothing.
fn plugin_storage_calls_are_charged_the_files_they_read_and_write() {
use vectorcraft_plugins::storage::{Meter, StorageError};
let folder = crate::tests_links::Folder::new("plugin-storage-meter");
@@ -2467,22 +2472,25 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() {
st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap();
st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap();
// Reading a one-byte setting reads the whole file.
// A file another window changed is read whole, even for a one-byte setting.
let touch = || set_modified(&file, std::fs::metadata(&file).unwrap().modified().unwrap() + std::time::Duration::from_secs(1));
touch();
let before = size();
let mut m = Meter::unlimited();
assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec()));
assert_eq!(m.used(), before);
// Setting it reads the file and writes it again, and so does deleting one.
// Setting it writes the file again, and so does deleting one.
let mut m = Meter::unlimited();
st.set_setting("small", b"yz", &mut m).unwrap();
assert_eq!(m.used(), before + size());
let (before, mut m) = (size(), Meter::unlimited());
assert_eq!(m.used(), size());
let mut m = Meter::unlimited();
assert!(st.delete_setting("small", &mut m).unwrap());
assert_eq!(m.used(), before + size());
assert_eq!(m.used(), size());
st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap();
// Less left than the file: nothing is read. Enough to read it but not to write it again:
// nothing changes.
touch();
let mut m = Meter::new(size() - 1);
assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget));
assert_eq!(m.used(), 0);
@@ -2502,6 +2510,122 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() {
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A session with the plug-in `id` installed in a new store → its storage and the path of its
/// settings file.
fn storage_in(
s: &mut Session,
folder: &crate::tests_links::Folder,
id: &str,
) -> (std::sync::Arc<dyn vectorcraft_plugins::storage::Storage>, std::path::PathBuf) {
s.attach_plugin_store(folder.0.clone());
install_storing(s, id, "");
(vectorcraft_plugins::registry::storage(id).unwrap(), crate::store::plugin_data_dir(&folder.0, id).join("settings.json"))
}
/// The settings stay in memory while the file keeps its size and modified time: a read doesn't open
/// the file, a set to the value already there doesn't write it, a real change is in the file when
/// the call returns, and a file with another time is read again.
#[test]
fn plugin_settings_are_read_from_memory_until_the_file_changes() {
use vectorcraft_plugins::storage::{Meter, StorageError};
let folder = crate::tests_links::Folder::new("plugin-storage-kept");
let id = "test.engine.storagekept";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap();
let written = std::fs::metadata(&file).unwrap().modified().unwrap();
// Other bytes of the same size and the same time: the storage can't tell, so what it answers
// shows that it did not read the file.
let other =
std::fs::read_to_string(&file).unwrap().replace(&vectorcraft_format::base64_encode(b"hello"), &vectorcraft_format::base64_encode(b"world"));
std::fs::write(&file, &other).unwrap();
set_modified(&file, written);
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec()));
assert_eq!(m.used(), 0, "answered from memory");
// Setting the value it has writes nothing: the file still holds the other bytes.
let mut m = Meter::unlimited();
st.set_setting("zoom", b"hello", &mut m).unwrap();
assert_eq!(m.used(), 0, "nothing to write");
assert_eq!(std::fs::read_to_string(&file).unwrap(), other);
// Another time: read again.
set_modified(&file, written + std::time::Duration::from_secs(1));
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"world".to_vec()));
assert_eq!(m.used(), other.len() as u64);
// A change reaches the file before the call returns, and a failed one is not kept.
st.set_setting("tile", b"x", &mut Meter::unlimited()).unwrap();
assert!(std::fs::read_to_string(&file).unwrap().contains("\"tile\""));
assert_eq!(st.set_setting("tile", b"y", &mut Meter::new(0)), Err(StorageError::Budget));
assert_eq!(st.setting("tile", &mut Meter::unlimited()).unwrap(), Some(b"x".to_vec()));
assert!(st.delete_setting("tile", &mut Meter::unlimited()).unwrap());
assert!(!std::fs::read_to_string(&file).unwrap().contains("\"tile\""));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// `plugin.remove` drops what was kept: a file put back with the removed settings' size and time is
/// read from disk by the storage of the id installed again.
#[test]
fn removing_a_plugin_drops_its_settings_from_memory() {
use vectorcraft_plugins::storage::Meter;
let folder = crate::tests_links::Folder::new("plugin-storage-kept-removed");
let id = "test.engine.storagekeptremoved";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap();
let (bytes, written) = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap());
run(&mut s, "plugin.remove", json!({"id": id}));
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, &bytes).unwrap();
set_modified(&file, written);
s.attach_plugin_storage(id);
let st = vectorcraft_plugins::registry::storage(id).unwrap();
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec()));
assert_eq!(m.used(), bytes.len() as u64, "read from the file");
s.remove_plugin_storage(id).unwrap();
}
/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear
/// update it, and removing an entry drops it. A file whose length changes behind the storage leaves
/// the folder's time alone, so the total it reports shows that it did not list the folder.
#[test]
fn plugin_cache_total_is_kept_and_follows_writes_removals_and_clears() {
use vectorcraft_plugins::storage::Meter;
let folder = crate::tests_links::Folder::new("plugin-storage-kept-cache");
let id = "test.engine.storagekeptcache";
let mut s = session();
let (st, file) = storage_in(&mut s, &folder, id);
let cache = file.parent().unwrap().join("cache");
let m = &mut Meter::unlimited();
let grow = |name: &str, len: u64| std::fs::File::options().write(true).open(cache.join(name)).unwrap().set_len(len).unwrap();
st.set_cache_entry("tile", b"abc", m).unwrap();
grow("tile.bin", 10);
assert_eq!(st.cache_bytes(), 3, "the write set the total");
// A write lists the folder for its caps, and starts from what it finds.
st.set_cache_entry("more", b"12345", m).unwrap();
grow("more.bin", 5);
assert_eq!(st.cache_bytes(), 15);
assert!(st.remove_cache_entry("more").unwrap());
assert_eq!(st.cache_bytes(), 10, "after a removal the total comes from the folder");
grow("tile.bin", 20);
assert_eq!(st.cache_bytes(), 10, "and is kept again");
assert_eq!(st.clear_cache().unwrap(), 20);
assert_eq!(st.cache_bytes(), 0);
// A folder deleted behind the storage is noticed.
st.set_cache_entry("tile", b"abc", m).unwrap();
std::fs::remove_dir_all(&cache).unwrap();
assert_eq!(st.cache_bytes(), 0);
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A module with other bytes than the plug-in installed with its id, or, with none installed, one
/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and
/// then keeps them; the same module installs again without it, and `replace` must be true or false.
+63
View File
@@ -144,6 +144,68 @@ fn preview_lists_pages_tiles_inks_and_warnings() {
assert_eq!(v["sheets"][3]["tile"], 4);
}
#[test]
fn copies_per_sheet_print_a_grid_of_the_artboard() {
// A 2 × 3.5 in card filled by a rectangle.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap();
let v = s.execute("print.setup", &json!({})).unwrap();
assert_eq!((v["settings"]["copiesPerSheet"].clone(), v["settings"]["gutter"].clone()), (json!(false), json!(0.0)));
// With an 18 pt gutter, Letter holds 3 × 3 copies; the first, at the top left, spans x 72 to 216.
let set = json!({"settings": {"copiesPerSheet": true, "gutter": 18}});
let v = s.execute("print.preview", &set).unwrap();
let copies = v["sheets"][0]["copies"].as_array().unwrap();
assert_eq!((v["pages"].clone(), copies.len(), copies[0].clone()), (json!(1), 9, json!([72.0, 0.0, 216.0, 252.0])));
// The PDF draws the card's paths nine times.
let cards = |bytes: &[u8]| {
let mut n = 0;
vectorcraft_pdf::import(bytes).unwrap().walk(|node| {
n += usize::from(node.path_data().and_then(|p| p.bounds()).is_some_and(|b| (b.width().round(), b.height().round()) == (144.0, 252.0)))
});
n
};
let (bytes, v) = print(&mut s, set);
assert_eq!(v["pages"], 1);
let one = cards(&print(&mut s, json!({})).0);
assert!(one > 0 && cards(&bytes) == 9 * one, "{one}");
// PostScript places the art once per copy.
let mut ps = |p: Value| {
let v = s.execute("file.print", &p).unwrap();
String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
};
let one = ps(json!({"format": "postscript"})).matches(" cm\n").count();
let grid = ps(json!({"format": "postscript", "settings": {"copiesPerSheet": true, "gutter": 18}})).matches(" cm\n").count();
assert_eq!(grid, one + 8);
}
#[test]
fn print_presets_keep_copies_per_sheet_and_older_presets_leave_it_off() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap();
// [Default] prints one copy per sheet.
let v = s.execute("print.presets.list", &json!({})).unwrap();
assert_eq!((v["presets"][0]["settings"]["copiesPerSheet"].clone(), v["presets"][0]["settings"]["gutter"].clone()), (json!(false), json!(0.0)));
// A saved preset keeps the option and the gutter, lists them as changed, and prints with them.
s.execute("print.presets.save", &json!({"name": "Cards", "settings": {"copiesPerSheet": true, "gutter": 18}})).unwrap();
let v = s.execute("print.presets.list", &json!({})).unwrap();
let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().filter_map(|c| c["option"].as_str()).collect();
assert_eq!(changed, ["copiesPerSheet", "gutter"]);
let v = s.execute("print.preview", &json!({"preset": "Cards"})).unwrap();
assert_eq!(v["sheets"][0]["copies"].as_array().map(Vec::len), Some(9));
// The option survives export and import.
let text = s.execute("print.presets.export", &json!({"names": ["Cards"]})).unwrap()["data"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("print.presets.import", &json!({"data": text})).unwrap();
assert_eq!((t.prefs.print_presets[0].settings.copies_per_sheet, t.prefs.print_presets[0].settings.gutter), (true, 18.0));
// A presets file written without the option imports with it off and no gutter.
let old = r#"{"format": "vcprintpresets", "presets": [{"name": "Old", "settings": {"copies": 2}}]}"#;
t.execute("print.presets.import", &json!({"data": old})).unwrap();
let q = &t.prefs.print_presets[1].settings;
assert_eq!((q.copies, q.copies_per_sheet, q.gutter), (2, false, 0.0));
}
#[test]
fn bad_settings_are_bad_params() {
let (mut s, _) = session();
@@ -154,6 +216,7 @@ fn bad_settings_are_bad_params() {
json!({"settings": {"media": "quarto"}}),
json!({"settings": {"artboards": "range", "range": "2"}}),
json!({"settings": {"scaling": "tileFull", "overlap": 500}}),
json!({"settings": {"gutter": 800}}),
] {
for c in ["file.print", "print.preview"] {
assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}");
+36 -13
View File
@@ -4,7 +4,7 @@
use std::fmt::Write;
use kurbo::Shape;
use kurbo::{Shape, Vec2};
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect};
@@ -24,6 +24,9 @@ pub struct PrintPage<'a> {
pub window: Option<Rect>,
/// Document space → drawing space.
pub place: Affine,
/// Copies on the sheet: each one's move in drawing space from where [`Self::place`] puts the
/// art. The art and the marks are drawn once per copy. Empty: one copy, not moved.
pub copies: &'a [Vec2],
/// The art drawn, in document space: what lies outside it is left out.
pub area: Rect,
/// Printer's marks, in drawing space.
@@ -59,7 +62,10 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) {
return Err(format!("page size {w} × {h}"));
}
if !finite(p.view) || !finite(p.place) {
// `moves` holds each copy's move in drawing space; without copies, it holds one zero move.
let moves = if p.copies.is_empty() { &[Vec2::ZERO][..] } else { p.copies };
let place_at = |m: Vec2| if m == Vec2::ZERO { p.place } else { Affine::translate(m) * p.place };
if !finite(p.view) || !moves.iter().all(|m| finite(place_at(*m))) {
return Err("a page's placement is not finite".into());
}
let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() };
@@ -69,6 +75,9 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
let paper = Rect::new(0.0, 0.0, w, h);
let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper };
let shown = p.window.map_or(shown, |t| t.intersect(shown));
// The marks body is drawn for every copy, so it is generated for the union of the regions
// the copies show.
let shown = moves.iter().map(|m| shown - *m).reduce(|a, b| a.union(b)).unwrap_or(shown);
let marks_opts = opts(shown);
let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m));
let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024);
@@ -99,19 +108,33 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
ps::push_path(&mut s, &t.to_path(0.1));
s.push_str("W\n");
}
s.push_str("q\n");
let _ = writeln!(s, "{} cm", ps::matrix(p.place));
ps::push_path(&mut s, &r.to_path(0.1));
s.push_str("W\n");
s.push_str(&art.setup);
s.push_str(&art.body);
s.push_str("Q\n");
for (i, m) in moves.iter().enumerate() {
s.push_str("q\n");
let _ = writeln!(s, "{} cm", ps::matrix(place_at(*m)));
ps::push_path(&mut s, &r.to_path(0.1));
s.push_str("W\n");
// The setup defines the spot color spaces once; each copy draws the same body.
if i == 0 {
s.push_str(&art.setup);
}
s.push_str(&art.body);
s.push_str("Q\n");
}
let mut found = vec![art];
if let Some(m) = marks {
if let Some(marks) = marks {
// The marks' spot colour spaces are defined after the art is drawn: names may be reused.
s.push_str(&m.setup);
s.push_str(&m.body);
found.push(m);
s.push_str(&marks.setup);
for m in moves {
let moved = *m != Vec2::ZERO;
if moved {
let _ = writeln!(s, "q\n{} cm", ps::matrix(Affine::translate(*m)));
}
s.push_str(&marks.body);
if moved {
s.push_str("Q\n");
}
}
found.push(marks);
}
s.push_str("Q\nend\nshowpage\n%%PageTrailer\n");
Ok((s, found))
+27
View File
@@ -25,6 +25,7 @@ fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> {
view: Affine::IDENTITY,
window: None,
place: Affine::translate((36.0, 36.0)),
copies: &[],
area: d.artboards[0].rect,
marks,
negative: false,
@@ -67,6 +68,32 @@ fn pages_follow_dsc_and_repeat_for_copies() {
assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings);
}
#[test]
fn copies_on_a_sheet_draw_the_art_and_marks_once_each() {
let d = doc();
let mark =
Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25));
let moves = [kurbo::Vec2::ZERO, kurbo::Vec2::new(218.0, 0.0), kurbo::Vec2::new(0.0, 118.0)];
let sheet = PrintPage { copies: &moves, ..page(&d, Some(&mark)) };
let ps = text(&print(&[sheet], &[0], &PrintJob::default()).unwrap());
// The art is placed three times, the second 218 pt right and the third 118 pt down, and its
// spot color space and the marks' are defined once.
assert_eq!(ps.matches("[1 0 0 1 36 36] cm\n").count(), 1, "{ps}");
assert!(ps.contains("[1 0 0 1 254 36] cm\n") && ps.contains("[1 0 0 1 36 154] cm\n"), "{ps}");
assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}");
assert!(ps.contains("q\n[1 0 0 1 218 0] cm\n") && ps.contains("q\n[1 0 0 1 0 118] cm\n"), "{ps}");
// A mark off the page around the first copy still prints around a later copy that has it on the page.
let look = Appearance::basic(Paint::None, Paint::registration(), 0.25);
let off = Node::path(NodeId(9998), shapes::rectangle(Rect::new(-100.0, -100.0, -90.0, -90.0)), look);
let later = [kurbo::Vec2::ZERO, kurbo::Vec2::new(200.0, 200.0)];
let ps = text(&print(&[PrintPage { copies: &later, ..page(&d, Some(&off)) }], &[0], &PrintJob::default()).unwrap());
assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}");
// One copy unmoved is the page without copies.
let one = [kurbo::Vec2::ZERO];
let single = text(&print(&[PrintPage { copies: &one, ..page(&d, Some(&mark)) }], &[0], &PrintJob::default()).unwrap());
assert_eq!(single, text(&print(&[page(&d, Some(&mark))], &[0], &PrintJob::default()).unwrap()));
}
#[test]
fn separations_screens_flatness_negatives_and_tiles() {
let d = doc();
+3
View File
@@ -118,6 +118,9 @@ pub(crate) struct Exporter<'a> {
/// Objects the symbol instances written held, together
/// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]).
symbol_nodes: u64,
/// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less
/// while one of the copies on a sheet is drawn ([`Writer::page`]).
pub(crate) symbol_limit: u64,
}
#[cfg(test)]
+1
View File
@@ -114,6 +114,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
view,
window: None,
place: Affine::IDENTITY,
copies: &[],
area: r,
// Art reaches as far as the bleed: the marks lie outside it.
clip: m != r,
+1
View File
@@ -47,6 +47,7 @@ impl<'a> Exporter<'a> {
in_layer: 0,
paper: Rect::ZERO,
symbol_nodes: 0,
symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES,
overprint: false,
overprinted: false,
invisible: false,
+16 -13
View File
@@ -272,20 +272,23 @@ impl Exporter<'_> {
let count = art.count() as u64;
let map = doc.symbol_slice_map(symbol, *xf);
let most = map.map_or(count, |m| m.most_placed(art, doc));
let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
let art = match map {
Some(m) => m.place(art, doc),
None => {
let mut a = art.clone();
a.transform(*xf, false);
a
}
};
// As `most_placed`, placed art counts its opacity-mask art.
let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64;
(art, count)
let within = self.symbol_nodes.saturating_add(most) <= self.symbol_limit;
let placed = within.then(|| {
vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
let art = match map {
Some(m) => m.place(art, doc),
None => {
let mut a = art.clone();
a.transform(*xf, false);
a
}
};
// As `most_placed`, placed art counts its opacity-mask art.
let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64;
(art, count)
})
});
if let Some(art) = placed {
if let Some(art) = placed.flatten() {
self.node(s, &art, page, true);
}
}
+55 -30
View File
@@ -2,7 +2,7 @@ use super::entry::RgbInCmyk;
use super::exporter::{constant_mask, cover, to_path, writable, xf};
use super::{
Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect,
Shape, Size, Standard, find, krilla, rfind, write,
Shape, Size, Standard, Vec2, find, krilla, rfind, write,
};
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
@@ -43,6 +43,9 @@ pub(crate) struct Sheet<'a> {
pub window: Option<Rect>,
/// Document space → drawing space.
pub place: Affine,
/// Copies on the page: each one's move in drawing space from where [`Self::place`] puts the
/// art. The art and the marks are drawn once per copy. Empty: one copy, not moved.
pub copies: &'a [Vec2],
/// The art drawn, in document space (the bleed box): what lies outside it is left out.
pub area: Rect,
/// Clip the art to [`Self::area`].
@@ -127,22 +130,17 @@ impl Writer {
if sheet.negative {
cover(&mut s, paper, 255, 1.0);
}
// `moves` holds each copy's move in drawing space; without copies, it holds one zero move.
let moves = if sheet.copies.is_empty() { &[Vec2::ZERO][..] } else { sheet.copies };
let place_at = |m: Vec2| if m == Vec2::ZERO { sheet.place } else { Affine::translate(m) * sheet.place };
// A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank.
let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO);
let fits = writable(&s, sheet.view, Rect::ZERO) && moves.iter().all(|m| writable(&s, sheet.view * place_at(*m), Rect::ZERO));
s.push_transform(&xf(sheet.view));
let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1)));
if let Some(clip) = &window {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
let placed = sheet.place != Affine::IDENTITY;
if placed {
s.push_transform(&xf(sheet.place));
}
let r = sheet.area;
let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten();
if let Some(clip) = &clip {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
// Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
// writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency
// at the top of a page is put in a group of its own, whose blending space `cmyk_blending`
@@ -151,35 +149,62 @@ impl Writer {
let doc = ex.doc;
let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency();
if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) {
s.push_isolated();
} else if cmyk_page_group {
let all = constant_mask(&mut s, r, 1.0);
s.push_mask(all);
}
if fits {
let shown = (sheet.view * sheet.place).inverse().transform_rect_bbox(paper);
ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r };
ex.children(&mut s, &doc.layers, r);
}
if page_group || cmyk_page_group {
s.pop();
}
if clip.is_some() {
s.pop();
}
if placed {
s.pop();
// Each copy draws within an equal share of the symbol objects the export has left
// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]), so every copy leaves out the same instances.
let limit = ex.symbol_limit;
let share = limit.saturating_sub(ex.symbol_nodes) / moves.len() as u64;
for m in moves {
ex.symbol_limit = ex.symbol_nodes.saturating_add(share);
let place = place_at(*m);
let placed = place != Affine::IDENTITY;
if placed {
s.push_transform(&xf(place));
}
let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten();
if let Some(clip) = &clip {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) {
s.push_isolated();
} else if cmyk_page_group {
let all = constant_mask(&mut s, r, 1.0);
s.push_mask(all);
}
if fits {
let shown = (sheet.view * place).inverse().transform_rect_bbox(paper);
ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r };
ex.children(&mut s, &doc.layers, r);
}
if page_group || cmyk_page_group {
s.pop();
}
if clip.is_some() {
s.pop();
}
if placed {
s.pop();
}
}
ex.symbol_limit = limit;
if fits && let Some(art) = sheet.marks {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let shown = sheet.window.map_or(shown, |r| r.intersect(shown));
let knockout = std::mem::replace(&mut ex.knockout, false);
// Marks keep their inks: they aren't converted to the destination. The page
// information is drawn as glyph shapes, like the other marks, not as text.
let convert = std::mem::replace(&mut ex.convert, false);
let outline = std::mem::replace(&mut ex.outline_text, true);
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
for m in moves {
let moved = *m != Vec2::ZERO;
if moved {
s.push_transform(&xf(Affine::translate(*m)));
}
ex.node(&mut s, art, shown - *m, true);
if moved {
s.pop();
}
}
(ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline);
}
if window.is_some() {
+18 -4
View File
@@ -6,7 +6,9 @@
//! to each artboard's), transverse, which layers print (template layers never do), the
//! placement on the imageable area (or where the Print Tiling tool put the pages,
//! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable
//! area, overlapping, a range of them.
//! area, overlapping, a range of them. Without tiles, copies per sheet repeats the artboard
//! (with its bleed and marks) across then down the imageable area, as many times as fit,
//! `gutter` apart, and places the grid as it places one copy.
//! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]),
//! around the artboard on the paper, at the paper's scale.
//! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the
@@ -33,14 +35,14 @@ use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::Arc;
use kurbo::{Affine, Rect};
use kurbo::{Affine, Rect, Vec2};
use serde::Serialize;
use vectorcraft_color::cms::{self, ProfileKind};
use vectorcraft_doc::marks::PrinterMarks;
use vectorcraft_doc::overprint::{clear_overprints, darken_overprints};
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
pub use layout::{MAX_TILES, TileGrid};
pub use layout::{MAX_COPIES_PER_SHEET, MAX_TILES, TileGrid};
pub use plates::print_inks;
pub use settings::*;
pub use tiling::{TilingPage, tiling};
@@ -106,8 +108,13 @@ pub struct PageSummary {
/// The part of the document this page prints (`[x0, y0, x1, y1]`, document space): the
/// artboard with its bleed, or what of it a tile shows.
pub area: [f64; 4],
/// The artboard (trim box) on the page, `[x0, y0, x1, y1]`.
/// The artboard (trim box) on the page, `[x0, y0, x1, y1]`; with copies per sheet, the box
/// around every copy's.
pub trim: [f64; 4],
/// With copies per sheet, each copy's artboard on the page, `[x0, y0, x1, y1]`, across then
/// down (the first is where `transform` puts it).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub copies: Vec<[f64; 4]>,
}
/// What a job prints, without printing it.
@@ -292,6 +299,7 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result<PrintPreview, PdfE
transform: (l.view * l.place).as_coeffs(),
area: [l.area.x0, l.area.y0, l.area.x1, l.area.y1],
trim: [l.page_trim.x0, l.page_trim.y0, l.page_trim.x1, l.page_trim.y1],
copies: l.copies.iter().map(|m| l.view.transform_rect_bbox(l.trim + *m)).map(|r| [r.x0, r.y0, r.x1, r.y1]).collect(),
})
})
.collect();
@@ -312,6 +320,10 @@ pub struct PlannedSheet {
pub window: Option<Rect>,
/// Document space → drawing space.
pub place: Affine,
/// With copies per sheet, each copy's move in drawing space from where [`Self::place`] puts
/// the art, across then down (the first is zero): the art and the marks are drawn once per
/// copy. Empty: one copy.
pub copies: Vec<Vec2>,
/// The art drawn, in document space (the bleed box): what lies outside it is left out.
pub area: Rect,
/// Printer's marks, in drawing space (separated for the sheet's ink).
@@ -334,6 +346,7 @@ impl PlannedSheet {
view: self.view,
window: self.window,
place: self.place,
copies: &self.copies,
area: self.area,
clip: true,
marks: self.marks.as_ref(),
@@ -389,6 +402,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
view: l.view,
window: l.window,
place: l.place,
copies: l.copies.clone(),
area: l.area,
marks: art,
negative: set.output.image == PrintImage::Negative,
+58 -14
View File
@@ -1,12 +1,13 @@
//! Where each printed page's art goes: the artboards (or all the art) that print, the paper's
//! orientation, the scale, the placement on the imageable area or the tiles, and the turns and
//! mirroring of the page (flipped orientation, emulsion down, transverse).
//! orientation, the scale, the placement on the imageable area (of one copy, or of a grid of
//! copies per sheet) or the tiles, and the turns and mirroring of the page (flipped orientation,
//! emulsion down, transverse).
//!
//! The art is placed in a drawing space: the paper (y down from its top-left corner), or, when
//! tiling, the whole printed area (the artboard grown by its bleed and marks) from its top-left
//! corner, of which each page shows one tile.
use kurbo::{Affine, Rect};
use kurbo::{Affine, Rect, Vec2};
use serde::Serialize;
use vectorcraft_doc::marks::outset;
use vectorcraft_doc::range::parse_range;
@@ -18,6 +19,9 @@ use crate::PdfError;
/// Most tiles of one artboard.
pub const MAX_TILES: usize = 1000;
/// Most copies of one artboard on a sheet ([`PrintSettings::copies_per_sheet`]).
pub const MAX_COPIES_PER_SHEET: usize = 1000;
/// One page of the job, before copies and inks.
#[derive(Clone, Debug)]
pub(crate) struct Layout {
@@ -43,9 +47,12 @@ pub(crate) struct Layout {
/// the marks go.
pub trim: Rect,
pub bleed: [f64; 4],
/// The TrimBox and BleedBox in page space.
/// The TrimBox and BleedBox in page space (around every copy with copies per sheet).
pub page_trim: Rect,
pub page_bleed: Rect,
/// With copies per sheet, where each copy goes: its move in drawing space from where
/// [`Self::place`] puts the art, across then down (the first is zero). Empty: one copy.
pub copies: Vec<Vec2>,
}
/// The tiles of one artboard (`print.preview`).
@@ -148,6 +155,13 @@ fn span(start: Option<f64>, extent: f64, tile: f64, step: f64, overlap: f64) ->
}
}
/// How many printed areas `extent` long fit in `room`, `gutter` apart (whole copies only).
fn fitting(room: f64, extent: f64, gutter: f64) -> f64 {
// Copies that fill the room exactly fit.
const EPS: f64 = 1e-6;
((room + gutter) / (extent + gutter) + EPS).floor().max(0.0)
}
/// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from
/// the paper onto the page, and the page's size.
fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) {
@@ -232,13 +246,15 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
let place_at = |trim: Rect| {
Affine::translate(trim.origin().to_vec2()) * Affine::scale_non_uniform(scale.0, scale.1) * Affine::translate(-r.origin().to_vec2())
};
// The page's boxes: the trim box within the bleed box within the page.
let boxes = |view: Affine, trim: Rect| {
// The page's boxes: the trim box within the bleed box within the page; `None` when none of
// the trim box is on the page.
let visible = |view: Affine, trim: Rect| {
let b = view.transform_rect_bbox(outset(trim, bleed_s)).intersect(page);
let t = view.transform_rect_bbox(trim).intersect(b);
if t.area() > 0.0 { (t, b) } else { (page, page) }
(t.area() > 0.0).then_some((t, b))
};
let base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout {
let boxes = |view: Affine, trim: Rect| visible(view, trim).unwrap_or((page, page));
let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout {
artboard,
tile,
size: page_size,
@@ -252,24 +268,52 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
bleed: bleed_s,
page_trim,
page_bleed,
copies,
};
if !set.scaling.tiles() {
// The printed area's top-left corner: where the tile origin puts it, else the placement.
// With copies per sheet, the grid holds as many printed areas as fit the imageable
// area, `gutter` apart, and is placed as one; when not even one fits, the artboard
// prints alone.
let gutter = set.gutter;
let fit = set.copies_per_sheet.then(|| (fitting(imageable.width(), extent.0, gutter), fitting(imageable.height(), extent.1, gutter)));
let grid = match fit {
Some((cols, rows)) if cols < 1.0 || rows < 1.0 => {
warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"));
None
}
Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => {
return Err(PdfError::BadSetting(format!("{what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up")));
}
fit => fit.map(|(cols, rows)| (cols as usize, rows as usize)),
};
let step = (extent.0 + gutter, extent.1 + gutter);
let all = grid.map_or(extent, |(cols, rows)| (cols as f64 * step.0 - gutter, rows as f64 * step.1 - gutter));
// The printed area's (or the grid's) top-left corner: where the tile origin puts it,
// else the placement.
let (x, y) = match origin {
Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]),
None => {
let (fx, fy) = set.placement.origin.factors();
(
imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x,
imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y,
imageable.x0 + (imageable.width() - all.0) * fx + set.placement.x,
imageable.y0 + (imageable.height() - all.1) * fy + set.placement.y,
)
}
};
let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1);
if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) {
if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + all.0, y + all.1)) {
warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print"));
}
pages.push(base(turn, None, place_at(trim), bleed_box, trim, boxes(turn, trim), None));
let copies: Vec<Vec2> = grid.map_or_else(Vec::new, |(cols, rows)| {
(0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect()
});
// The boxes hold the copies on the page; copies off it are left out.
let page_boxes = copies
.iter()
.filter_map(|m| visible(turn, trim + *m))
.reduce(|a, b| (a.0.union(b.0), a.1.union(b.1)))
.unwrap_or_else(|| boxes(turn, trim));
pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies));
continue;
}
// Tiles across then down over the printed area, overlapping by `overlap`.
@@ -312,7 +356,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
let w = window(t);
let view = turn * Affine::translate((at.0 - w.x0, at.1 - w.y0));
let area = from_doc.transform_rect_bbox(w).intersect(bleed_box);
pages.push(base(view, Some(w), place, area, trim, boxes(view, trim), Some((t, count))));
pages.push(base(view, Some(w), place, area, trim, boxes(view, trim), Some((t, count)), vec![]));
}
let tiles = (0..count).map(|t| from_doc.transform_rect_bbox(window(t))).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect();
grids.push(TileGrid { artboard, columns: cols, rows, printed: pick.iter().map(|t| t + 1).collect(), tiles });
+8
View File
@@ -349,6 +349,11 @@ pub struct PrintSettings {
pub overlap: f64,
/// 1-based tiles to print (`"1-3, 5"`, numbered across then down); empty: every tile.
pub tile_range: String,
/// Print as many copies of the artboard as fit the imageable area on each sheet, in a grid
/// (not when tiling).
pub copies_per_sheet: bool,
/// The space between copies on a sheet (pt).
pub gutter: f64,
/// The device's unprintable margin around the paper (pt): the imageable area is inside it.
pub margin: f64,
// Marks and Bleed.
@@ -384,6 +389,8 @@ impl Default for PrintSettings {
scale: PrintScale::default(),
overlap: 0.0,
tile_range: String::new(),
copies_per_sheet: false,
gutter: 0.0,
margin: 0.0,
marks: MarkSettings::default(),
// The document's bleed (Document Setup) unless other values are given.
@@ -576,6 +583,7 @@ impl PrintSettings {
within("scale.width", self.scale.width, 1.0, 1000.0, "%")?;
within("scale.height", self.scale.height, 1.0, 1000.0, "%")?;
within("overlap", self.overlap, 0.0, 720.0, " pt")?;
within("gutter", self.gutter, 0.0, 720.0, " pt")?;
within("margin", self.margin, 0.0, 144.0, " pt")?;
within("tileOrigin.x", self.tile_origin.x, -14_400.0, 14_400.0, " pt")?;
within("tileOrigin.y", self.tile_origin.y, -14_400.0, 14_400.0, " pt")?;
+142
View File
@@ -212,6 +212,148 @@ fn placement_and_custom_scale() {
assert!(job(&big, json!({"autoRotate": false})).warnings.iter().any(|w| w.contains("larger than the imageable area")));
}
/// The top-left corners of the copies on the first page of `pv`.
fn corners(pv: &PrintPreview) -> Vec<(f64, f64)> {
pv.sheets[0].copies.iter().map(|c| (c[0], c[1])).collect()
}
/// Every `(x, y)` of `xs` and `ys`, across then down.
fn grid(xs: &[f64], ys: &[f64]) -> Vec<(f64, f64)> {
ys.iter().flat_map(|y| xs.iter().map(move |x| (*x, *y))).collect()
}
#[test]
fn copies_per_sheet_fill_the_page_in_a_grid() {
// A 2 × 3.5 in card (144 × 252 pt) on Letter (612 × 792 pt): 4 across and 3 down, the
// 576 × 756 pt grid centered.
let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]);
let pv = preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap();
assert_eq!((pv.pages, pv.sheets.len(), pv.sheets[0].orientation), (1, 1, Orientation::Portrait));
assert_eq!(corners(&pv), grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]));
assert!(pv.sheets[0].copies.iter().all(|c| (c[2] - c[0], c[3] - c[1]) == (144.0, 252.0)), "{:?}", pv.sheets[0].copies);
assert_eq!(pv.sheets[0].trim, [18.0, 18.0, 594.0, 774.0], "the TrimBox holds every copy");
assert!(pv.warnings.is_empty(), "{:?}", pv.warnings);
// The printed page holds the twelve cards there.
let p = pages(&job(&d, json!({"copiesPerSheet": true})).bytes);
assert_eq!(p.len(), 1);
let mut printed: Vec<(f64, f64)> = p[0].1.iter().map(|(r, _)| (r.x0.round(), r.y0.round())).collect();
printed.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
assert_eq!(printed, grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]), "{:?}", p[0].1);
assert!(p[0].1.iter().all(|(r, paint)| rgb8(paint) == [255, 0, 0] && near(*r, Rect::new(r.x0, r.y0, r.x0 + 144.0, r.y0 + 252.0))));
// An 18 pt gutter: 3 across and 3 down, the 468 × 792 pt grid centered.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 18}))).unwrap();
assert_eq!(corners(&pv), grid(&[72.0, 234.0, 396.0], &[0.0, 270.0, 540.0]));
// The placement places the grid, inside the margin.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap();
assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0]));
// Moved 400 pt right, two columns leave the paper: the TrimBox holds the copies still on it.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "placement": {"origin": "topLeft", "x": 400}}))).unwrap();
assert_eq!(pv.sheets[0].trim, [400.0, 0.0, 612.0, 756.0]);
}
#[test]
fn copies_per_sheet_is_off_by_default_and_off_prints_as_before() {
let s = PrintSettings::default();
assert_eq!((s.copies_per_sheet, s.gutter), (false, 0.0));
let v = serde_json::to_value(&s).unwrap();
assert_eq!((&v["copiesPerSheet"], &v["gutter"]), (&json!(false), &json!(0.0)));
assert!(settings(json!({"gutter": -1})).check().is_err() && settings(json!({"gutter": 721})).check().is_err());
// Off, the job is the same file whatever the gutter, and its pages list no copies.
let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]);
assert_eq!(job(&d, json!({})).bytes, job(&d, json!({"copiesPerSheet": false, "gutter": 36})).bytes);
let pv = serde_json::to_value(preview(&d, &settings(json!({}))).unwrap()).unwrap();
assert!(pv["sheets"][0].get("copies").is_none(), "{pv}");
// Tiles print one artboard across pages: copies per sheet is left out.
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "scaling": "tileFull"}))).unwrap();
assert!(pv.sheets[0].copies.is_empty() && pv.sheets[0].tile.is_some());
}
#[test]
fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() {
// 700 × 900 pt is larger than Letter either way round.
let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]);
let r = job(&d, json!({"copiesPerSheet": true}));
assert_eq!(r.pages, 1);
assert!(
r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"),
"{:?}",
r.warnings
);
assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings);
assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet");
assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty());
// When one copy fits, it prints alone, centered, whatever the gutter.
let d = doc(400.0, 500.0, &[]);
let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 300}))).unwrap();
assert_eq!(corners(&pv), [(106.0, 146.0)]);
// More copies than a sheet takes are refused.
let tiny = doc(1.0, 1.0, &[]);
assert!(preview(&tiny, &settings(json!({"copiesPerSheet": true}))).is_err_and(|e| e.to_string().contains("more than 1000 copies")));
}
#[test]
fn each_copy_prints_with_its_marks() {
let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(1.0, 0.0, 0.0))]);
let set = json!({"copiesPerSheet": true, "marks": {"trim": true}});
let pv = preview(&d, &settings(set.clone())).unwrap();
let copies = pv.sheets[0].copies.len();
assert!(copies > 1, "{pv:?}");
let back = import(&job(&d, set).bytes).unwrap();
let mut marks = 0;
back.walk(|n| marks += usize::from(n.appearance.stroke().is_some_and(|s| s.paint.is_registration())));
assert_eq!(marks, 8 * copies, "8 trim marks around each copy");
}
#[test]
fn every_copy_on_a_sheet_draws_the_same_symbol_instances() {
// A symbol of three squares (4 objects with its group), placed twice.
let mut d = doc(100.0, 100.0, &[]);
let squares = (0..3)
.map(|i| {
let r = Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0);
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0)))
})
.collect();
let art = Node::group(d.alloc_id(), squares);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Squares".into(), art: Arc::new(art) });
let layer = d.default_layer().unwrap();
for y in [10.0, 50.0] {
let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) };
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, instance)).unwrap();
}
let set = PdfSettings { compression: CompressionSettings { compress_text: false, ..Default::default() }, ..Default::default() };
let mut w = crate::export::Writer::new(&d, &set, "Copies", None, false).unwrap();
let mut ex = crate::export::Exporter::new(&d, &set);
// Room for 10 symbol objects: the first copy could write both instances (8) and leave the
// second copy none.
ex.symbol_limit = 10;
let copies = [vectorcraft_geom::Vec2::ZERO, vectorcraft_geom::Vec2::new(200.0, 0.0)];
let page = Rect::new(0.0, 0.0, 300.0, 100.0);
let sheet = crate::export::Sheet {
size: (300.0, 100.0),
unit: 1.0,
trim: page,
bleed: page,
view: vectorcraft_geom::Affine::IDENTITY,
window: None,
place: vectorcraft_geom::Affine::IDENTITY,
copies: &copies,
area: Rect::new(0.0, 0.0, 100.0, 100.0),
clip: false,
marks: None,
negative: false,
links: false,
};
w.page(&mut ex, &sheet).unwrap();
assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet");
w.absorb(ex);
let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap();
let p = pages(&bytes);
let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count();
assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1);
}
#[test]
fn tiles_cover_the_artboard_with_overlap() {
// 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1.
+1 -1
View File
@@ -91,7 +91,7 @@ pub const STORAGE_BUDGET: i32 = -7;
pub const MAX_STORAGE_CALLS: usize = 10_000;
/// Most bytes one run's storage calls read and write, all together, counting the whole settings
/// file each settings call reads and writes and each cache entry read or written; a call that would
/// file each time it is read or written and each cache entry read or written; a call that would
/// pass it returns [`STORAGE_BUDGET`].
pub const MAX_STORAGE_BYTES: usize = 1 << 30;
+3 -2
View File
@@ -93,8 +93,9 @@ impl std::fmt::Display for StorageError {
/// Where one plug-in's settings and cache are kept. Every key and name the host passes is a
/// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]);
/// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes
/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it
/// reads and the whole file it writes, a cache call the entry it reads or writes.
/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file
/// when it reads the file and the whole file when it writes it, a cache call the entry it reads or
/// writes. A settings call that the storage answers from memory charges nothing.
pub trait Storage: Send + Sync {
/// The value of setting `key`, if it has one.
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError>;
+16 -3
View File
@@ -607,6 +607,17 @@ fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usiz
})
}
// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic,
// which the push-in's room does not count either.
fn may_squeeze(g: &SGlyph) -> bool {
g.len > 0 && g.inline.is_none()
}
// How much a pushed-in line's `squeeze` adds to the CJK gap after `g`.
fn squeeze_after(g: &SGlyph, squeeze: f64, vertical: bool) -> f64 {
if may_squeeze(g) { squeeze * squeeze_em(g, vertical) } else { 0.0 }
}
// What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph
// scaling of a `hung` one.
fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex {
@@ -672,8 +683,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
// the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`].
let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS;
if pushed_in {
let ems: f64 =
(line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], cx.vertical)).sum();
let ems: f64 = (line.index..line.body)
.filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j]))
.map(|j| squeeze_em(&sg[j], cx.vertical))
.sum();
if ems > EPS {
squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE);
}
@@ -778,7 +791,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
advance += justification.per_space;
} else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical);
advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical);
}
} else if !line.held.contains(&glyph_index)
&& drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy())
+19
View File
@@ -347,6 +347,25 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() {
assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}");
}
/// A pushed-in line closes the gaps next to its CJK characters and leaves the gap after inline art
/// open. Needs a font with full-width Japanese (skipped without one).
#[test]
fn a_pushed_in_line_leaves_the_gap_after_inline_art() {
let mut t = TextObject::point(Point::ZERO, "一二", style(20.0));
t.xf = Affine::IDENTITY;
t.runs.push(TextRun::inline(art(0.5), style(20.0)));
t.runs.push(TextRun::new("四五六七八九十一二三四五」六", style(20.0)));
t.para.kinsoku_type = vectorcraft_doc::KinsokuShoriType::PushInFirst;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 200.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
if (l.glyphs[0].advance - 20.0).abs() > 2.0 {
return; // no font with full-width Japanese here
}
assert!(l.glyphs[1].advance < 20.0 - 0.5, "the other gaps close up: {}", l.glyphs[1].advance);
let art = inline_glyph(&l);
assert!((art.advance - 20.0).abs() < 1e-6, "the art keeps its advance: {}", art.advance);
}
#[test]
fn inline_art_moves_with_vertically_aligned_lines() {
use vectorcraft_doc::text::{AreaOptions, VerticalAlign};
+20 -1
View File
@@ -115,6 +115,23 @@ impl Channel {
Channel::Blue => (2, 0, 1),
}
}
/// The color field's axes (across, up) as keys and screen readers see them: hue in degrees,
/// saturation and brightness in percent, red, green and blue from 0 to 255.
fn field_axes(self) -> [widgets::FieldAxis<'static>; 2] {
let axis = |i: usize| {
let (name, units, suffix) = match (self.is_hsb(), i) {
(true, 0) => (tl!("Hue"), 360.0, "°"),
(true, 1) => (tl!("Saturation"), 100.0, "%"),
(true, _) => (tl!("Brightness"), 100.0, "%"),
(false, 0) => (tl!("Red"), 255.0, ""),
(false, 1) => (tl!("Green"), 255.0, ""),
(false, _) => (tl!("Blue"), 255.0, ""),
};
widgets::FieldAxis { name, units, suffix }
};
let (_, x, y) = self.axes();
[axis(x), axis(y)]
}
}
/// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours.
@@ -367,7 +384,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction {
} else {
let (z, x, y) = field_pos(channel, &p);
let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color);
if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) {
if let (Some((x, y)), _) =
widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), (x, y), true, &at)
{
pick = Some(field_pick(channel, z, x, y));
}
ui.add_space(SLIDER_GAP);
+25 -10
View File
@@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
}
let coeffs: Vec<f64> = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let clip = ui.painter().with_clip_rect(page);
// With copies per sheet, `copies` holds each copy's artboard on the page, and `moves` holds
// each copy's move on screen from the first.
let copies: Vec<DRect> = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default();
let moves: Vec<egui::Vec2> = match copies.first() {
Some(first) => copies.iter().map(|r| egui::vec2((r.x0 - first.x0) as f32, (r.y0 - first.y0) as f32) * fit).collect(),
None => vec![egui::Vec2::ZERO],
};
if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) {
let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0);
let layers = choice::<PrintLayers>(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable);
if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) {
let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset;
let mut mesh = egui::Mesh::with_texture(tex.id());
for (p, uv) in [
((area.x0, area.y0), (0.0, 0.0)),
((area.x1, area.y0), (1.0, 0.0)),
((area.x1, area.y1), (1.0, 1.0)),
((area.x0, area.y1), (0.0, 1.0)),
] {
mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE });
for m in &moves {
let first = mesh.vertices.len() as u32;
for (p, uv) in [
((area.x0, area.y0), (0.0, 0.0)),
((area.x1, area.y0), (1.0, 0.0)),
((area.x1, area.y1), (1.0, 1.0)),
((area.x0, area.y1), (0.0, 1.0)),
] {
mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1) + *m, uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE });
}
mesh.add_triangle(first, first + 1, first + 2);
mesh.add_triangle(first, first + 2, first + 3);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
clip.add(mesh);
}
}
@@ -460,7 +470,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0));
}
if let Some(trim) = rect_of(&sheet["trim"]) {
// The preview strokes each copy's artboard edge, or the trim box without copies per sheet.
let trims = if copies.is_empty() { rect_of(&sheet["trim"]).into_iter().collect() } else { copies };
for trim in trims {
let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset);
clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle);
}
@@ -660,6 +672,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if tiles {
note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile."));
}
flag(ui, d, "copiesPerSheet", tl!("Copies per Sheet (as many as fit, in a grid)"), !tiles);
let per_sheet = get(d, "copiesPerSheet").as_bool() == Some(true);
row(ui, tl!("Gutter:"), |ui| length(ui, d, "gutter", unit, !tiles && per_sheet));
}
fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
+20
View File
@@ -216,6 +216,24 @@ fn without_a_print_service_print_saves_a_pdf() {
assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false}));
}
#[test]
fn copies_per_sheet_prints_a_grid_of_each_artboard() {
let (mut app, jobs) = app();
app.run("file.print", json!({})).unwrap();
set(&mut app, "copiesPerSheet", json!(true));
set(&mut app, "gutter", json!(9));
frame(&mut app);
let r = super::confirm(&mut app).unwrap();
assert_eq!(r["pages"], 3, "a sheet per artboard");
assert_eq!(pages(&jobs.borrow()[0].2), 3);
let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap();
assert_eq!((saved["copiesPerSheet"].clone(), saved["gutter"].clone()), (json!(true), json!(9.0)));
// Each 120 × 90 artboard fills landscape Letter 6 across and 6 down, 9 pt apart.
let pv = app.session.execute("print.preview", &json!({})).unwrap();
assert_eq!(pv["sheets"][0]["copies"].as_array().map(Vec::len), Some(36));
assert_eq!(pv["sheets"][0]["copies"][1], json!([142.5, 13.5, 262.5, 103.5]));
}
#[test]
fn dragging_the_preview_moves_the_placement_on_the_paper() {
let (mut app, _) = app();
@@ -225,6 +243,8 @@ fn dragging_the_preview_moves_the_placement_on_the_paper() {
(json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)),
(json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)),
(json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)),
// Copies per sheet: the drag moves the whole grid.
(json!({"copiesPerSheet": true, "gutter": 9}), (10.0, 5.0)),
] {
let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap();
let sheet = &before["sheets"][0];
+6
View File
@@ -551,6 +551,11 @@ font-technology Variable Променливи
Color Table ({n} colors) Цветова таблица ({n} цвята)
Color Theme Цветова тема
Color Themes Цветови теми
Color Wheel Цветно колело
Hue ring Пръстен на оттенъка
Saturation and brightness Насищане и яркост
Color field Цветово поле
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Цветен тип:
Color: Цвят:
Colorize Оцветяване
@@ -3463,6 +3468,7 @@ digits Malayalam Малаялам
Tile Type: Тип плочка:
tile {number} плочка {number}
Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка.
Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа)
Tint Оттенък
Tip extends past the end point Връхът продължава след крайната точка
Tip on the end point Връх върху крайната точка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -696,6 +696,11 @@
Color Boja
Color Guide Vodič za boje
Color Themes Teme boja
Color Wheel Kotač boja
Hue ring Prsten nijanse
Saturation and brightness Zasićenje i svjetlina
Color field Polje boje
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS svojstva
Document Info Informacije o dokumentu
Flattener Preview Pregled spljoštavanja
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -766,6 +766,11 @@ antiAlias Sharp Nítid
Color Table ({n} colors) Taula de colors ({n} colors)
Color Theme Tema de colors
Color Themes Temes de colors
Color Wheel Roda de colors
Hue ring Anell de to
Saturation and brightness Saturació i lluentor
Color field Camp de color
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Tipus de color:
Color wheel Roda de color
Color: Color:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -705,6 +705,11 @@
Color Barva
Color Guide Průvodce barvami
Color Themes Barevné motivy
Color Wheel Barevné kolo
Hue ring Prstenec odstínů
Saturation and brightness Sytost a jas
Color field Barevné pole
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties Vlastnosti CSS
Document Info Informace o dokumentu
Flattener Preview Náhled sloučení
@@ -2714,6 +2719,7 @@ digits Malayalam Malajálamské
Tile Range: Rozsah dlaždic:
tile {number} dlaždice {number}
Tiles are numbered across, then down; leave the range empty for every tile. Dlaždice se číslují po řádcích, pak dolů; prázdný rozsah znamená všechny dlaždice.
Copies per Sheet (as many as fit, in a grid) Kopie na list (kolik se vejde, v mřížce)
Transverse (the page a quarter turn on the paper) Napříč (stránka otočená na papíře o čtvrt otáčky)
Unprintable margin: Netisknutelný okraj:
{pages} in all, {count} per copy celkem {pages}, na kopii {count}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -654,6 +654,11 @@
Color Farve
Color Guide Farveguide
Color Themes Farvetemaer
Color Wheel Farvehjul
Hue ring Ring for farvetone
Saturation and brightness Mætning og lysstyrke
Color field Farvefelt
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS-egenskaber
Document Info Dokumentoplysninger
Flattener Preview Udjævningsforhåndsvisning
@@ -2502,6 +2507,7 @@ digits Malayalam Malayalam
Tile Range: Fliseinterval:
tile {number} flise {number}
Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser.
Copies per Sheet (as many as fit, in a grid) Kopier pr. ark (så mange der er plads til, i et gitter)
Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret)
Unprintable margin: Ikke-udskrivbar margen:
{pages} in all, {count} per copy {pages} i alt, {count} pr. kopi
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -544,6 +544,11 @@ font-technology Variable Variabel
Color Table ({n} colors) Farbtabelle ({n} Farben)
Color Theme Farbthema
Color Themes Farbthemen
Color Wheel Farbkreis
Hue ring Farbtonring
Saturation and brightness Sättigung und Helligkeit
Color field Farbauswahlfeld
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Farbart:
Color: Farbe:
Colorize Einfärben
@@ -3495,6 +3500,8 @@ effect Points Punkte
Tile Type: Kacheltyp:
tile {number} Kachel {number}
Tiles are numbered across, then down; leave the range empty for every tile. Kacheln werden erst quer, dann nach unten nummeriert; ein leerer Bereich steht für alle Kacheln.
Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster)
Gutter: Zwischenraum:
Tint Tonwert
Tip extends past the end point Spitze ragt über den Endpunkt hinaus
Tip on the end point Spitze auf dem Endpunkt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -526,6 +526,11 @@ font-technology Variable Μεταβλητές
Color Table ({n} colors) Πίνακας χρωμάτων ({n} χρώματα)
Color Theme Χρωματικό θέμα
Color Themes Χρωματικά θέματα
Color Wheel Χρωματικός τροχός
Hue ring Δακτύλιος απόχρωσης
Saturation and brightness Κορεσμός και φωτεινότητα
Color field Πεδίο χρώματος
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Τύπος χρώματος:
Color: Χρώμα:
Colorize Χρωματισμός
@@ -3887,6 +3892,7 @@ digits Malayalam Μαλαγιάλαμ
Tile Type: Τύπος πλακιδίου:
tile {number} πλακίδιο {number}
Tiles are numbered across, then down; leave the range empty for every tile. Τα πλακίδια αριθμούνται πρώτα οριζόντια και έπειτα κατακόρυφα· αφήστε κενό το εύρος για όλα τα πλακίδια.
Copies per Sheet (as many as fit, in a grid) Αντίγραφα ανά φύλλο (όσα χωρούν, σε πλέγμα)
Tip extends past the end point Η αιχμή προεξέχει από το τελικό σημείο
Tip on the end point Η αιχμή βρίσκεται στο τελικό σημείο
Title Case Κεφαλαίο σε κάθε λέξη
Can't render this file because it is too large.
+7
View File
@@ -566,6 +566,11 @@ font-technology Variable Variables
Color Table ({n} colors) Tabla de colores ({n} colores)
Color Theme Tema de color
Color Themes Temas de color
Color Wheel Rueda de color
Hue ring Anillo de tono
Saturation and brightness Saturación y brillo
Color field Campo de color
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Tipo de color:
Color: Color:
Colorize Colorear
@@ -3551,6 +3556,8 @@ digits Malayalam Malayálam
Tile Type: Tipo de mosaico:
tile {number} mosaico {number}
Tiles are numbered across, then down; leave the range empty for every tile. Los mosaicos se numeran de izquierda a derecha y luego hacia abajo; deja el rango vacío para todos.
Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula)
Gutter: Medianil:
Tint Matiz
Tip extends past the end point La punta se extiende más allá del punto final
Tip on the end point Punta en el punto final
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -696,6 +696,11 @@
Color Väri
Color Guide Väriopas
Color Themes Väriteemat
Color Wheel Väriympyrä
Hue ring Sävyrengas
Saturation and brightness Kylläisyys ja kirkkaus
Color field Värikenttä
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS-ominaisuudet
Document Info Asiakirjan tiedot
Flattener Preview Litistyksen esikatselu
@@ -2658,6 +2663,7 @@ digits Malayalam Malajalam
Tile Range: Ruutualue:
tile {number} ruutu {number}
Tiles are numbered across, then down; leave the range empty for every tile. Ruudut numeroidaan vaakasuunnassa ja sitten alaspäin; jätä alue tyhjäksi, niin kaikki ruudut tulostetaan.
Copies per Sheet (as many as fit, in a grid) Kopioita arkille (niin monta kuin mahtuu, ruudukkoon)
Transverse (the page a quarter turn on the paper) Poikittainen (sivu neljänneskierroksen käännettynä paperilla)
Unprintable margin: Tulostumaton reunus:
{pages} in all, {count} per copy {pages} yhteensä, {count} kopiota kohden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -567,6 +567,11 @@ font-technology Variable Variables
Color Table ({n} colors) Table des couleurs ({n} couleurs)
Color Theme Thème de couleurs
Color Themes Thèmes de couleurs
Color Wheel Roue chromatique
Hue ring Anneau de teinte
Saturation and brightness Saturation et luminosité
Color field Champ de couleur
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Type de couleur :
Color: Couleur :
Colorize Coloriser
@@ -3542,6 +3547,7 @@ digits Malayalam Malayalam
Tile Range: Plage de carreaux :
Tile Type: Type de carreau :
tile {number} carreau {number}
Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille)
Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux.
Tint Teinte
Tip extends past the end point La pointe dépasse le point d’extrémité
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -867,6 +867,11 @@ antiAlias Sharp शार्प
Color Table (4 colors) रंग तालिका (4 रंग)
Color Theme रंग थीम
Color Themes रंग थीम्स
Color Wheel कलर व्हील
Hue ring ह्यू रिंग
Saturation and brightness सैचुरेशन और ब्राइटनेस
Color field कलर फ़ील्ड
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: रंग का प्रकार:
Color wheel कलर व्हील
Color: रंग:
Can't render this file because it is too large.
+6
View File
@@ -742,6 +742,11 @@ font-technology Variable Változó
Color Table ({n} colors) Színtábla ({n} szín)
Color Theme Színtéma
Color Themes Színtémák
Color Wheel Színkör
Hue ring Színárnyalat-gyűrű
Saturation and brightness Telítettség és fényesség
Color field Színmező
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Színtípus:
Color: Szín:
Colorization Színezés
@@ -4225,6 +4230,7 @@ mark-style Japanese Japán
tile {number} csempe {number}
Tiles are numbered across, then down; leave the range empty for every tile. A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt.
Tilt Dőlés
Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban)
Tint Tónus
Tints Tónusok
Tints and Shades Tónusok és árnyalatok
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -654,6 +654,11 @@
Color Warna
Color Guide Panduan warna
Color Themes Tema warna
Color Wheel Roda warna
Hue ring Cincin rona
Saturation and brightness Saturasi dan kecerahan
Color field Kolom warna
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties Properti CSS
Document Info Info dokumen
Flattener Preview Pratinjau perata
@@ -2502,6 +2507,7 @@ digits Malayalam Malayalam
Tile Range: Rentang ubin:
tile {number} ubin {number}
Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin.
Copies per Sheet (as many as fit, in a grid) Salinan per lembar (sebanyak yang muat, dalam kisi)
Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas)
Unprintable margin: Margin yang tidak dapat dicetak:
{pages} in all, {count} per copy {pages} total, {count} per salinan
Can't render this file because it contains an unexpected character in line 2306 and column 5.
+7
View File
@@ -566,6 +566,11 @@ font-technology Variable Variabili
Color Table ({n} colors) Tabella colori ({n} colori)
Color Theme Tema colore
Color Themes Temi colore
Color Wheel Ruota dei colori
Hue ring Anello tonalità
Saturation and brightness Saturazione e luminosità
Color field Campo colore
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Tipo di colore:
Color: Colore:
Colorize Colora
@@ -3587,6 +3592,8 @@ digits Malayalam Malayalam
Tile Type: Tipo di tassello:
tile {number} riquadro {number}
Tiles are numbered across, then down; leave the range empty for every tile. I riquadri sono numerati da sinistra a destra, poi dall'alto in basso; lascia vuoto l'intervallo per stamparli tutti.
Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia)
Gutter: Spazio intermedio:
Tint Tinta
Tip extends past the end point La punta si estende oltre il punto finale
Tip on the end point Punta sul punto finale
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -714,6 +714,11 @@ digits Malayalam マラヤーラム数字
Color カラー
Color Guide カラーガイド
Color Themes カラーテーマ
Color Wheel カラーホイール
Hue ring 色相リング
Saturation and brightness 彩度と明るさ
Color field カラーフィールド
{name1} {value1}, {name2} {value2} {name1} {value1}、{name2} {value2}
CSS Properties CSS プロパティ
Document Info ドキュメント情報
Flattener Preview 分割・統合プレビュー
@@ -3279,6 +3284,8 @@ mark-style Japanese 日本式
Tile Range: 分割範囲:
Tile Type: タイルの種類:
Tiles are numbered across, then down; leave the range empty for every tile. タイルは左から右、上から下の順に番号が付きます。範囲を空にするとすべてのタイルが対象です。
Copies per Sheet (as many as fit, in a grid) 用紙ごとに複数コピー (収まるだけグリッド状に配置)
Gutter: 間隔:
Toggle Fill/Stroke Focus 塗り/線の切り替え
Toggle Selection 選択を切り替え
Toggle single/double column 1 列/2 列を切り替え
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -606,6 +606,11 @@
Color ფერი
Color Guide ფერის გზამკვლევი
Color Themes ფერის თემები
Color Wheel ფერთა წრე
Hue ring ფერის ტონის რგოლი
Saturation and brightness გაჯერება და სიკაშკაშე
Color field ფერის ველი
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS თვისებები
Document Info დოკუმენტის ინფორმაცია
Flattener Preview გაბრტყელების წინასწარი ნახვა
Can't render this file because it is too large.
+7
View File
@@ -707,6 +707,11 @@ digits Malayalam 말라얄람어
Color 색상
Color Guide 색상 가이드
Color Themes 색상 테마
Color Wheel 색상환
Hue ring 색조 링
Saturation and brightness 채도 및 밝기
Color field 색상 필드
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS 속성
Document Info 문서 정보
Flattener Preview 병합 미리 보기
@@ -3268,6 +3273,8 @@ mark-style Japanese 일본식
Tile Range: 타일 범위:
Tile Type: 타일 유형:
Tiles are numbered across, then down; leave the range empty for every tile. 타일은 가로로 먼저, 이어서 세로로 번호가 매겨집니다. 모든 타일을 지정하려면 범위를 비워 두십시오.
Copies per Sheet (as many as fit, in a grid) 용지당 여러 매 (들어가는 만큼 격자로 배치)
Gutter: 간격:
Toggle Fill/Stroke Focus 칠/선 포커스 전환
Toggle Selection 선택 전환
Toggle single/double column 한 줄/두 줄 전환
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -668,6 +668,11 @@
Color Kleur
Color Guide Kleurhulp
Color Themes Kleurthema's
Color Wheel Kleurenwiel
Hue ring Tintring
Saturation and brightness Verzadiging en helderheid
Color field Kleurveld
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties CSS-eigenschappen
Document Info Documentinfo
Flattener Preview Afvlakvoorbeeld
@@ -2516,6 +2521,7 @@ digits Malayalam Malayalam
Tile Range: Tegelbereik:
tile {number} tegel {number}
Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels.
Copies per Sheet (as many as fit, in a grid) Kopieën per vel (zoveel als er passen, in een raster)
Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier)
Unprintable margin: Niet afdrukbare marge:
{pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -654,6 +654,11 @@
Color Kolor
Color Guide Przewodnik kolorów
Color Themes Motywy kolorów
Color Wheel Koło kolorów
Hue ring Pierścień barwy
Saturation and brightness Nasycenie i jasność
Color field Pole koloru
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
CSS Properties Właściwości CSS
Document Info Informacje o dokumencie
Flattener Preview Podgląd spłaszczania
@@ -2627,6 +2632,8 @@ digits Malayalam Malajalam
Tile Range: Zakres kafelków:
tile {number} kafelek {number}
Tiles are numbered across, then down; leave the range empty for every tile. Kafelki są numerowane poziomo, a potem w dół; pozostaw zakres pusty dla wszystkich kafelków.
Copies per Sheet (as many as fit, in a grid) Kopie na arkusz (tyle, ile się zmieści, w siatce)
Gutter: Odstęp:
Transverse (the page a quarter turn on the paper) Poprzecznie (strona obrócona o ćwierć obrotu na papierze)
Unprintable margin: Margines niedrukowalny:
{pages} in all, {count} per copy Razem: {pages}, na kopię: {count}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -511,6 +511,11 @@ antiAlias Sharp Nítido
Color Swatches Amostras de Cor
Color Table Tabela de Cores
Color Themes Temas de Cores
Color Wheel Roda de Cores
Hue ring Anel de Tom
Saturation and brightness Saturação e Brilho
Color field Campo de Cor
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Tipo de Cor:
Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia
Color blindness – Protanopia-type Daltonismo – tipo protanopia
@@ -2916,6 +2921,7 @@ digits Malayalam Malaiala
Tile Range: Intervalo de Blocos:
Tile Type: Tipo de Bloco:
Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco.
Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade)
Title Case Tipo de Título
Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado
Toggle Selection Alternar Seleção
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -564,6 +564,11 @@ font-technology Variable Вариативные
Color Table ({n} colors) Цветовая таблица ({n} цветов)
Color Theme Цветовая тема
Color Themes Цветовые темы
Color Wheel Цветовой круг
Hue ring Кольцо оттенков
Saturation and brightness Насыщенность и яркость
Color field Цветовое поле
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Тип цвета:
Color: Цвет:
Colorize Тонировать
@@ -3582,6 +3587,7 @@ digits Malayalam Малаялам
Tile Type: Тип плитки:
tile {number} плитка {number}
Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток.
Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой)
Tint Оттенок
Tip extends past the end point Кончик выходит за конечную точку
Tip on the end point Кончик на конечной точке
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -553,6 +553,11 @@ font-technology Variable Variabla
Color Table ({n} colors) Färgtabell ({n} färger)
Color Theme Färgtema
Color Themes Färgteman
Color Wheel Färghjul
Hue ring Nyansring
Saturation and brightness Mättnad och ljusstyrka
Color field Färgfält
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Färgtyp:
Color: Färg:
Colorize Färglägg
@@ -3473,6 +3478,7 @@ digits Malayalam Malayalam
Tile Type: Ruttyp:
tile {number} ruta {number}
Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor.
Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät)
Tint Färgton
Tip extends past the end point Spetsen sträcker sig förbi slutpunkten
Tip on the end point Spetsen på slutpunkten
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -547,6 +547,11 @@ font-technology Variable Değişken
Color Table ({n} colors) Renk Tablosu ({n} renkleri)
Color Theme Renk Teması
Color Themes Renk Temaları
Color Wheel Renk Tekerleği
Hue ring Renk tonu halkası
Saturation and brightness Doygunluk ve parlaklık
Color field Renk seçim alanı
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Renk Türü:
Color: Renk:
Colorize Renklendir
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -560,6 +560,11 @@ font-technology Variable Варіативні
Color Table ({n} colors) Таблиця кольорів ({n} кольорів)
Color Theme Колірна тема
Color Themes Колірні теми
Color Wheel Колірне коло
Hue ring Кільце відтінків
Saturation and brightness Насиченість і яскравість
Color field Колірне поле
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Тип кольору:
Color: Колір:
Colorize Тонувати
@@ -3580,6 +3585,8 @@ effect Points Точки
Tile Type: Тип плитки:
tile {number} плитка {number}
Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруються зліва направо, потім униз; Залишіть діапазон порожнім для всіх плиток.
Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою)
Gutter: Проміжок:
Tint Відтінок
Tip extends past the end point Кінчик виходить за кінцеву точку
Tip on the end point Кінчик на кінцевій точці
Can't render this file because it contains an unexpected character in line 182 and column 5.
+7
View File
@@ -653,6 +653,11 @@ font-technology Variable Biến thiên
Color Table ({n} colors) Bảng màu ({n} màu)
Color Theme Bộ màu
Color Themes Bộ màu
Color Wheel Bánh xe màu
Hue ring Vòng sắc độ
Saturation and brightness Độ bão hòa và độ sáng
Color field Vùng chọn màu
{name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2}
Color Type: Loại màu:
Color: Màu:
Colorization Tô màu
@@ -3927,6 +3932,8 @@ mark-style Japanese Kiểu Nhật
tile {number} ô {number}
Tiles are numbered across, then down; leave the range empty for every tile. Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô.
Tilt Độ nghiêng
Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới)
Gutter: Khoảng cách:
Tint Tông màu
Tints Sắc pha
Tints and Shades Sắc pha và bóng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+6
View File
@@ -512,6 +512,11 @@ antiAlias Sharp 锐利
Color Table ({n} colors) 颜色表 ({n} 色)
Color Theme 色彩主题
Color Themes 颜色主题
Color Wheel 色轮
Hue ring 色相环
Saturation and brightness 饱和度和亮度
Color field 颜色区域
{name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2}
Color Type: 颜色类型:
Color: 颜色:
Colorize 上色
@@ -3414,6 +3419,7 @@ digits Malayalam 马拉雅拉姆数字
Tile Type: 拼贴类型:
tile {number} 拼贴 {number}
Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。
Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列)
Tint 色调
Tip extends past the end point 箭头超出端点
Tip on the end point 箭头位于端点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -528,6 +528,11 @@ font-technology Variable 可變
Color Table ({n} colors) 顏色表 ({n} 色)
Color Theme 色彩主題
Color Themes 色彩主題
Color Wheel 色輪
Hue ring 色相環
Saturation and brightness 飽和度和亮度
Color field 顏色區域
{name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2}
Color Type: 顏色類型:
Color: 顏色:
Colorize 上色
@@ -4476,6 +4481,8 @@ digits Malayalam 馬來亞拉姆數字
Tile Type: 拼貼類型:
tile {number} 拼貼 {number}
Tiles are numbered across, then down; leave the range empty for every tile. 拼貼先由左至右再由上至下編號;範圍留空表示全部拼貼。
Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列)
Gutter: 間距:
Tint 色調
Tip extends past the end point 尖端延伸超過終點
Tip on the end point 尖端位於終點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -196,8 +196,8 @@ pub struct VectorcraftApp {
/// A URL to open through egui next frame (when the host has no `open_url` service).
pending_url: Option<String>,
/// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User
/// Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. `false`
/// unless the host sets it.
/// Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while
/// it is set. `false` unless the host sets it.
pub titlebar_from_env: bool,
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
+1
View File
@@ -644,6 +644,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
panel("Color", "color"),
panel("Color Guide", "colorGuide"),
panel("Color Themes", "colorThemes"),
panel("Color Wheel", crate::panels::color_wheel::ID),
panel("CSS Properties", crate::panels::css_properties::ID),
panel("Document Info", "docInfo"),
panel("Flattener Preview", crate::panels::flattener_preview::ID),
+4
View File
@@ -15,6 +15,7 @@ pub mod character;
pub mod color;
pub mod color_guide;
pub mod color_themes;
pub mod color_wheel;
pub mod css_properties;
pub mod doc_info;
pub mod flattener_preview;
@@ -177,6 +178,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
svg_interactivity::ID => svg_interactivity::show(app, ui),
variables::ID => variables::show(app, ui),
"colorThemes" => color_themes::show(app, ui),
color_wheel::ID => color_wheel::show(app, ui),
links::ID => links::show(app, ui),
asset_export::ID => asset_export::show(app, ui),
css_properties::ID => css_properties::show(app, ui),
@@ -655,6 +657,8 @@ mod tests_blend;
#[cfg(test)]
mod tests_brush_libraries;
#[cfg(test)]
mod tests_color_wheel;
#[cfg(test)]
mod tests_constrain;
#[cfg(test)]
mod tests_css_properties;
+10 -5
View File
@@ -354,7 +354,7 @@ fn to32(c: &Color) -> Color32 {
}
/// The global or spot swatch a colour is a tint of (Tint mode).
struct Tint {
pub(crate) struct Tint {
swatch: String,
spot: bool,
/// 0..1.
@@ -375,20 +375,20 @@ impl Tint {
/// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with
/// the global swatch it is a tint of), or a selected freeform point.
enum Target {
pub(crate) enum Target {
Paint(Option<Color>, Option<Tint>),
Stop { paint: Paint, index: usize, color: Color, tint: Option<Tint> },
Point { index: usize, color: Color },
}
impl Target {
fn color(&self) -> Option<Color> {
pub(crate) fn color(&self) -> Option<Color> {
match self {
Target::Paint(c, _) => *c,
Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color),
}
}
fn tint(&self) -> Option<&Tint> {
pub(crate) fn tint(&self) -> Option<&Tint> {
match self {
Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(),
Target::Point { .. } => None,
@@ -425,9 +425,14 @@ fn target_with(app: &VectorcraftApp, mixed: bool) -> Target {
}
}
/// [`target`] for the Color Wheel panel, which edits what this panel's sliders edit.
pub(crate) fn wheel_target(app: &VectorcraftApp, ui: &Ui) -> Target {
target(app, ui)
}
/// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid
/// colour, keeping the active proxy in front.
fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) {
pub(crate) fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) {
match tgt {
Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => {
let mut stops = g.gradient.stops.clone();
+166
View File
@@ -0,0 +1,166 @@
//! Color Wheel panel (Window › Color Wheel): the Fill and Stroke proxy beside a hue ring around a
//! square with saturation across and brightness up at the ring's hue. The wheel edits what the
//! Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform
//! point) through the same paint commands, and a drag is one undo step. Alt paints the inactive
//! proxy and Shift steps the ring by 45°. A pick is display sRGB, converted into the working RGB
//! space: an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch
//! becomes a process color in the document's color mode. The HSB picked last stays while the
//! objects, the proxy, the color and the color settings are unchanged, so a gray keeps its hue.
use egui::{Ui, UiBuilder};
use vectorcraft_color::cms::{self, Cms};
use vectorcraft_color::{Color, Paint, hsb_to_rgb, rgb_to_hsb};
use vectorcraft_doc::{ColorMode, NodeId};
use super::color::{Target, apply, wheel_target};
use super::{pstate, set_pstate};
use crate::VectorcraftApp;
use crate::widgets::{self, FieldAxis, Live};
pub const ID: &str = "colorWheel";
/// The widest the wheel is drawn.
const MAX_WHEEL: f32 = 200.0;
/// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored.
const MEMORY: &str = "color-wheel-hsb";
/// What the HSB picked last belongs to: the document, the selected objects or text characters,
/// the appearance item, the proxy, the gradient stop or freeform point, its paint, and the color
/// the pick stored. The wheel shows that HSB only while all of them are unchanged.
#[derive(Clone, Debug, PartialEq)]
struct Owner {
doc: Option<u64>,
selection: Vec<NodeId>,
/// The Type tool's selected characters, when it edits the paint.
text_selection: Option<(NodeId, usize, usize)>,
/// The Appearance panel's active fill or stroke row.
appearance_item: Option<usize>,
fill: bool,
/// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`).
slot: Option<(bool, usize)>,
/// The gradient behind a selected stop or point, including its other colors.
gradient: Option<Paint>,
color: Color,
}
impl Owner {
fn of(app: &VectorcraftApp, tgt: &Target, color: Color) -> Self {
let st = app.session.active();
let slot = match tgt {
Target::Paint(..) => None,
Target::Stop { index, .. } => Some((false, *index)),
Target::Point { index, .. } => Some((true, *index)),
};
Self {
doc: st.map(|d| d.uid),
selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(),
text_selection: super::character::text_editing(app),
appearance_item: app.session.appearance_item(),
fill: app.session.fill_active,
slot,
gradient: slot.map(|_| super::active_paint(app)),
color,
}
}
}
/// The HSB a color shows on the wheel: that of its display sRGB through `cms`.
pub(crate) fn display_hsb(cms: &Cms, c: &Color) -> [f32; 3] {
rgb_to_hsb(cms.display_rgb(c))
}
/// The HSB the wheel shows for `c` through `cms` when `hsb` was picked last and `c` then displayed
/// as the sRGB `shown`: `hsb` while `c` still displays as `shown`, else `c`'s own display HSB (after
/// a change of the color settings or the document's profile).
pub(crate) fn remembered_hsb(cms: &Cms, c: &Color, shown: [f32; 3], hsb: [f32; 3]) -> [f32; 3] {
if cms.display_rgb(c) == shown { hsb } else { display_hsb(cms, c) }
}
/// The color a pick of display HSB `hsb` stores in place of `was`, through `cms`. The picked sRGB
/// goes into the working RGB space. An RGB color stays RGB, and a CMYK or Lab color converts to its
/// model with the color settings. Anything else (no color, a Gray color, a tint of a global swatch)
/// becomes a process color in the document's color mode: managed CMYK when `cmyk_doc`, else RGB.
pub(crate) fn stored(cms: &Cms, hsb: [f32; 3], was: Option<&Color>, cmyk_doc: bool) -> Color {
let [r, g, b] = cms.srgb_to_rgb(hsb_to_rgb(hsb));
let rgb = Color::rgb(r, g, b);
let cmyk = || {
let [c, m, y, k] = cms.to_cmyk(&rgb, cms.settings().intent);
Color::cmyk(c, m, y, k)
};
match was {
Some(Color::Rgb { .. }) => rgb,
Some(Color::Cmyk { .. }) => cmyk(),
Some(Color::Lab { .. }) => {
let l = cms.lab(&rgb);
Color::lab(l.l, l.a, l.b)
}
_ if cmyk_doc => cmyk(),
_ => rgb,
}
}
/// The color a pick replaces, which sets the model of the new one: the target's, or with Alt
/// (`behind`) the inactive proxy's solid color. None for a tint of a global swatch.
fn replaced(app: &VectorcraftApp, tgt: &Target, behind: bool) -> Option<Color> {
if !behind {
return tgt.color().filter(|_| tgt.tint().is_none());
}
let (fill, stroke) = super::current_paints(app);
match if app.session.fill_active { stroke } else { fill } {
Paint::Solid { color, swatch, .. } => {
let global = swatch.is_some_and(|n| app.session.active().is_some_and(|d| d.doc.global_color(&n).is_some()));
(!global).then_some(color)
}
_ => None,
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let tgt = wheel_target(app, ui);
let color = tgt.color();
let cms = cms::active();
// With None, a pattern or a "?" proxy the wheel shows the last solid color without markers,
// and a click, an arrow key or a screen reader's action applies the picked color.
let hsb = match (color, pstate::<Option<(Owner, [f32; 3], [f32; 3])>>(ui.ctx(), MEMORY)) {
(Some(c), Some((owner, shown, hsb))) if owner == Owner::of(app, &tgt, c) => remembered_hsb(&cms, &c, shown, hsb),
(c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)),
};
let marked = color.is_some();
let mut pick: Option<([f32; 3], Live)> = None;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(44.0);
super::proxy(app, ui, 40.0);
});
let size = ui.available_width().min(MAX_WHEEL);
let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, hsb[0], marked);
let square = widgets::ring_square(ring.rect);
let mut field_ui = ui.new_child(UiBuilder::new().id_salt("color-wheel-square").max_rect(square));
let axes =
[FieldAxis { name: tl!("Saturation"), units: 100.0, suffix: "%" }, FieldAxis { name: tl!("Brightness"), units: 100.0, suffix: "%" }];
let at = |x: f32, y: f32| widgets::hsb32([hsb[0], x, 1.0 - y]);
let name = tl!("Saturation and brightness");
let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), (hsb[1], 1.0 - hsb[2]), marked, &at);
if let Some((h, phase)) = ring.hue {
pick = Some(([h, hsb[1], hsb[2]], phase));
}
// A press between the square and the ring picks the closest point of the square, which
// takes the keyboard focus.
if let Some((p, phase)) = ring.inner {
let field_id = widgets::id_with(field_ui.scope_id(), "field");
field_ui.memory_mut(|m| m.request_focus(field_id));
let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0);
pick = Some(([hsb[0], f(p.x, square.left(), square.width()), 1.0 - f(p.y, square.top(), square.height())], phase));
}
if let Some((x, y)) = field {
pick = Some(([hsb[0], x, 1.0 - y], field_phase));
}
});
let Some((hsb, phase)) = pick else { return };
let behind = super::alt_held(ui);
let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk);
let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc);
apply(app, &tgt, c, phase, behind);
if !behind {
set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb)));
}
}
@@ -0,0 +1,583 @@
//! The Color Wheel panel drawn headless: Window › Color Wheel, picks on the hue ring and the
//! square, Shift and Alt, gradient stops, None, the remembered hue, the color model of a pick, the
//! arrow keys and what screen readers read.
use egui::accesskit::{Action, ActionData, ActionRequest};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use vectorcraft_color::cms::{self, Cms, ColorSettings, delta_e2000};
use vectorcraft_color::{Color, Paint};
use vectorcraft_engine::Session;
use super::color_wheel::{ID, display_hsb, remembered_hsb, show, stored};
use crate::VectorcraftApp;
/// An app with a selected rectangle filled with `fill`.
fn app(fill: serde_json::Value) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("paint.setFill", json!({"color": fill})).unwrap();
app
}
/// The panel in a 260-point-wide flyout, with the app's shortcuts after it as in a frame of the app.
fn harness(app: VectorcraftApp) -> Harness<'static, VectorcraftApp> {
let mut h = Harness::builder().with_size(vec2(260.0, 320.0)).build_ui_state(
|ui, app: &mut VectorcraftApp| {
show(app, ui);
crate::shortcuts::handle(app, ui.ctx());
},
app,
);
h.run_steps(2);
h
}
fn rect_of(h: &Harness<'static, VectorcraftApp>, label: &str) -> Rect {
h.get_by_label(label).rect()
}
/// The point at hue `deg` on the ring drawn in `ring`, `out` points outside the middle of its band.
fn on_ring(ring: Rect, deg: f32, out: f32) -> Pos2 {
let r = ring.width() / 2.0 - 4.0 - 7.0 + out;
let a = deg.to_radians();
ring.center() + vec2(a.cos(), -a.sin()) * r
}
/// The point (x across, y up; 0..1 each) of the square.
fn in_square(sq: Rect, x: f32, y: f32) -> Pos2 {
Pos2::new(sq.left() + x * sq.width(), sq.bottom() - y * sq.height())
}
fn press(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(pos));
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, modifiers: Modifiers) {
press(h, pos, true, modifiers);
press(h, pos, false, modifiers);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
/// A click whose press and release come in one frame (`Harness::event` gives each event a frame of
/// its own).
fn quick_click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
h.input_mut().events.extend([button(true), button(false)]);
h.run_steps(2);
}
/// A drag through `points`, pressed at the first and released at the last.
fn drag(h: &mut Harness<'static, VectorcraftApp>, points: &[Pos2], modifiers: Modifiers) {
let (Some(first), Some(last)) = (points.first(), points.last()) else { return };
press(h, *first, true, modifiers);
for p in points {
for _ in 0..2 {
h.event(Event::PointerMoved(*p));
h.run_steps(1);
}
}
press(h, *last, false, modifiers);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
fn key(h: &mut Harness<'static, VectorcraftApp>, key: egui::Key, modifiers: Modifiers) {
h.key_press_modifiers(modifiers, key);
h.run_steps(1);
}
fn fill(app: &VectorcraftApp) -> Color {
crate::panels::current_paints(app).0.color().expect("a solid fill")
}
fn hsb(c: &Color) -> [f32; 3] {
display_hsb(&cms::active(), c)
}
fn near(a: [f32; 3], b: [f32; 3], tol: [f32; 3]) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() <= tol[i])
}
fn undo_len(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().history.undo.len()
}
/// Window › Color Wheel opens the panel, and a click on the left of the ring turns a red fill cyan
/// at the same saturation and brightness.
#[test]
fn window_color_wheel_opens_the_panel_and_the_ring_picks_the_hue() {
let mut app = app(json!("#ff0000"));
app.run("window.panel", json!({"panel": ID})).unwrap();
assert!(crate::panel_docking::showing(&app.ui, ID));
assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Color Wheel"));
let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color Wheel");
assert!(item.is_some(), "the Window menu lists it");
let mut h = harness(app);
let ring = rect_of(&h, "Hue ring");
let undo = undo_len(h.state());
click(&mut h, on_ring(ring, 180.0, 0.0), Modifiers::NONE);
assert_eq!(fill(h.state()).to_hex(), "#00ffff");
assert_eq!(undo_len(h.state()), undo + 1, "one undo step");
}
#[test]
fn the_square_picks_saturation_across_and_brightness_up() {
let mut h = harness(app(json!([0.6, 0.3, 0.3])));
let sq = rect_of(&h, "Saturation and brightness");
click(&mut h, sq.right_top() + vec2(-0.5, 0.5), Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state())));
click(&mut h, in_square(sq, 0.25, 0.5), Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [0.0, 0.25, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state())));
}
/// A press between the square and the ring's inner edge picks the closest point of the square.
#[test]
fn a_press_inside_the_ring_beside_the_square_picks_its_closest_point() {
let mut h = harness(app(json!([0.6, 0.3, 0.3])));
let sq = rect_of(&h, "Saturation and brightness");
// 1.4 points beyond the corner along the diagonal: the band's inner edge is 2 points out.
click(&mut h, sq.right_top() + vec2(1.0, -1.0), Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 1e-4, 1e-4]), "{:?}", hsb(&fill(h.state())));
}
/// The part a press starts on keeps the drag: from the ring into the square only the hue changes.
#[test]
fn a_drag_from_the_ring_into_the_square_changes_only_the_hue() {
let mut h = harness(app(json!([0.6, 0.3, 0.3])));
let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness"));
let before = hsb(&fill(h.state()));
drag(&mut h, &[on_ring(ring, 90.0, 0.0), sq.center() + vec2(-10.0, 0.0)], Modifiers::NONE);
let after = hsb(&fill(h.state()));
assert!(near(after, [180.0, before[1], before[2]], [0.5, 1e-3, 1e-3]), "{before:?} → {after:?}");
}
/// A press up to 4 points outside the band still picks a hue; one in the rect's corner does nothing.
#[test]
fn a_press_just_outside_the_ring_picks_a_hue_and_the_corners_do_nothing() {
let mut h = harness(app(json!("#ff0000")));
let ring = rect_of(&h, "Hue ring");
click(&mut h, on_ring(ring, 180.0, 7.0 + 3.0), Modifiers::NONE);
assert_eq!(fill(h.state()).to_hex(), "#00ffff");
click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE);
assert_eq!(fill(h.state()).to_hex(), "#00ffff");
}
/// Each click takes the part of the ring under it, also when its press and release come in one
/// frame, and a click on the band gives the ring the keyboard focus.
#[test]
fn a_click_in_one_frame_takes_the_part_under_it() {
let mut h = harness(app(json!([0.6, 0.3, 0.3])));
let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness"));
click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE);
quick_click(&mut h, on_ring(ring, 120.0, 0.0));
assert!(near(hsb(&fill(h.state())), [120.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "after a corner: {:?}", hsb(&fill(h.state())));
key(&mut h, egui::Key::ArrowRight, Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [121.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "focused: {:?}", hsb(&fill(h.state())));
quick_click(&mut h, sq.right_top() + vec2(1.0, -1.0));
assert!(near(hsb(&fill(h.state())), [121.0, 1.0, 1.0], [0.5, 1e-3, 1e-3]), "after the band: {:?}", hsb(&fill(h.state())));
}
#[test]
fn shift_drag_on_the_ring_steps_the_hue_by_45_degrees() {
let mut h = harness(app(json!("#ff0000")));
let ring = rect_of(&h, "Hue ring");
drag(&mut h, &[on_ring(ring, 10.0, 0.0), on_ring(ring, 60.0, 0.0), on_ring(ring, 100.0, 0.0)], Modifiers::SHIFT);
assert!(near(hsb(&fill(h.state())), [90.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
}
#[test]
fn alt_click_paints_the_stroke_and_the_fill_stays_in_front() {
let mut app = app(json!("#ff0000"));
app.run("paint.setStroke", json!({"color": "#808080", "focus": false})).unwrap();
assert!(app.session.fill_active);
let mut h = harness(app);
let ring = rect_of(&h, "Hue ring");
click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::ALT);
let (f, s) = crate::panels::current_paints(h.state());
assert_eq!((f.color().unwrap().to_hex(), s.color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into()));
assert!(h.state().session.fill_active, "the fill stays in front");
}
#[test]
fn a_pick_recolors_the_selected_gradient_stop() {
let mut app = app(json!("#ff0000"));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let mut h = harness(app);
let ring = rect_of(&h, "Hue ring");
click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::NONE);
let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") };
let hex: Vec<String> = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect();
assert_eq!(hex, ["#ff0000", "#00ff00"]);
}
/// With None there is no color to edit: no markers, and a click sets the fill.
#[test]
fn with_a_none_fill_the_wheel_has_no_marker_and_a_click_sets_the_fill() {
let mut app = app(json!("#ff0000"));
app.run("paint.setFill", json!({"none": true})).unwrap();
let mut h = harness(app);
assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker");
let sq = rect_of(&h, "Saturation and brightness");
click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE);
let c = fill(h.state());
assert!(near(hsb(&c), [hsb(&h.state().session.last_solid)[0], 0.5, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&c));
}
/// With None the ring and the square still take a screen reader's actions and the arrow keys. They
/// start from the last solid color the wheel shows, and the result sets the fill.
#[test]
fn with_a_none_fill_screen_readers_and_the_arrow_keys_set_the_fill() {
let mut app = app(json!("#ff0000"));
app.run("paint.setFill", json!({"none": true})).unwrap();
let mut h = harness(app);
let last = hsb(&h.state().session.last_solid);
let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate();
let data = Some(ActionData::NumericValue(200.0));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [200.0, last[1], last[2]], [0.5, 2e-3, 2e-3]), "{:?}", hsb(&fill(h.state())));
// A click focuses the square; with the fill set to None again the arrows still step it.
let sq = rect_of(&h, "Saturation and brightness");
click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE);
h.state_mut().run("paint.setFill", json!({"none": true})).unwrap();
h.run_steps(1);
assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker");
let last = hsb(&h.state().session.last_solid);
key(&mut h, egui::Key::ArrowLeft, Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, Modifiers::NONE);
let after = hsb(&fill(h.state()));
assert!(near(after, [last[0], last[1] - 0.01, last[2] - 0.01], [0.5, 2e-3, 2e-3]), "{last:?} → {after:?}");
}
/// The hue stays while the square passes through a gray: a drag to the left edge (white, whose own
/// hue is 0°) and back ends at the color a click at its end gives, for an RGB and for a CMYK fill
/// (whose stored color differs from the picked RGB), and an RGB green is green again.
#[test]
fn a_gray_keeps_the_hue_it_was_dragged_from() {
for fill_json in [json!("#00ff00"), json!({"c": 0.8, "m": 0.1, "y": 0.9, "k": 0.0})] {
let mut h = harness(app(fill_json.clone()));
let before = hsb(&fill(h.state()));
let sq = rect_of(&h, "Saturation and brightness");
// Inside the square's edges; the drag past its left edge picks saturation 0.
let at = |x: f32| in_square(sq, x.clamp(0.005, 0.995), before[2].clamp(0.005, 0.995));
let gray = Pos2::new(sq.left() - 5.0, at(0.0).y);
drag(&mut h, &[at(before[1]), gray, at(before[1])], Modifiers::NONE);
let dragged = fill(h.state());
let mut direct = harness(app(fill_json.clone()));
click(&mut direct, at(before[1]), Modifiers::NONE);
assert_eq!(dragged.to_hex(), fill(direct.state()).to_hex(), "{fill_json}: {before:?} → {:?}", hsb(&dragged));
assert!((hsb(&dragged)[0] - before[0]).abs() < 5.0, "{fill_json}: {before:?} → {:?}", hsb(&dragged));
}
let mut h = harness(app(json!("#00ff00")));
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() + 1.0), in_square(sq, 0.995, 0.995)], Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [120.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state())));
}
/// The remembered hue belongs to the object it was picked on: another object of the same color
/// shows its own hue.
#[test]
fn the_remembered_hue_stays_with_its_object() {
let mut app = app(json!("#00ff00"));
let a = app.session.active().unwrap().selection.objects[0].0;
let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"color": "#ffffff", "ids": [b]})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
// Green to white (saturation 0 at the top edge): the wheel keeps hue 120°.
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
assert_eq!(fill(h.state()), Color::WHITE);
h.state_mut().run("select.set", json!({"ids": [b]})).unwrap();
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let hue = hsb(&fill(h.state()))[0];
assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}");
}
/// Equal colors on two appearance fills have separate remembered hues. A square pick on the
/// second white fill uses its own 0° hue after the first fill was changed from green to white.
#[test]
fn the_remembered_hue_stays_with_its_appearance_item() {
let mut app = app(json!("#00ff00"));
let id = app.session.active().unwrap().selection.objects[0];
app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill]
app.run("paint.setFill", json!({"item": 2, "color": "#ffffff"})).unwrap();
app.run("appearance.setActiveItem", json!({"index": 0})).unwrap();
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
let item_fill = |app: &VectorcraftApp, index| {
app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_at(Some(index)).unwrap().paint.color().unwrap()
};
assert_eq!(item_fill(h.state(), 0), Color::WHITE);
assert_eq!(item_fill(h.state(), 2), Color::WHITE);
h.state_mut().run("appearance.setActiveItem", json!({"index": 2})).unwrap();
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let hue = hsb(&item_fill(h.state(), 2))[0];
assert!(!(1.0..359.0).contains(&hue), "the second fill's own hue is 0°, not 120°: {hue}");
assert_eq!(item_fill(h.state(), 0), Color::WHITE);
}
/// The Type tool can recolor a selected character range without changing the selected object.
/// The next range of the same color starts with its own hue.
#[test]
fn the_remembered_hue_stays_with_its_text_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 100, "text": "AB", "color": "#00ff00"})).unwrap()["id"].as_u64().unwrap();
app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "fill": "#ffffff"})).unwrap();
app.select_tool("type");
app.session.set_tool_option("textSelection", &json!({"id": id, "start": 0, "end": 1}));
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
h.state_mut().session.set_tool_option("textSelection", &json!({"id": id, "start": 1, "end": 2}));
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let node = h.state().session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("still text") };
assert_eq!(t.runs.first().unwrap().style.fill.color(), Some(Color::WHITE));
let hue = hsb(&t.runs.last().unwrap().style.fill.color().unwrap())[0];
assert!(!(1.0..359.0).contains(&hue), "the second character's own hue is 0°, not 120°: {hue}");
}
/// Replacing a gradient can keep the same selected stop and color while changing the paint it
/// belongs to. The new gradient starts with that stop's own hue.
#[test]
fn the_remembered_hue_stays_with_its_gradient() {
let mut app = app(json!("#00ff00"));
let first = json!([{"offset": 0, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]);
app.run("paint.setFill", json!({"gradient": {"stops": first}})).unwrap();
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
let second = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]);
h.state_mut().run("paint.setFill", json!({"gradient": {"stops": second}})).unwrap();
assert_eq!(h.state().session.selected_stop(), Some(0));
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") };
let hue = hsb(&g.gradient.stops[0].color)[0];
assert!(!(1.0..359.0).contains(&hue), "the new gradient's white stop has hue 0°, not 120°: {hue}");
}
/// The remembered HSB holds only while the color displays as it did when picked: in another
/// working RGB space the wheel shows the color's own HSB there.
#[test]
fn the_remembered_hsb_follows_a_change_of_working_space() {
let srgb = Cms::default();
let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap();
let c = Color::rgb(0.5, 0.3, 0.2);
let picked = [25.0, 0.6, 0.5];
let shown = srgb.display_rgb(&c);
assert_eq!(remembered_hsb(&srgb, &c, shown, picked), picked);
let own = display_hsb(&p3, &c);
assert_ne!(own, picked);
assert_eq!(remembered_hsb(&p3, &c, shown, picked), own);
}
/// A CMYK fill stays CMYK and keeps its look (ΔE 2000 under 2) when its marker is clicked; the
/// working profile separates the picked color again, so its inks can move.
#[test]
fn a_cmyk_fill_stays_cmyk_and_keeps_its_look() {
let [c, m, y, k] = Color::rgb(0.2, 0.5, 0.7).to_cmyk_managed(cms::active().settings().intent);
for inks in [[c, m, y, k], [0.2, 0.2, 0.2, 0.0]] {
let mut h = harness(app(json!({"c": inks[0], "m": inks[1], "y": inks[2], "k": inks[3]})));
let before = fill(h.state());
let [_, s, b] = hsb(&before);
let sq = rect_of(&h, "Saturation and brightness");
click(&mut h, in_square(sq, s, b), Modifiers::NONE);
let after = fill(h.state());
assert!(matches!(after, Color::Cmyk { .. }), "{after:?}");
let cms = cms::active();
assert!(delta_e2000(cms.lab(&before), cms.lab(&after)) < cms::GAMUT_THRESHOLD, "{before:?} → {after:?}");
}
}
/// A Gray fill becomes a process color in the document's color mode.
#[test]
fn a_gray_fill_becomes_rgb_or_cmyk_with_the_document() {
for (mode, rgb) in [("rgb", true), ("cmyk", false)] {
let mut app = app(json!("#ff0000"));
app.run("file.documentColorMode", json!({"mode": mode})).unwrap();
app.run("paint.setFill", json!({"color": {"gray": 0.5}})).unwrap();
let mut h = harness(app);
let ring = rect_of(&h, "Hue ring");
click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE);
let c = fill(h.state());
assert_eq!(matches!(c, Color::Rgb { .. }), rgb, "{mode}: {c:?}");
assert_eq!(matches!(c, Color::Cmyk { .. }), !rgb, "{mode}: {c:?}");
}
}
/// In a working RGB space wider than sRGB a pick goes into it through the color settings, so a hue
/// change keeps the displayed saturation and brightness.
#[test]
fn a_pick_in_display_p3_keeps_the_components_it_does_not_change() {
let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap();
let was = Color::rgb(0.5, 0.3, 0.2);
let [h, s, b] = display_hsb(&p3, &was);
let new = stored(&p3, [h + 40.0, s, b], Some(&was), false);
let [h2, s2, b2] = display_hsb(&p3, &new);
assert!(matches!(new, Color::Rgb { .. }));
assert!((h2 - (h + 40.0)).abs() < 1.0 && (s2 - s).abs() < 0.01 && (b2 - b).abs() < 0.01, "{:?} → {:?}", [h, s, b], [h2, s2, b2]);
}
/// A press gives the ring or the square the keyboard focus. The arrows then step the color, each
/// press one undo step, the app's tool keys wait, and after Escape they work again.
#[test]
fn the_arrow_keys_step_the_focused_ring_or_square() {
let mut h = harness(app(json!("#ff0000")));
let ring = rect_of(&h, "Hue ring");
click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE);
let undo = undo_len(h.state());
key(&mut h, egui::Key::ArrowRight, Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [1.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
key(&mut h, egui::Key::ArrowUp, Modifiers::SHIFT);
assert!(near(hsb(&fill(h.state())), [11.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
assert_eq!(undo_len(h.state()), undo + 2, "a step each");
key(&mut h, egui::Key::ArrowLeft, Modifiers::SHIFT);
key(&mut h, egui::Key::ArrowDown, Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, Modifiers::NONE);
assert!(near(hsb(&fill(h.state())), [359.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "wraps: {:?}", hsb(&fill(h.state())));
let sq = rect_of(&h, "Saturation and brightness");
click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE);
let before = hsb(&fill(h.state()));
key(&mut h, egui::Key::ArrowUp, Modifiers::NONE);
key(&mut h, egui::Key::ArrowRight, Modifiers::NONE);
let after = hsb(&fill(h.state()));
assert!(near(after, [before[0], before[1] + 0.01, before[2] + 0.01], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}");
// The tool keys wait while a control has the focus.
let tool = h.state().session.tool_id().to_string();
let p = |h: &mut Harness<'static, VectorcraftApp>| {
h.event(Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.event(Event::Text("p".into()));
h.run_steps(1);
};
p(&mut h);
assert_eq!(h.state().session.tool_id(), tool);
key(&mut h, egui::Key::Escape, Modifiers::NONE);
p(&mut h);
assert_eq!(h.state().session.tool_id(), "pen");
}
/// Screen readers find the ring as a slider from 0 to 360 that their actions move, and the square
/// with both of its components, whose actions move its saturation as → and ← do.
#[test]
fn screen_readers_read_and_move_the_ring_and_the_square() {
let mut h = harness(app(json!([0.6, 0.3, 0.3])));
let ring = h.get_by_label("Hue ring");
let node = ring.accesskit_node();
assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(360.0), Some(1.0)));
assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a)));
assert_eq!(node.numeric_value(), Some(0.0));
let (target_node, target_tree) = node.locate();
assert_eq!(h.get_by_label("Saturation and brightness").value().as_deref(), Some("Saturation 50%, Brightness 60%"));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [1.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
let data = Some(ActionData::NumericValue(200.0));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [200.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
let square = h.get_by_label("Saturation and brightness");
let node = square.accesskit_node();
assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(100.0), Some(1.0)));
assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a)));
assert!(node.numeric_value().is_some_and(|v| (v - 50.0).abs() < 1e-3), "{:?}", node.numeric_value());
let (target_node, target_tree) = node.locate();
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [200.0, 0.51, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
let data = Some(ActionData::NumericValue(20.0));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [200.0, 0.2, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Decrement, data: None }));
h.run_steps(1);
assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state())));
}
#[test]
fn a_huge_set_value_on_the_ring_wraps_like_any_other_hue() {
let mut h = harness(app(json!([0.3, 0.6, 0.3])));
let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate();
let data = Some(ActionData::NumericValue(1e300));
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data }));
h.run_steps(1);
let color = fill(h.state());
let Color::Rgb { r, g, b } = color else { panic!("expected RGB fill: {color:?}") };
assert!([r, g, b].iter().all(|v| v.is_finite()), "{color:?}");
assert!(hsb(&color).iter().all(|v| v.is_finite()), "{:?}", hsb(&color));
assert!(near(hsb(&color), [0.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&color));
}
/// A step past the edge of the square leaves the fill alone: a CMYK cyan shows at 100% saturation,
/// and a step up from there neither separates it again nor adds an undo step.
#[test]
fn a_step_past_the_edge_of_the_square_changes_nothing() {
let mut h = harness(app(json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0})));
let before = fill(h.state());
assert_eq!(hsb(&before)[1], 1.0, "{:?}", hsb(&before));
let undo = undo_len(h.state());
let (target_node, target_tree) = h.get_by_label("Saturation and brightness").accesskit_node().locate();
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None }));
h.run_steps(1);
assert_eq!(fill(h.state()), before);
assert_eq!(undo_len(h.state()), undo, "no undo step");
}
/// The Color Picker's color field is the same widget: a press focuses it, the arrows step the
/// components it shows, and screen readers read its name and both components and step the first.
#[test]
fn the_color_pickers_field_takes_the_arrow_keys() {
let mut app = app(json!([0.6, 0.3, 0.3]));
app.run("ui.colorPicker", json!({})).unwrap();
let mut fonts = false;
let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
// The fonts take effect from the next frame.
if !fonts {
crate::theme::install_fonts(ui.ctx());
fonts = true;
return;
}
crate::dialogs::show(app, ui.ctx());
},
app,
);
h.run_steps(3);
let field = rect_of(&h, "Color field");
assert_eq!(h.get_by_label("Color field").value().as_deref(), Some("Saturation 50%, Brightness 60%"));
click(&mut h, field.center(), Modifiers::NONE);
let picked = |h: &Harness<'static, VectorcraftApp>| -> [f32; 3] {
serde_json::from_value(h.state().ui.dialog.as_ref().unwrap().fields["__hsb"].clone()).unwrap()
};
let before = picked(&h);
key(&mut h, egui::Key::ArrowRight, Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, Modifiers::SHIFT);
let after = picked(&h);
assert!(near(after, [before[0], before[1] + 0.01, before[2] - 0.1], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}");
let (target_node, target_tree) = h.get_by_label("Color field").accesskit_node().locate();
h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None }));
h.run_steps(1);
let stepped = picked(&h);
assert!(near(stepped, [after[0], after[1] + 0.01, after[2]], [1e-3, 1e-4, 1e-4]), "{after:?} → {stepped:?}");
assert!(h.state().ui.dialog.is_some(), "the dialog stays open");
}
+5 -4
View File
@@ -36,8 +36,8 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid));
fields.insert("__taskBar".into(), json!(app.ui.task_bar));
// Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (VectorCraft Title Bar on
// Every Desktop's note).
// Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed
// row and VectorCraft Title Bar on Every Desktop's note).
fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env));
fields.insert("__search".into(), json!(""));
fields.insert("__focusSearch".into(), json!(true));
@@ -483,7 +483,8 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
// Windows and Linux only: macOS always has the system's title bar, the web has none.
PrefKind::Bool if sp.key == "systemTitleBar" => {
if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
bool_row(ui, d, sp.key, sp.label);
// VECTORCRAFT_TITLEBAR chooses the frame while it is set: the checkbox is dimmed.
ui.add_enabled_ui(!d.bool("__titlebarFromEnv"), |ui| bool_row(ui, d, sp.key, sp.label));
// The window's decorations are chosen once, when the app starts. On Linux and the
// BSDs the note goes under the next row, which also applies at the next start.
if !APP_TITLE_BAR_SHOWN {
@@ -499,7 +500,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
let from_env = d.bool("__titlebarFromEnv");
let system = d.bool("systemTitleBar");
ui.add_enabled_ui(!from_env && !system, |ui| bool_row(ui, d, sp.key, sp.label));
if system {
if system && !from_env {
let note = tl!("System Title Bar takes precedence over this option while it is on.");
ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0));
}
+39 -2
View File
@@ -336,7 +336,8 @@ fn app_title_bar_preference_shows_and_persists() {
/// VectorCraft Title Bar on Every Desktop does not take clicks while System Title Bar is on or
/// VECTORCRAFT_TITLEBAR is set. With the variable set its note names the variable in place of the
/// next-launch note, and with System Title Bar on a note says that System Title Bar takes precedence.
/// next-launch note, and with System Title Bar on and the variable unset a note says that System
/// Title Bar takes precedence.
#[test]
fn app_title_bar_is_dimmed_while_something_takes_precedence() {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
@@ -364,7 +365,7 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() {
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}");
assert_eq!(shown.iter().any(|t| t == system_note), supported && system, "{shown:?}");
assert_eq!(shown.iter().any(|t| t == system_note), supported && system && !from_env, "{shown:?}");
// The next-launch note stays under this row on Linux and the BSDs, where it also covers
// System Title Bar, and under System Title Bar on Windows.
let restart_shown = if supported { !from_env } else { cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) };
@@ -392,6 +393,42 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() {
}
}
/// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar does not take clicks.
#[test]
fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame() {
let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")));
for from_env in [false, true] {
let mut a = app();
a.titlebar_from_env = from_env;
open(&mut a, Some("User Interface"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let label = out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()),
_ => None,
});
assert_eq!(label.is_some(), supported);
if let Some(r) = label {
for pressed in [true, false] {
let input = egui::RawInput {
events: vec![
egui::Event::PointerMoved(r.center()),
egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
],
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
}
assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}");
}
}
}
/// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and
/// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop
/// app reads before the window opens.
+1
View File
@@ -201,6 +201,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("attributes", "Attributes", "settings"),
(crate::panels::svg_interactivity::ID, "SVG Interactivity", "spline-pointer"),
("colorThemes", "Color Themes", "sun"),
(crate::panels::color_wheel::ID, "Color Wheel", "circle"),
(crate::panels::links::ID, "Links", "link"),
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
(crate::panels::css_properties::ID, "CSS Properties", "globe"),
+46 -16
View File
@@ -5,7 +5,7 @@ use serde_json::Value;
use crate::VectorcraftApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Updater};
use craft_update::updater::{Action, Status, Updater};
#[cfg(not(target_arch = "wasm32"))]
use craft_update::{CheckResultDialog, CheckResultResponse};
@@ -20,10 +20,10 @@ pub(crate) struct Runtime {
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
/// Whether the result of Help ▸ Check for Updates is in view.
pub(crate) manual_window_open: bool,
/// The web build has no updater; its window only says so.
#[cfg(target_arch = "wasm32")]
pub(crate) manual_window_open: bool,
#[cfg(target_arch = "wasm32")]
pub(crate) status_message: Option<String>,
}
@@ -38,6 +38,7 @@ impl Default for Runtime {
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
#[cfg(target_arch = "wasm32")]
manual_window_open: false,
#[cfg(target_arch = "wasm32")]
status_message: None,
@@ -51,13 +52,20 @@ impl VectorcraftApp {
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
/// Starts the install helper of an update that is ready, so it installs once the process has
/// exited (does nothing when no update is ready). Call it in `on_exit` when the process ends
/// there, since the app is then never dropped.
#[cfg(not(target_arch = "wasm32"))]
pub fn start_pending_update(&mut self) {
self.updates_rt.updater.start_pending_update();
}
}
pub(crate) fn check(app: &mut VectorcraftApp) -> Result<Value, String> {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
app.updates_rt.manual_window_open = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
@@ -87,10 +95,18 @@ pub(crate) fn tick(app: &mut VectorcraftApp, ctx: &egui::Context) {
let _ = (app, ctx);
}
/// Whether the check result window shows: for every status but idle (a requested check, and the
/// first start after an update, which reports that it installed or why it failed), and never
/// over the startup prompt.
#[cfg(not(target_arch = "wasm32"))]
fn result_window_shows(status: &Status, prompt_open: bool) -> bool {
!prompt_open && !matches!(status, Status::Idle)
}
pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
// The close for a ready update waits until no document holds unsaved work.
let clean = !crate::unsaved::any_dirty(app);
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => app.open_url(&url),
@@ -99,20 +115,15 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
// An available update goes to the prompt above, which replaces the result dialog.
let rt = &mut app.updates_rt;
if rt.manual_window_open && rt.updater.prompt_open() {
rt.manual_window_open = false;
}
if rt.manual_window_open {
if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) {
match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) {
CheckResultResponse::None => {}
CheckResultResponse::Close => rt.manual_window_open = false,
// Closing the dialog dismisses the outcome it showed.
CheckResultResponse::Close => rt.updater.dismiss_status(),
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
}
}
// Closing the dialog dismisses the outcome it showed.
if !rt.manual_window_open {
rt.updater.dismiss_status();
}
}
#[cfg(target_arch = "wasm32")]
@@ -142,13 +153,12 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::Status;
use vectorcraft_engine::Session;
#[test]
fn default_runtime_is_idle() {
let rt = Runtime::default();
#[cfg(target_arch = "wasm32")]
assert!(!rt.manual_window_open);
#[cfg(not(target_arch = "wasm32"))]
{
@@ -158,6 +168,26 @@ mod tests {
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn result_window_shows_every_status_but_idle() {
let shown = [
Status::Checking,
Status::UpToDate,
Status::Failed("offline".into()),
Status::Installing,
Status::Ready,
Status::Managed,
Status::Updated("99.0.0".into()),
Status::InstallFailed("locked".into()),
];
for status in &shown {
assert!(result_window_shows(status, false), "{status:?} shows");
assert!(!result_window_shows(status, true), "{status:?} waits for the prompt");
}
assert!(!result_window_shows(&Status::Idle, false));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn startup_prompt_shows_and_closes() {
+4 -4
View File
@@ -33,12 +33,12 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s
#[cfg(test)]
use chips::gradient_chip_colors;
pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile};
pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase};
pub use color::{
Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider,
color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color,
segmented_wheel, stepped_area,
FieldAxis, Live, RingResponse, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh,
color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, hsb32, hue_ring,
region_texture, ring_square, segment_color, segmented_wheel, stepped_area,
};
pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase};
pub use drag::{
PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click,
};
+257 -12
View File
@@ -1,5 +1,5 @@
use super::labels::{label_field, name_field, take_previous_label};
use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple};
use super::numeric::{STEP_DECIMALS, STEPS, field_text, round_to, step_to_multiple};
use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus};
use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2};
@@ -320,31 +320,130 @@ fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(
}
}
/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y
/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase.
/// One axis of a [`color_field`] as keys and screen readers see it: the component's name, how many
/// of its units the axis spans (an arrow press moves one unit) and the units' suffix.
#[derive(Clone, Copy, Debug)]
pub struct FieldAxis<'a> {
pub name: &'a str,
pub units: f32,
pub suffix: &'a str,
}
/// A press on `resp`, or a click whose press and release came in one frame, gives it the keyboard
/// focus, so the arrow keys reach it. Escape or a click elsewhere takes the focus away again.
fn focus_on_press(resp: &Response) {
if resp.is_pointer_button_down_on() || resp.clicked() {
resp.request_focus();
}
}
/// The arrow keys pressed for control `id` this frame while it has the keyboard focus, in units
/// ([`STEPS`]: ten with Shift, a tenth with Ctrl/Cmd, else one): (→ minus ←, ↑ minus ↓). The control
/// takes the arrows of each axis it uses (`horizontal`, `vertical`), which then do not move the focus
/// to the next widget.
fn arrow_steps(ui: &Ui, id: egui::Id, horizontal: bool, vertical: bool) -> (f32, f32) {
if !ui.memory(|m| m.has_focus(id)) {
return (0.0, 0.0);
}
use egui::Key;
let filter = egui::EventFilter { horizontal_arrows: horizontal, vertical_arrows: vertical, ..Default::default() };
ui.memory_mut(|m| m.set_focus_lock_filter(id, filter));
let (mut x, mut y) = (0.0, 0.0);
ui.input_mut(|i| {
for (mods, size) in STEPS {
let mut count = |key| i.count_and_consume_key(mods, key) as f64 * size;
if horizontal {
x += count(Key::ArrowRight) - count(Key::ArrowLeft);
}
if vertical {
y += count(Key::ArrowUp) - count(Key::ArrowDown);
}
}
});
(x as f32, y as f32)
}
/// The focus outline of a focused color control.
fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) {
if ui.memory(|m| m.has_focus(resp.id)) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(rect.expand(2.0), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Outside);
}
}
/// A 2D color field (the Color Picker's, the Color Wheel panel's square): `color_at(x, y)` gives
/// the color at a point (0..1 each, y down) and, when `marked`, a ring marks `pos`. A press gives
/// it the keyboard focus. While it has the focus, → and ← move `pos` across by one unit of
/// `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`, marked or not. Screen
/// readers read `name` and, when `marked`, both components. They read it as a slider over
/// `axes[0]`'s units (0 when not `marked`), which their increment and decrement actions move as →
/// and ← do and their set-value action sets, as for a [`hue_ring`]. Returns the clicked or dragged
/// point, or the point the keys or actions moved to, and the phase. When `marked`, a step past an
/// edge returns no point.
pub fn color_field(
ui: &mut Ui,
id: impl std::hash::Hash,
(name, axes): (&str, [FieldAxis<'_>; 2]),
size: Vec2,
pos: (f32, f32),
marked: bool,
color_at: &dyn Fn(f32, f32) -> Color32,
) -> (Option<(f32, f32)>, Live) {
use egui::accesskit::{Action, ActionData};
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag());
focus_on_press(&resp);
let painter = ui.painter_at(rect.expand(6.0));
painter.add(color_mesh(rect, (32, 32), color_at));
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height());
painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK));
painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE));
match pointer_phase(&resp) {
Some((p, phase)) => {
let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0);
(Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase)
}
None => (None, Live::Idle),
focus_outline(ui, &resp, rect);
let (x, y) = pos;
if marked {
let c = pos2(rect.left() + x.clamp(0.0, 1.0) * rect.width(), rect.top() + y.clamp(0.0, 1.0) * rect.height());
painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK));
painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE));
}
let value = |v: f32, a: &FieldAxis| format!("{}{}", (v.clamp(0.0, 1.0) * a.units).round(), a.suffix);
let text = marked.then(|| {
crate::i18n::fmt(
tl!("{name1} {value1}, {name2} {value2}"),
&[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))],
)
});
let shown = if marked { f64::from(x.clamp(0.0, 1.0) * axes[0].units) } else { 0.0 };
resp.widget_info(|| egui::WidgetInfo { current_text_value: text.clone(), ..egui::WidgetInfo::slider(ui.is_enabled(), shown, name) });
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_min_numeric_value(0.0);
node.set_max_numeric_value(f64::from(axes[0].units));
node.set_numeric_value_step(1.0);
for action in [Action::SetValue, Action::Increment, Action::Decrement] {
node.add_action(action);
}
});
let (dx, dy) = arrow_steps(ui, resp.id, true, true);
let (steps, set) = ui.input(|i| {
let steps =
i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32;
let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data {
Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32),
_ => None,
});
(steps, set)
});
if let Some((p, phase)) = pointer_phase(&resp) {
let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0);
return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase);
}
let across = set.map_or(x + (dx + steps) / axes[0].units, |v| v / axes[0].units);
let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0));
// A step past an edge leaves the point where it is. Without a marker there is no color yet, so
// a key or an action applies even when the point stays.
let asked = set.is_some() || dx + steps != 0.0 || dy != 0.0;
if moved != (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)) || (asked && !marked) {
return (Some(moved), Live::Released);
}
(None, Live::Idle)
}
/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1
@@ -628,6 +727,152 @@ fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape {
egui::Shape::mesh(mesh)
}
/// The width of a [`hue_ring`]'s band.
const RING_WIDTH: f32 = 14.0;
/// How far inside its rect a [`hue_ring`]'s band is drawn: a press up to this far outside the band
/// still picks a hue.
const RING_MARGIN: f32 = 4.0;
/// The display sRGB of HSB `[h°, s, v]` as an egui color, with no color conversion.
pub fn hsb32(hsb: [f32; 3]) -> Color32 {
let [r, g, b] = vectorcraft_color::hsb_to_rgb(hsb).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
Color32::from_rgb(r, g, b)
}
/// The square a [`hue_ring`] drawn in `rect` holds: the largest one inside the band's inner edge,
/// 2 points clear of it.
pub fn ring_square(rect: Rect) -> Rect {
let inner = rect.width().min(rect.height()) / 2.0 - RING_MARGIN - RING_WIDTH - 2.0;
Rect::from_center_size(rect.center(), Vec2::splat(inner.max(0.0) * std::f32::consts::SQRT_2))
}
/// The part of a [`hue_ring`] a press started on.
#[derive(Clone, Copy, Debug, PartialEq)]
enum RingPart {
Band,
Inner,
Corner,
}
/// What a [`hue_ring`] did this frame.
pub struct RingResponse {
/// The rect the ring is drawn in.
pub rect: Rect,
/// A new hue (degrees, 0..360) from a press or drag on the band, an arrow key or a screen
/// reader, and its phase.
pub hue: Option<(f32, Live)>,
/// The pointer of a press or drag that started inside the band's inner edge, and its phase.
pub inner: Option<(Pos2, Live)>,
}
/// A hue ring `size` across: a band of hues drawn [`RING_MARGIN`] inside its rect, the hue
/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` when
/// `marked`. A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the
/// pointer (with Shift, in 45° steps) and gives the ring the keyboard focus. A press inside the
/// band's inner edge comes back as `inner`, and a press in the rect's corners does nothing. The part
/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise `hue` by
/// 1° and ← and ↓ lower it ([`STEPS`]), marked or not, and 360° wraps to 0°. Screen readers read it
/// as a slider named `name` from 0 to 360 (0 when not `marked`), which their increment, decrement
/// and set-value actions move.
pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: f32, marked: bool) -> RingResponse {
use egui::accesskit::{Action, ActionData};
const SEG: u32 = 72;
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag());
let c = rect.center();
let outer = size / 2.0 - RING_MARGIN;
let inner = outer - RING_WIDTH;
let mut mesh = egui::Mesh::default();
for k in 0..=SEG {
let h = k as f32 * 360.0 / SEG as f32;
let col = hsb32([h, 1.0, 1.0]);
mesh.colored_vertex(wheel_pos(c, inner, h, 1.0), col);
mesh.colored_vertex(wheel_pos(c, outer, h, 1.0), col);
}
for k in 0..SEG {
let a = 2 * k;
mesh.add_triangle(a, a + 1, a + 2);
mesh.add_triangle(a + 1, a + 3, a + 2);
}
let painter = ui.painter_at(rect);
painter.add(egui::Shape::mesh(mesh));
painter.circle_stroke(c, outer, Stroke::new(1.0, t.border));
painter.circle_stroke(c, inner, Stroke::new(1.0, t.border));
if ui.memory(|m| m.has_focus(resp.id)) {
painter.circle_stroke(c, outer + 2.0, Stroke::new(1.0, t.accent));
}
if marked {
let m = wheel_pos(c, (inner + outer) / 2.0, hue, 1.0);
painter.circle_stroke(m, RING_WIDTH / 2.0 - 1.0, Stroke::new(1.5, Color32::WHITE));
painter.circle_stroke(m, RING_WIDTH / 2.0, Stroke::new(1.0, Color32::BLACK));
}
resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), if marked { f64::from(hue) } else { 0.0 }, name));
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_min_numeric_value(0.0);
node.set_max_numeric_value(360.0);
node.set_numeric_value_step(1.0);
for action in [Action::SetValue, Action::Increment, Action::Decrement] {
node.add_action(action);
}
});
let part_at = |p: Pos2| match p.distance(c) {
d if d < inner => RingPart::Inner,
d if d <= outer + RING_MARGIN => RingPart::Band,
_ => RingPart::Corner,
};
let part_id = resp.id.with("part");
if resp.is_pointer_button_down_on()
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(p) = resp.interact_pointer_pos()
{
ui.data_mut(|d| d.insert_temp(part_id, part_at(p)));
}
let pointer = pointer_phase(&resp);
// A click takes the part under it; a drag keeps the part its press started on.
let part = match pointer {
Some((p, _)) if resp.clicked() => Some(part_at(p)),
Some((p, _)) => Some(ui.data(|d| d.get_temp::<RingPart>(part_id)).unwrap_or_else(|| part_at(p))),
None => ui.data(|d| d.get_temp::<RingPart>(part_id)),
};
if resp.clicked() || resp.drag_stopped() {
ui.data_mut(|d| d.remove::<RingPart>(part_id));
}
if part == Some(RingPart::Band) {
focus_on_press(&resp);
}
let (dx, dy) = arrow_steps(ui, resp.id, true, true);
let (steps, set) = ui.input(|i| {
let steps =
i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32;
let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data {
Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v.rem_euclid(360.0) as f32),
_ => None,
});
(steps + dx + dy, set)
});
let mut out = RingResponse { rect, hue: None, inner: None };
if let Some((p, phase)) = pointer {
match part.unwrap_or_else(|| part_at(p)) {
RingPart::Band => {
let v = p - c;
let h = (-v.y).atan2(v.x).to_degrees();
let h = if ui.input(|i| i.modifiers.shift) { (h / 45.0).round() * 45.0 } else { h };
out.hue = Some((h.rem_euclid(360.0), phase));
}
RingPart::Inner => out.inner = Some((p, phase)),
RingPart::Corner => {}
}
} else {
let new = set.unwrap_or(hue + steps);
// Without a marker there is no color yet, so a set value applies even when it equals `hue`.
if new != hue || (set.is_some() && !marked) {
out.hue = Some((new.rem_euclid(360.0), Live::Released));
}
}
out
}
/// Hue sectors and saturation rings of the [`segmented_wheel`]: patches 15° of hue by 20%
/// saturation wide, the sectors centered on red at 0°.
const WHEEL_SECTORS: u32 = 24;
+1 -1
View File
@@ -192,7 +192,7 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
/// One step of a numeric field with each modifier, most specific first (a plain pattern would also
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to
/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten.
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
pub(super) const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next
/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1
+8 -1
View File
@@ -246,6 +246,11 @@ Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes
My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`,
`colorTheme.addToSwatches`, `swatch.newGroup` for the theme being made), and `colorTheme.list` reads the library.
Color Wheel panel (`window.panel {panel: "colorWheel"}`): a hue ring around a saturation and brightness square. A
pick runs the commands the Color panel's sliders run: `paint.setFill` or `paint.setStroke` with `keepModel`,
`paint.editGradient` for a gradient's selected stop, and `paint.freeform.setPoint` for a freeform gradient's selected
point (the first stop or point when none is selected).
Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`,
`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the
@@ -338,7 +343,9 @@ when the app starts, off by default) turns off the desktop's frame and uses the
sessions where the desktop would draw the frame: X11 sessions, Wayland compositors that draw server-side decorations
such as KDE Plasma, and a window opened through XWayland. System Title Bar and `VECTORCRAFT_TITLEBAR` (`app`, or
`system` or `desktop`) take precedence over it. The checkbox is dimmed while either applies, and a note under it names
each one that applies.
each one that applies. `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in:
`app` keeps the app bar as the title bar with System Title Bar on. While the variable is set, the Preferences dialog dims
System Title Bar and its `__titlebarFromEnv` field is `true`.
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
+1 -1
View File
@@ -118,7 +118,7 @@ A file opened from the file manager (double-click, Open With, a drop on the exec
## Linux: Wayland and X11
On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over VectorCraft Title Bar on Every Desktop.
On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over System Title Bar and VectorCraft Title Bar on Every Desktop.
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
+14 -5
View File
@@ -5177,6 +5177,12 @@ documents every field:
visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area
(artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull|
tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down).
- Copies per sheet: with `copiesPerSheet: true` (off by default; not with tiling) each sheet holds as many copies of
the artboard as fit the imageable area, each with its bleed and marks, across then down, `gutter` pt apart (0–720,
default 0). The copies are not turned, and the placement places the whole grid. An artboard that doesn't fit prints
one copy per sheet, with a warning; more than 1,000 copies on a sheet is an error. `print.preview` lists each
copy's artboard on the page as `copies: [[x0, y0, x1, y1]…]`, across then down, and `trim` (the TrimBox) holds
them all.
- `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`),
drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen.
- `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name,
@@ -5473,10 +5479,11 @@ its pixels are decoded. `file.place {path}` links the file like any image. Vecto
## Printing
`print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the
current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or
separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job
out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the
document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the
current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, copies per sheet, marks and
bleed, composite or separations output, graphics and colour management options of File › Print. `print.preview
{settings?}` lays the job out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the
`transform` from the document onto the page (pt, y down), the document `area` it prints, its `trim` box on the page
and, with copies per sheet, each copy's artboard on the page (`copies`), plus `tiles`, the
`inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per
sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print
dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's
@@ -6685,7 +6692,9 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of
size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns
`{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that
isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where
no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none
no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in with that id is installed or
a storage folder for the id exists, and that deletion can fail too; for an id with only a `.removed` folder it does
not ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none
installed, one whose id has settings or a cache in the
`Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the
user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns
+15 -5
View File
@@ -314,10 +314,18 @@ the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory:
A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the
module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5`
when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used
its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a
settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the
entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would
pass the 1 GiB returns `-7` before it reads or writes anything.
its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes. A
settings call counts the whole settings file when it reads the file, and a set or delete also counts the whole file
when it writes the file; a cache call counts the entry it reads or writes. A read counts them even when the value
doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` before it reads or writes anything.
The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size
or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A
set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete
of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the
file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and
the run waits for it.
A live effect runs while the art draws, so keep the entries it uses small.
- A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`,
`AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two.
@@ -336,7 +344,9 @@ pass the 1 GiB returns `-7` before it reads or writes anything.
(`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that.
`plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails
and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the
`.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them.
`.removed` folder, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in
with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id that
isn't installed and has only a `.removed` folder, `plugin.remove` does not delete the folder and fails as not found.
`plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is
any.
`plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and
+6 -3
View File
@@ -821,7 +821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`).
- Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it.
- On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in.
- Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do.
- A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559).
- On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723).
@@ -1186,6 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels.
- **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel.
- **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given.
- **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. Scores and estimates are unchanged.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode").
Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`.
@@ -1405,6 +1406,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`).
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
- Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283).
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`).
- File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`).
@@ -1568,13 +1570,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.