Merge main into fix/docs-layers-row-menu

This commit is contained in:
dotCooCoo
2026-11-13 11:55:06 -08:00
61 changed files with 2605 additions and 134 deletions
+21
View File
@@ -194,6 +194,27 @@ impl ImageBlob {
.collect();
Some((w, h, cover))
}
/// The pixels as straight RGBA, row by row from the top, with the width and the height;
/// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`].
pub fn rgba(&self) -> Option<(u32, u32, Vec<u8>)> {
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let (w, h) = img.dimensions();
Some((w, h, img.into_raw()))
}
/// The width and height in pixels the image's file says it has; `None` for data that isn't an
/// image the decoder reads. Reads only its header.
pub fn pixel_size(&self) -> Option<(u32, u32)> {
image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()
}
/// Whether the image's file is a PNG that stores palette indices (an indexed-color image).
/// Reads only its header.
pub fn indexed_png(&self) -> bool {
// The signature, then the IHDR chunk: length, type, width, height, bit depth, color type.
self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3)
}
}
impl ImageBlob {
+211 -3
View File
@@ -3,7 +3,11 @@
//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text
//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are
//! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead.
//!
//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as
//! a palette PNG.
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color;
use vectorcraft_doc::pattern::PatternDef;
pub(crate) use vectorcraft_doc::recolor::map_paint;
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind};
use vectorcraft_render::encode::png::{PngOptions, encode_indexed};
use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to};
use super::edit::selected_roots;
use super::{
@@ -66,6 +72,15 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
cmd!(
"edit.colors.toIndexed",
"Convert to Indexed Color…",
["Edit", "Edit Colors"],
None,
"{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)",
has_selection,
to_indexed
),
cmd!(
"edit.colors.saturate",
"Saturate…",
@@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 {
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is.
type Remake<'a> = dyn Fn(&ImageBlob) -> Option<ImageBlob> + 'a;
/// The objects a pass recolors, and the locked and hidden objects the selection left out, each
/// with whether it is hidden ([`pass_targets`]).
type Targets = (Vec<NodeId>, Vec<(NodeId, bool)>);
/// The identity colour map (a pass that only maps linked colours).
fn same(c: Color) -> Color {
c
@@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> {
scope: Scope,
/// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]).
gray_images: bool,
/// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is).
remake: Option<&'a Remake<'a>>,
/// Image key → the key of its recoloured copy (`None`: unchanged).
images: HashMap<String, Option<String>>,
/// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured).
@@ -207,6 +231,7 @@ impl<'a> Recolor<'a> {
links: None,
scope,
gray_images: false,
remake: None,
images: HashMap::new(),
patterns: HashMap::new(),
new_images: vec![],
@@ -229,7 +254,7 @@ impl<'a> Recolor<'a> {
/// A pass that maps colors as this one does, with nothing recolored yet.
fn again(&self) -> Self {
Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) }
Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) }
}
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
@@ -328,7 +353,9 @@ impl<'a> Recolor<'a> {
}
let f = self.f;
let blob = d.images.get(key)?;
let new = if self.gray_images {
let new = if let Some(remake) = self.remake {
remake(blob)
} else if self.gray_images {
blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0])
} else {
blob.map_rgb(|[r, g, b]| {
@@ -534,6 +561,187 @@ impl NotChanged {
}
}
/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and
/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden.
fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result<Targets> {
Ok(match ids_param(p, "ids", cmd)? {
Some(v) => (v, vec![]),
None => {
let roots = selected_roots(s)?;
let st = s.doc()?;
let left = left_out(&st.doc, st.isolation, &roots);
(roots, left)
}
})
}
/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as
/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is
/// recorded when no image changes.
fn to_indexed(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.colors.toIndexed";
let indexing = Indexing::of(s, p)?;
// A typing session ends here as `s.edit` would end it, before the copy is taken, so that the
// copy holds what was typed.
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
let remake = |b: &ImageBlob| indexing.convert(b);
let images = Scope { fill: false, stroke: false, images: true, patterns: false };
let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) };
// A left-out image counts as one the pass would change when it is an image that is not an
// indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key.
let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![]));
let (ids, left) = pass_targets(s, p, C)?;
let st = s.doc()?;
let colorized_ok = super::paint::check_colorized(&st.doc).is_ok();
let not_changed = NotChanged::of(
Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden),
);
let mut doc = (*st.doc).clone();
let n = pass.run(&mut doc, &ids);
if let Some((w, h)) = indexing.too_large.get() {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS)));
}
if n > 0 {
s.edit("Convert to Indexed Color", |d, _| {
*d = doc;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))
})?;
}
let mut out = json!({ "changed": n });
not_changed.report(&mut out);
Ok(out)
}
/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8
/// export, with transparency kept and no matte, and the fixed palette of the uniform and document
/// reductions.
struct Indexing {
options: PaletteOptions,
fixed: Option<Vec<[u8; 3]>>,
/// The size of an image [`Self::convert`] left as it is because it has more than
/// [`Self::MAX_PIXELS`] pixels.
too_large: Cell<Option<(u32, u32)>>,
}
impl Indexing {
/// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at
/// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the
/// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered
/// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice
/// over.
const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16;
/// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the
/// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them.
fn of(s: &Session, p: &Value) -> Result<Self> {
const C: &str = "edit.colors.toIndexed";
let reduction = text_param(p, "reduction", C)?.unwrap_or("selective");
let colors = match p.get("colors") {
None | Some(Value::Null) => None,
Some(v) => {
Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16)
}
};
let dither = match text_param(p, "dither", C)? {
None => Dither::Diffusion,
Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?,
};
let dither_amount = match p.get("ditherAmount") {
None | Some(Value::Null) => 100,
Some(v) => {
v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8
}
};
let options =
|r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None };
let refuse_colors = |name: &str| match colors {
Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))),
None => Ok(()),
};
let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) };
Ok(match reduction.to_ascii_lowercase().as_str() {
"uniform" => {
let n = colors.unwrap_or(256);
// One entry is kept for transparent pixels.
let levels =
(2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?;
let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8;
let mut palette = Vec::with_capacity(usize::from(levels.pow(3)));
for r in 0..levels {
for g in 0..levels {
for b in 0..levels {
palette.push([level(r), level(g), level(b)]);
}
}
}
fixed(palette)
}
"document" => {
refuse_colors("document")?;
let mut palette: Vec<[u8; 3]> = vec![];
for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) {
let [r, g, b, _] = c.to_rgba8(1.0);
if !palette.contains(&[r, g, b]) {
palette.push([r, g, b]);
}
}
if palette.is_empty() {
return Err(bad(C, "the document has no color swatches"));
}
if palette.len() > 255 {
return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len())));
}
fixed(palette)
}
id => {
let r = Reduction::from_id(id).ok_or_else(|| {
bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)"))
})?;
if r == Reduction::BlackWhite {
refuse_colors("blackWhite")?;
}
Self { options: options(r), fixed: None, too_large: Cell::new(None) }
}
})
}
/// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than
/// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and
/// every pixel would show as it does.
fn convert(&self, b: &ImageBlob) -> Option<ImageBlob> {
let (w, h) = b.pixel_size()?;
if u64::from(w) * u64::from(h) > Self::MAX_PIXELS {
self.too_large.set(Some((w, h)));
return None;
}
let (w, h, rgba) = b.rgba()?;
let ix = match &self.fixed {
Some(palette) => quantize_to(&rgba, w, h, &self.options, palette),
None => quantize(&rgba, w, h, &self.options),
};
if b.indexed_png() && shows_same(&ix, &rgba) {
return None;
}
// The PNG writer's buffers take the place of the pixels.
drop(rgba);
Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?))
}
}
/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent
/// where they are transparent, and the same color where they are opaque.
fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool {
let px = rgba.as_chunks::<4>().0;
px.len() == ix.indices.len()
&& px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) {
(0, clear) => clear,
(255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]),
_ => false,
})
}
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32;
recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| {
+32 -11
View File
@@ -3,10 +3,14 @@
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
//!
//! While read roots are in force, a reply names a linked file outside them by its file name alone
//! ([`links::shown_script`]), and `removeFile` also takes that name for it.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
use vectorcraft_doc::{Document, LinkInfo, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
use super::links::{self, LinkBase};
use super::overprint::edit_counted;
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets};
@@ -17,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"SVG Interactivity",
[],
None,
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…; with automation read roots in force, a file outside them by its file name alone, a URL as it is], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
has_doc,
info
),
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add JavaScript File",
[],
None,
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked (the same text) stays where it is; a document links at most 64 files → {files} (as svgInteractivity.info gives them)",
has_doc,
add_file
),
@@ -62,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove JavaScript File",
[],
None,
"{file} unlink a JavaScript file from the document → {files}",
"{file} unlink a JavaScript file from the document: the file linked as `file` or, with automation read roots in force, a file outside them whose file name is `file`; an error, unlinking nothing, when `file` fits more than one → {files} (as svgInteractivity.info gives them)",
has_doc,
remove_file
),
@@ -111,7 +115,8 @@ fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&m
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let st = s.doc()?;
let d = &st.doc;
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
let events = lists
.first()
@@ -120,7 +125,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"events": events,
"files": d.svg_scripts,
"files": shown_files(st),
"eventNames": SVG_EVENTS,
}))
}
@@ -157,8 +162,14 @@ fn file_param(p: &Value, cmd: &str) -> Result<String> {
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
}
/// The JavaScript files `st` links, in order, as a reply to the caller asking now may name them
/// ([`links::shown_script`]).
fn shown_files(st: &crate::DocState) -> Vec<String> {
st.doc.svg_scripts.iter().map(|f| links::shown_script(st, f).into_owned()).collect()
}
fn files(s: &Session) -> Result<Value> {
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
Ok(json!({ "files": shown_files(s.doc()?) }))
}
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
@@ -180,11 +191,21 @@ fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.removeFile";
let file = file_param(p, C)?;
if !s.doc()?.doc.svg_scripts.contains(&file) {
return Err(bad(C, format!("{file:?} isn't linked")));
}
let st = s.doc()?;
let linked = &st.doc.svg_scripts;
// A file a reply names by its file name alone is also unlinked by that name, unless the name
// also fits another linked file.
let doc_path = LinkBase::of(st).doc;
let named: Vec<&String> =
linked.iter().filter(|f| **f != file && LinkInfo::new(f.as_str()).name() == file && links::script_hidden(f, doc_path)).collect();
let stored = match (linked.contains(&file), named.as_slice()) {
(true, []) => file,
(false, [f]) => (*f).clone(),
(false, []) => return Err(bad(C, format!("{file:?} isn't linked"))),
_ => return Err(bad(C, format!("{file:?} names more than one linked JavaScript file"))),
};
s.edit("Remove JavaScript File", |d, _| {
d.svg_scripts.retain(|f| *f != file);
d.svg_scripts.retain(|f| *f != stored);
Ok(())
})?;
files(s)
+14 -2
View File
@@ -1173,6 +1173,19 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// Is the linked JavaScript file `f` (of the document saved at `doc_path`) named by its file name
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
/// always shown as it is.
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
}
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
/// may name it: as it is, or by its file name alone where [`script_hidden`] says so.
pub fn shown_script<'a>(st: &crate::DocState, f: &'a str) -> Cow<'a, str> {
if script_hidden(f, LinkBase::of(st).doc) { Cow::Owned(LinkInfo::new(f).name().to_string()) } else { Cow::Borrowed(f) }
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
@@ -1556,8 +1569,7 @@ pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Co
}
}
}
let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url));
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) {
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f, doc_path)) {
*f = LinkInfo::new(f.as_str()).name().to_string();
}
Cow::Owned(d)
@@ -158,6 +158,7 @@ pub(super) const ALLOWED: &[&str] = &[
"edit.colors.saturate",
"edit.colors.toCMYK",
"edit.colors.toGrayscale",
"edit.colors.toIndexed",
"edit.colors.toRGB",
"edit.duplicate",
"edit.findNext",
+244
View File
@@ -484,6 +484,250 @@ fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() {
assert!(source(false).is_none(), "the colors keep their model");
}
/// Convert to Indexed Color stores an image as a palette PNG in one undo step; an image with few
/// enough colors keeps them, and converting it again changes nothing and adds no undo step.
#[test]
fn convert_to_indexed_stores_an_image_as_a_palette_png() {
let mut s = session();
let id = image_node(&mut s);
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
let key = image_key(&s, id);
assert!(s.doc().unwrap().doc.images[&key].indexed_png(), "an indexed PNG");
assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]], "half opaque becomes opaque");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let again = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
assert_eq!(again["changed"], 0, "{again}");
assert_eq!(image_key(&s, id), key);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "no undo step when nothing changes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixels(&s, id)[3], [255, 255, 255, 128], "one undo step restores the image");
}
/// Pixels under half opacity become transparent; an indexed image converted to a palette it
/// already shows is left as it is, and one converted to a smaller palette changes.
#[test]
fn convert_to_indexed_keeps_transparency_and_reduces_an_indexed_image() {
let mut s = session();
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [0, 0, 0, 100], [250, 250, 250, 255]]);
s.execute("edit.colors.toIndexed", &json!({"dither": "none"})).unwrap();
assert_eq!(pixels(&s, id)[2][3], 0, "under half opacity: transparent");
let r = s.execute("edit.colors.toIndexed", &json!({"reduction": "blackWhite", "dither": "none"})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
let px = pixels(&s, id);
assert_eq!([px[0], px[1], px[3]], [[0, 0, 0, 255], [0, 0, 0, 255], [255, 255, 255, 255]]);
assert_eq!(px[2][3], 0);
}
/// The uniform reduction takes evenly spaced levels of each channel, and the document reduction
/// the colors of the document's swatches.
#[test]
fn convert_to_indexed_uses_a_uniform_or_the_document_palette() {
let mut s = session();
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]);
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 9, "dither": "none"})).unwrap();
assert_eq!(pixels(&s, id), [[255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 255, 255]]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap();
let swatches: Vec<[u8; 3]> = crate::cmd::swatchlib::library_colors(&s, "document")
.unwrap()
.iter()
.map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
})
.collect();
for p in pixels(&s, id) {
assert!(swatches.contains(&[p[0], p[1], p[2]]), "{p:?} is a swatch color");
}
}
/// The start of a PNG of `w` × `h` RGBA pixels: its header and a first image data chunk, without
/// the rest of the pixels.
fn png_header(w: u32, h: u32) -> Vec<u8> {
let crc = |data: &[u8]| {
let mut c = !0u32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
};
let chunk = |ty: &[u8; 4], data: &[u8]| {
let body = [ty.as_slice(), data].concat();
[(data.len() as u32).to_be_bytes().as_slice(), &body, &crc(&body).to_be_bytes()].concat()
};
let ihdr = [w.to_be_bytes().as_slice(), &h.to_be_bytes(), &[8, 6, 0, 0, 0]].concat();
[b"\x89PNG\r\n\x1a\n".as_slice(), &chunk(b"IHDR", &ihdr), &chunk(b"IDAT", &[0x78, 0x9c, 0x03, 0x00])].concat()
}
/// An image larger than Convert to Indexed Color converts is refused by its size, read from the
/// file's header, before any pixel is decoded; nothing changes.
#[test]
fn convert_to_indexed_refuses_an_image_too_large_to_convert() {
let mut s = session();
let id = image_node(&mut s);
assert_eq!(vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)).pixel_size(), Some((30_000, 30_000)));
s.edit("Swap", |d, _| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)));
Ok(())
})
.unwrap();
let undo = s.doc().unwrap().history.undo.len();
let e = s.execute("edit.colors.toIndexed", &json!({})).unwrap_err().to_string();
assert!(e.contains("an image of 30000 × 30000 pixels is too large to convert"), "{e}");
assert_eq!(image_key(&s, id), "pic");
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
/// Convert to Indexed Color refuses parameters it can't use, and leaves the image as it is.
#[test]
fn convert_to_indexed_refuses_bad_params() {
let mut s = session();
let id = image_node(&mut s);
for (p, msg) in [
(json!({"colors": 1}), "`colors` must be a whole number from 2 to 256"),
(json!({"colors": 257}), "`colors` must be a whole number from 2 to 256"),
(json!({"colors": "8"}), "`colors` must be a whole number from 2 to 256"),
(json!({"colors": 16.5}), "`colors` must be a whole number from 2 to 256"),
(json!({"reduction": "uniform", "colors": 8}), "`colors` must be at least 9 for the uniform reduction"),
(json!({"reduction": "document", "colors": 16}), "`colors` does not apply to the document reduction"),
(json!({"reduction": "blackWhite", "colors": 2}), "`colors` does not apply to the blackWhite reduction"),
(json!({"reduction": "posterize"}), "unknown reduction `posterize`"),
(json!({"dither": "random"}), "unknown dither `random`"),
(json!({"ditherAmount": 101}), "`ditherAmount` must be a number from 0 to 100"),
(json!({"ditherAmount": "50"}), "`ditherAmount` must be a number from 0 to 100"),
] {
let e = s.execute("edit.colors.toIndexed", &p).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
}
assert_eq!(image_key(&s, id), "pic");
}
/// An embedded `w` × `h` image of the pixels `f(x, y)`, placed and selected.
fn placed(s: &mut Session, w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> NodeId {
let mut png = Vec::new();
image::RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y))).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
NodeId(r["ids"][0].as_u64().unwrap())
}
/// `ids` picks the images to convert; a linked image and a colorized image keep their pixels.
#[test]
fn convert_to_indexed_takes_ids_and_leaves_linked_and_colorized_images() {
let mut s = session();
let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]);
let b = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]);
let (ka, kb) = (image_key(&s, a), image_key(&s, b));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [b.0]})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
assert_eq!(image_key(&s, a), ka, "not in `ids`");
assert_ne!(image_key(&s, b), kb);
s.edit("Link", |d, _| {
let NodeKind::Image(im) = &mut d.node_mut(a).unwrap().kind else { panic!("not an image") };
im.link = Some(vectorcraft_doc::LinkInfo::new("art.png"));
Ok(())
})
.unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [a.0]})).unwrap();
assert_eq!(r["changed"], 0, "a linked image: {r}");
assert_eq!(image_key(&s, a), ka);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
let mut png = Vec::new();
image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let r = s.execute("file.place", &json!({"name": "gray.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let gray = NodeId(r["ids"][0].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [gray.0], "color": "#ff0000"})).unwrap();
assert!(s.doc().unwrap().doc.node(gray).unwrap().extras().colorize.is_some(), "the fill colorizes the image");
let kg = image_key(&s, gray);
let r = s.execute("edit.colors.toIndexed", &json!({"ids": [gray.0]})).unwrap();
assert_eq!(r["changed"], 0, "a colorized image: {r}");
assert_eq!(image_key(&s, gray), kg);
}
/// With the selection Select All makes, locked and hidden images are left out and counted in
/// `notChanged`; a left-out image too large to convert is counted without an error.
#[test]
fn convert_to_indexed_counts_left_out_images() {
let mut s = session();
let a = placed(&mut s, 2, 1, |x, _| [200, 30 + x as u8, 90, 255]);
let locked = placed(&mut s, 2, 1, |x, _| [20, 40, 200 + x as u8, 255]);
let hidden = placed(&mut s, 2, 1, |x, _| [120, 140 + x as u8, 20, 255]);
s.edit("Huge", |d, _| {
let NodeKind::Image(im) = &mut d.node_mut(locked).unwrap().kind else { panic!("not an image") };
im.key = "huge".into();
d.images.insert("huge".into(), vectorcraft_doc::ImageBlob::png(png_header(30_000, 30_000)));
Ok(())
})
.unwrap();
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let kh = image_key(&s, hidden);
let r = s.execute("edit.colors.toIndexed", &json!({})).unwrap();
assert_eq!(r["changed"], 1, "{r}");
assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 1}), "{r}");
assert!(r["warning"].is_string(), "{r}");
assert!(s.doc().unwrap().doc.images[&image_key(&s, a)].indexed_png());
assert_eq!((image_key(&s, locked), image_key(&s, hidden)), ("huge".to_string(), kh));
}
/// The uniform reduction takes the most levels a channel whose cube leaves one entry for
/// transparency (3 for 64 colors, 6 for 256), and the document reduction takes 1 to 255 swatch
/// colors.
#[test]
fn convert_to_indexed_sizes_the_uniform_and_document_palettes() {
let mut s = session();
let id = image_of(&mut s, [[200, 30, 90, 255], [20, 40, 220, 255], [120, 130, 140, 255], [250, 250, 250, 255]]);
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "colors": 64, "dither": "none"})).unwrap();
assert_eq!(pixels(&s, id), [[255, 0, 127, 255], [0, 0, 255, 255], [127, 127, 127, 255], [255, 255, 255, 255]]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.colors.toIndexed", &json!({"reduction": "uniform", "dither": "none"})).unwrap();
assert_eq!(pixels(&s, id), [[204, 51, 102, 255], [0, 51, 204, 255], [102, 153, 153, 255], [255, 255, 255, 255]]);
s.execute("edit.undo", &json!({})).unwrap();
s.edit("No swatches", |d, _| {
d.swatches.clear();
d.swatch_groups.clear();
Ok(())
})
.unwrap();
let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string();
assert!(e.contains("the document has no color swatches"), "{e}");
s.edit("Many swatches", |d, _| {
for i in 0..256u32 {
let paint = Paint::solid(Color::rgb8(i as u8, 7, 9));
d.swatches.push(vectorcraft_color::swatch::Swatch { name: format!("c{i}"), paint, global: false, spot: false });
}
Ok(())
})
.unwrap();
let e = s.execute("edit.colors.toIndexed", &json!({"reduction": "document"})).unwrap_err().to_string();
assert!(e.contains("the document's swatches hold 256 colors; the document reduction takes at most 255"), "{e}");
assert_eq!(image_key(&s, id), "pic");
}
/// `dither` and `ditherAmount` reach the quantizer: a gray ramp reduced to black and white is
/// dithered by each method, and at an amount of 0 it comes out as with no dither.
#[test]
fn convert_to_indexed_dithers_by_the_amount_given() {
let ramp = |p: Value| {
let mut s = session();
let id = placed(&mut s, 16, 4, |x, _| [(x * 17) as u8, (x * 17) as u8, (x * 17) as u8, 255]);
s.execute("edit.colors.toIndexed", &p).unwrap();
pixels(&s, id)
};
let none = ramp(json!({"reduction": "blackWhite", "dither": "none"}));
for d in ["diffusion", "pattern", "noise"] {
assert_eq!(ramp(json!({"reduction": "blackWhite", "dither": d, "ditherAmount": 0})), none, "{d} at 0");
assert_ne!(ramp(json!({"reduction": "blackWhite", "dither": d})), none, "{d} at 100");
}
}
/// A PDF of a CMYK document writes an image Convert to Grayscale made with one channel (#5632).
#[test]
fn a_converted_image_is_written_to_pdf_as_gray_in_a_cmyk_document() {
@@ -368,6 +368,7 @@ fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unse
("edit.colors.invert", "ids", json!({}), Selected),
("edit.colors.toCMYK", "ids", json!({}), Selected),
("edit.colors.toGrayscale", "ids", json!({}), Selected),
("edit.colors.toIndexed", "ids", json!({}), Selected),
("edit.colors.toRGB", "ids", json!({}), Selected),
("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected),
("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected),
+119
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use crate::tests_links::{Folder, save, write};
use crate::*;
fn session() -> Session {
@@ -110,6 +111,124 @@ fn javascript_files_are_linked_once_and_unlinked() {
assert_eq!(files(&mut s), json!(["menu.js", "https://example.com/a.js"]));
}
/// `cmd` with `p` for a caller whose read roots are `read` and whose write roots are `write`.
fn confined(s: &mut Session, read: &[&Folder], write: &[&Folder], cmd: &str, p: Value) -> std::result::Result<Value, String> {
let read: Vec<&std::path::Path> = read.iter().map(|f| f.0.as_path()).collect();
let write: Vec<&std::path::Path> = write.iter().map(|f| f.0.as_path()).collect();
let roots = crate::file_access::AutomationRoots::new(&read, &write).unwrap();
crate::file_access::confine(roots.as_ref(), || s.execute(cmd, &p)).map_err(|e| e.to_string())
}
/// `info`, `addFile` and `removeFile` for a caller with read roots name a linked file outside them
/// by its file name, a relative one taken from the document's folder; a file inside them and a URL
/// are named as they are, and the document keeps every path.
#[test]
fn javascript_file_replies_hide_paths_outside_read_roots() {
let (away, home) = (Folder::new("js-replies-away"), Folder::new("js-replies-home"));
let (outside, inside) = (away.file("menu.js"), home.file("in.js"));
for f in [&outside, &inside, &away.file("lib/rel.js")] {
write(f, b"void 0;");
}
let url = "https://example.com/a.js";
let mut s = session();
for f in [outside.as_str(), inside.as_str(), url, "lib/rel.js"] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
save(&mut s, &away.file("poster.vectorcraft"));
let stored = s.doc().unwrap().doc.svg_scripts.clone();
let shown = json!(["menu.js", inside, url, "rel.js"]);
let info = confined(&mut s, &[&home], &[], "svgInteractivity.info", json!({})).unwrap();
assert_eq!(info["files"], shown, "{info}");
let added = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "https://example.com/b.js"})).unwrap();
assert_eq!(added["files"], json!(["menu.js", inside, url, "rel.js", "https://example.com/b.js"]), "{added}");
let removed = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "https://example.com/b.js"})).unwrap();
assert_eq!(removed["files"], shown, "{removed}");
assert!(!format!("{info}{added}{removed}").contains("js-replies-away"));
// A caller with only write roots reads none of the paths.
let info = confined(&mut s, &[], &[&home], "svgInteractivity.info", json!({})).unwrap();
assert_eq!(info["files"], json!(["menu.js", "in.js", url, "rel.js"]), "{info}");
// A caller whose read roots hold the files, and one without roots, sees every path.
assert_eq!(confined(&mut s, &[&away, &home], &[], "svgInteractivity.info", json!({})).unwrap()["files"], json!(stored));
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["files"], json!(stored));
assert_eq!(s.doc().unwrap().doc.svg_scripts, stored, "the document keeps its paths");
}
/// A caller with read roots unlinks a file a reply names by its file name alone by that name, as
/// one undo step; the name is not taken for a file it may read, nor without read roots.
#[test]
fn hidden_javascript_file_can_be_removed_by_unique_shown_name() {
let (away, home) = (Folder::new("js-remove-away"), Folder::new("js-remove-home"));
let (outside, inside) = (away.file("menu.js"), home.file("other.js"));
let mut s = session();
for f in [&outside, &inside] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
assert!(s.execute("svgInteractivity.removeFile", &json!({"file": "menu.js"})).is_err(), "no name without read roots");
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "other.js"})).unwrap_err();
assert!(e.contains("\"other.js\" isn't linked"), "a file inside the roots is unlinked by its path: {e}");
let steps = s.doc().unwrap().history.undo.len();
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap();
assert_eq!(r["files"], json!([inside]), "{r}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [inside.as_str()]);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), inside.as_str()]);
// A caller that knows the whole path unlinks the file by it.
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({ "file": outside })).unwrap();
assert_eq!(r["files"], json!([inside]), "{r}");
}
/// Two linked files outside the read roots with the same file name: that name unlinks neither,
/// and the error names no path.
#[test]
fn ambiguous_hidden_javascript_file_name_is_refused_without_edit() {
let (away, home) = (Folder::new("js-ambiguous-away"), Folder::new("js-ambiguous-home"));
let (a, b) = (away.file("a/menu.js"), away.file("b/menu.js"));
let mut s = session();
for f in [&a, &b] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
let steps = s.doc().unwrap().history.undo.len();
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
assert!(e.contains("\"menu.js\" names more than one linked JavaScript file") && !e.contains("js-ambiguous-away"), "{e}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), b.as_str()]);
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step");
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "nav.js"})).unwrap_err();
assert!(e.contains("\"nav.js\" isn't linked") && !e.contains("js-ambiguous-away"), "{e}");
// A file linked as the name itself, beside one outside the roots shown by that name: neither.
run(&mut s, "svgInteractivity.removeFile", json!({ "file": b }));
run(&mut s, "svgInteractivity.addFile", json!({"file": "menu.js"}));
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
assert!(e.contains("names more than one"), "{e}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), "menu.js"]);
// Without read roots the name is the file linked as it.
run(&mut s, "svgInteractivity.removeFile", json!({"file": "menu.js"}));
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str()]);
}
/// `addFile` takes a file the same text links as already linked: a file name that a reply shows for
/// a different file is linked as one more file, within the 64-file limit.
#[test]
fn adding_a_shown_name_does_not_deduplicate_a_different_hidden_path() {
let (away, home) = (Folder::new("js-add-away"), Folder::new("js-add-home"));
let mut s = session();
let outside = away.file("0.js");
run(&mut s, "svgInteractivity.addFile", json!({ "file": outside }));
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap();
assert_eq!(r["files"], json!(["0.js", "0.js"]), "{r}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), "0.js"]);
run(&mut s, "edit.undo", json!({}));
for i in 1..64 {
run(&mut s, "svgInteractivity.addFile", json!({ "file": away.file(&format!("{i}.js")) }));
}
let e = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap_err();
assert!(e.contains("at most 64"), "a 65th file is refused: {e}");
let steps = s.doc().unwrap().history.undo.len();
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({ "file": outside })).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(64), "the same path is already linked");
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
#[test]
fn a_document_links_at_most_64_javascript_files() {
let mut s = session();
+6
View File
@@ -804,6 +804,12 @@ impl Session {
self.tool.wants_ticks()
}
/// Did the active tool leave part of its overlays for the next frame
/// ([`vectorcraft_tools::Tool::overlays_pending`])?
pub fn tool_overlays_pending(&self) -> bool {
self.tool.overlays_pending()
}
/// Does the active tool build a stroke from every position the pointer passed
/// ([`vectorcraft_tools::Tool::wants_samples`])?
pub fn tool_wants_samples(&self) -> bool {
+6
View File
@@ -945,6 +945,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step",
),
(
"ui.indexedColorDialog",
"Convert to Indexed Color…",
"",
"{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step",
),
(
"window.swatchLibrary",
"Swatch Library",
+38 -13
View File
@@ -154,18 +154,8 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind
pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed {
let px = rgba.as_chunks::<4>().0;
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
let matte = o.matte.unwrap_or([255; 3]);
let blend = o.matte.is_some() || !o.transparency;
// The opaque colours: blended over the matte, greys for the grey reductions.
let colors: Vec<[u8; 3]> = px
.iter()
.map(|p| {
let a = if blend { p[3] as u32 } else { 255 };
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
if gray { [luma(c); 3] } else { c }
})
.collect();
let colors = opaque_colors(px, o, matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite));
let has_clear = px.iter().any(clear);
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
@@ -195,11 +185,46 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions,
if palette.is_empty() {
palette.push(matte);
}
finish(px, &colors, width, height, &palette, o, if exact { Dither::None } else { o.dither })
}
/// [`quantize`] to the fixed `palette`: each pixel takes its nearest entry, with `o`'s dither,
/// transparency and matte; `o.reduction` sets only the distance weights, and `o.colors` is not
/// used. Only the first 255 colors of `palette` are used, leaving room for the transparent entry.
/// Entries no pixel uses are left out.
pub fn quantize_to(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, palette: &[[u8; 3]]) -> Indexed {
let px = rgba.as_chunks::<4>().0;
let colors = opaque_colors(px, o, false);
let mut palette: Vec<[u8; 3]> = palette.iter().take(255).copied().collect();
if palette.is_empty() {
palette.push(o.matte.unwrap_or([255; 3]));
}
finish(px, &colors, width, height, &palette, o, o.dither)
}
/// The color of each pixel when opaque: blended over the matte, and gray when `gray`.
fn opaque_colors(px: &[[u8; 4]], o: &PaletteOptions, gray: bool) -> Vec<[u8; 3]> {
let matte = o.matte.unwrap_or([255; 3]);
let blend = o.matte.is_some() || !o.transparency;
px.iter()
.map(|p| {
let a = if blend { p[3] as u32 } else { 255 };
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
if gray { [luma(c); 3] } else { c }
})
.collect()
}
/// Map the pixels `px` (their opaque `colors`) to `palette` with `dither`, then drop the entries
/// no pixel uses and put the transparent entry first when a pixel is transparent.
fn finish(px: &[[u8; 4]], colors: &[[u8; 3]], width: u32, height: u32, palette: &[[u8; 3]], o: &PaletteOptions, dither: Dither) -> Indexed {
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
let matte = o.matte.unwrap_or([255; 3]);
let has_clear = px.iter().any(clear);
let weights = o.reduction.weights();
let dither = if exact { Dither::None } else { o.dither };
// (A width past the pixels there are would size the error rows from a number nothing checked.)
let mut indices =
map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
map(colors, (width as usize).min(colors.len()), palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
// Drop the entries no pixel uses (fixed palettes), keeping the order.
let mut used = [false; 256];
for (i, &ix) in indices.iter().enumerate() {
+17 -1
View File
@@ -3,7 +3,7 @@ use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::png::{PngOptions, encode_indexed};
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize};
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to};
use super::*;
/// A `w`×`h` opaque image of `f(x, y)` colours.
@@ -93,6 +93,22 @@ fn fixed_palettes() {
assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]);
}
#[test]
fn a_given_palette_keeps_the_entries_pixels_use() {
let px: Vec<u8> = [[250, 10, 10, 255], [10, 10, 240, 255], [0, 0, 0, 40], [240, 20, 30, 255]].concat();
let given = [[0, 255, 0], [255, 0, 0], [0, 0, 255], [255, 255, 255]];
let o = PaletteOptions { dither: Dither::None, matte: None, ..Default::default() };
let ix = quantize_to(&px, 4, 1, &o, &given);
assert_eq!(ix.transparent, Some(0));
assert_eq!(ix.palette[1..], [[255, 0, 0], [0, 0, 255]], "unused entries left out, the order kept");
assert_eq!(ix.indices, [1, 2, 0, 1]);
// At most 255 of the given colors are used, leaving room for the transparent entry.
let many: Vec<[u8; 3]> = (0..300u32).map(|i| [(i % 256) as u8, (i / 256) as u8, 0]).collect();
let all = image(256, 2, |x, y| [x as u8, y as u8, 0]);
let ix = quantize_to(&all, 256, 2, &o, &many);
assert!(ix.palette.len() <= 255 && ix.palette.iter().all(|c| many[..255].contains(c)), "{}", ix.palette.len());
}
#[test]
fn dithering_keeps_the_average_colour() {
let px = image(64, 16, |x, _| [(x * 4) as u8; 3]);
+675 -1
View File
@@ -23,9 +23,13 @@
//! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that
//! line to 45° steps from the canvas axes.
use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point};
use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes};
use vectorcraft_pathops as po;
use super::{FEEDBACK, blob_area, blob_tip, eraser_area, eraser_tip, points_json, polyline};
use crate::guides::DrawSnap;
@@ -35,6 +39,32 @@ use crate::{Action, Cursor, Mods, Overlay, PenPose, PointerEvent, PointerKind, T
/// Pencil, Paintbrush or Blob Brush uses the same radius.
const SMOOTH_RADIUS: f64 = 12.0;
/// The most points of a drag that one step of its band adds, as when the band is outlined again
/// at a new zoom.
const BAND_STEP: usize = 32;
/// The most steps one call adds to a band: a band outlined again for a long drag shows in full
/// over several frames ([`Tool::overlays_pending`]).
const BAND_STEPS_PER_CALL: usize = 8;
/// The side of the squares a smoothing drag's band is kept in, in radii: a step of the drag adds
/// its area only to the squares it reaches, so the work of a step does not grow with the drag.
const BAND_TILE_RADII: f64 = 8.0;
/// Where the grid of those squares starts, in squares from the origin, so that the edge of the
/// band of a drag along whole coordinates does not run along a side of a square.
const BAND_TILE_OFFSET: f64 = 0.381_966;
/// The most squares one segment of a drag is checked against: a segment longer than 2,048 squares
/// is kept outside the squares, and shows as the two sides of its band.
const BAND_TILE_SAMPLES: f64 = 4096.0;
/// The side of the cells of a coarser grid, in squares: each cell lists the squares and the long
/// segments in it, so that a new square or long segment is checked only against those near it.
const BAND_CELL_TILES: i64 = 1024;
/// The most points, half a cell apart, along a long segment at which its cells are listed: a
/// longer segment is listed in no cell and checked against every square and long segment.
const BAND_CELL_SAMPLES: f64 = 4096.0;
/// The most work one call does on the band's long segments: comparisons of a long segment with a
/// square or another long segment, and pieces of an outline tested. The rest waits for the next
/// call ([`Tool::overlays_pending`]), and the band shown until then is the last one finished.
const BAND_WORK_PER_CALL: usize = 20_000;
/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn:
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
@@ -136,6 +166,584 @@ pub struct GestureTool {
/// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas,
/// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool.
tip_at: Option<Point>,
/// The band the smoothing drag has shown so far ([`GestureTool::smooth_band`]).
band: Cell<Option<SmoothBand>>,
/// The band last shown does not cover the whole drag yet; the next call adds to it.
band_pending: Cell<bool>,
}
/// The band a smoothing drag smooths, for the drag's first `used` points at `radius`, kept as its
/// area in each square of a grid that it reaches.
struct SmoothBand {
radius: f64,
used: usize,
first: Point,
last: Point,
tiles: BTreeMap<(i64, i64), BandTile>,
/// The segments of the drag too long for the squares ([`BAND_TILE_SAMPLES`]).
long: Vec<LongSegment>,
/// The squares and long segments listed in each cell of a coarser grid ([`BAND_CELL_TILES`]).
cells: BTreeMap<(i64, i64), BandCell>,
/// The long segments listed in no cell ([`BAND_CELL_SAMPLES`]).
wide: Vec<usize>,
/// The squares near a long segment whose `cut` is to be made again.
dirty_tiles: BTreeSet<(i64, i64)>,
/// The long segments whose `drawn` is to be made again.
dirty_long: BTreeSet<usize>,
/// The band changed since `shown` was made.
changed: bool,
/// The outline last finished.
shown: Option<BezPath>,
/// The work done on long segments in the current call ([`BAND_WORK_PER_CALL`]).
work: usize,
}
/// The squares and long segments (indexes into [`SmoothBand::long`]) in one cell of the coarser grid.
#[derive(Default)]
struct BandCell {
tiles: Vec<(i64, i64)>,
long: Vec<usize>,
}
/// The part of a band in one square of the grid, and its outline less the square's sides.
struct BandTile {
area: PathData,
outline: BezPath,
/// The long segments whose band can reach the square (indexes into [`SmoothBand::long`]).
near_long: Vec<usize>,
/// `outline` less its parts inside the band of a long segment in `near_long`.
cut: BezPath,
}
/// A segment of a drag too long for the squares, from `a` to `b`, with the part of it outside the
/// squares (`from` and `to`: 0 at `a`, 1 at `b`): the squares hold its band for a square's side
/// from each end ([`SmoothBand::add_long`]).
struct LongSegment {
a: Point,
b: Point,
from: f64,
to: f64,
/// The squares its band can reach.
near: BTreeSet<(i64, i64)>,
/// The other long segments whose band can meet its band.
meets: Vec<usize>,
/// The parts of the two sides of its band that show.
drawn: BezPath,
}
impl LongSegment {
/// The two sides of its band at `radius`, over its part outside the squares.
fn sides(&self, radius: f64) -> [vectorcraft_geom::Line; 2] {
let d = self.b - self.a;
let s = Vec2::new(-d.y, d.x) * (radius / d.hypot());
let (p, q) = (self.a.lerp(self.b, self.from), self.a.lerp(self.b, self.to));
[vectorcraft_geom::Line::new(p + s, q + s), vectorcraft_geom::Line::new(p - s, q - s)]
}
}
impl SmoothBand {
/// The band of a drag that starts at `first`, before it has moved.
fn new(first: Point, radius: f64) -> Self {
Self {
radius,
used: 1,
first,
last: first,
tiles: BTreeMap::new(),
long: vec![],
cells: BTreeMap::new(),
wide: vec![],
dirty_tiles: BTreeSet::new(),
dirty_long: BTreeSet::new(),
changed: true,
shown: None,
work: 0,
}
}
/// The cell of the coarser grid that holds square `key`.
fn cell_of((ix, iy): (i64, i64)) -> (i64, i64) {
(ix.div_euclid(BAND_CELL_TILES), iy.div_euclid(BAND_CELL_TILES))
}
/// The cells around points half a cell apart along the segment from `a` to `b`: every cell
/// holding a square its band can reach, or a point within the radius of its band. `None` for
/// a segment longer than [`BAND_CELL_SAMPLES`] half cells or reaching far outside any document.
fn cells_along(&self, a: Point, b: Point) -> Option<BTreeSet<(i64, i64)>> {
let n = (a.distance(b) / (0.5 * BAND_CELL_TILES as f64 * self.side())).ceil().max(1.0);
if !n.is_finite() || n > BAND_CELL_SAMPLES {
return None;
}
let mut cells = BTreeSet::new();
for k in 0..=(n as u32) {
let (cx, cy) = Self::cell_of(self.tile_of(a.lerp(b, f64::from(k) / n))?);
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
cells.insert((cx + dx, cy + dy));
}
}
Some(cells)
}
/// Whether the band of the segment from `a` to `b` can reach square `key`.
fn reaches(&self, key: (i64, i64), a: Point, b: Point) -> bool {
distance_to_segment(self.tile_rect(key).center(), a, b) <= self.radius + 0.5 * std::f64::consts::SQRT_2 * self.side()
}
/// Whether the segment from `a` to `b` is too long for the squares: longer than
/// [`BAND_TILE_SAMPLES`] half squares.
fn too_long(&self, a: Point, b: Point) -> bool {
let n = a.distance(b) / (0.5 * self.side());
n.is_finite() && n > BAND_TILE_SAMPLES
}
/// Add the band of the segment from `a` to `b`, too long for the squares: the squares take its
/// band for a square's side from each end, where they can, and the rest is kept as the
/// segment.
fn add_long(&mut self, a: Point, b: Point) {
let d = b - a;
let stub = d * (self.side() / d.hypot());
let from = if self.grow(&[a, a + stub]).is_some() { self.side() / d.hypot() } else { 0.0 };
let to = if self.grow(&[b - stub, b]).is_some() { 1.0 - self.side() / d.hypot() } else { 1.0 };
let i = self.long.len();
// The squares and long segments listed in its cells, or all of them.
let mut tiles = BTreeSet::new();
let mut others: BTreeSet<usize> = self.wide.iter().copied().collect();
match self.cells_along(a, b) {
Some(cells) => {
self.work += cells.len();
for c in cells {
let cell = self.cells.entry(c).or_default();
tiles.extend(cell.tiles.iter().copied());
others.extend(cell.long.iter().copied());
cell.long.push(i);
}
}
None => {
tiles.extend(self.tiles.keys().copied());
others.extend(0..i);
self.wide.push(i);
}
}
self.work += tiles.len() + others.len();
let near: BTreeSet<_> = tiles.into_iter().filter(|&k| self.reaches(k, a, b)).collect();
for k in &near {
if let Some(t) = self.tiles.get_mut(k) {
t.near_long.push(i);
self.dirty_tiles.insert(*k);
}
}
let reach = 2.0 * self.radius + 2e-3 * self.radius;
let meets: Vec<usize> = others.into_iter().filter(|&j| self.long.get(j).is_some_and(|o| segment_distance(a, b, o.a, o.b) <= reach)).collect();
for &j in &meets {
if let Some(o) = self.long.get_mut(j) {
o.meets.push(i);
self.dirty_long.insert(j);
}
}
self.long.push(LongSegment { a, b, from, to, near, meets, drawn: BezPath::new() });
self.dirty_long.insert(i);
self.last = b;
self.changed = true;
}
/// The side of the grid's squares.
fn side(&self) -> f64 {
BAND_TILE_RADII * self.radius
}
/// The square of the grid `key` names.
fn tile_rect(&self, (ix, iy): (i64, i64)) -> Rect {
let s = self.side();
let (x0, y0) = ((ix as f64 + BAND_TILE_OFFSET) * s, (iy as f64 + BAND_TILE_OFFSET) * s);
Rect::new(x0, y0, x0 + s, y0 + s)
}
/// The square of the grid that holds `p`; `None` far outside any document.
fn tile_of(&self, p: Point) -> Option<(i64, i64)> {
let s = self.side();
let (x, y) = ((p.x / s - BAND_TILE_OFFSET).floor(), (p.y / s - BAND_TILE_OFFSET).floor());
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
}
/// The squares within the radius of `piece`: those around points along it half a square apart.
fn tiles_near(&self, piece: &[Point]) -> Option<BTreeSet<(i64, i64)>> {
let mut keys = BTreeSet::new();
for w in piece.windows(2) {
let n = (w[0].distance(w[1]) / (0.5 * self.side())).ceil().max(1.0);
if !n.is_finite() || n > BAND_TILE_SAMPLES {
return None;
}
for k in 0..=(n as u32) {
let (ix, iy) = self.tile_of(w[0].lerp(w[1], f64::from(k) / n))?;
for (dx, dy) in [(-1, -1), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
keys.insert((ix + dx, iy + dy));
}
}
}
Some(keys)
}
/// Add the area within the radius of `piece`, the drag from the last point covered so far, to
/// the squares it reaches. `None`, with the band unchanged, when the outlines cannot be
/// combined.
fn grow(&mut self, piece: &[Point]) -> Option<()> {
let (r, nz) = (self.radius, FillRule::NonZero);
let end = *piece.last()?;
// Within a hundredth of the radius at any zoom.
let precision = (0.01 * r).min(po::DEFAULT_PRECISION);
let keys = self.tiles_near(piece)?;
let added = blob_area(piece, 2.0 * r, 0.0, 100.0);
let mut changed = vec![];
for key in keys {
let rect = self.tile_rect(key);
let clip = po::try_boolean(&added, nz, &shapes::rectangle(rect), nz, po::BoolOp::Intersect, precision).ok()?;
if clip.is_empty() {
continue;
}
let area = match self.tiles.get(&key) {
Some(t) => po::try_boolean(&t.area, nz, &clip, nz, po::BoolOp::Union, precision).ok().filter(|a| !a.is_empty())?,
None => clip,
};
changed.push((key, area));
}
// A square's outline depends on the squares across its sides, so those are outlined
// again too.
let mut redo = BTreeSet::new();
for (key @ (ix, iy), area) in changed {
redo.extend([(ix, iy), (ix - 1, iy), (ix + 1, iy), (ix, iy - 1), (ix, iy + 1)]);
let near_long = match self.tiles.get(&key) {
Some(t) => t.near_long.clone(),
None => {
// The long segments listed in the square's cell, and those in no cell.
let cell = self.cells.entry(Self::cell_of(key)).or_default();
cell.tiles.push(key);
let mut listed: BTreeSet<usize> = cell.long.iter().copied().collect();
listed.extend(self.wide.iter().copied());
let near: Vec<usize> = listed.into_iter().filter(|&i| self.long.get(i).is_some_and(|l| self.reaches(key, l.a, l.b))).collect();
for &i in &near {
if let Some(l) = self.long.get_mut(i) {
l.near.insert(key);
}
}
near
}
};
// The sides of those long segments are cut where the square's area holds them.
self.dirty_long.extend(near_long.iter().copied());
self.tiles.insert(key, BandTile { area, outline: BezPath::new(), near_long, cut: BezPath::new() });
}
self.changed = true;
let outlines: Vec<_> = redo.into_iter().filter(|k| self.tiles.contains_key(k)).map(|k| (k, self.tile_outline(k))).collect();
for (key, outline) in outlines {
if let Some(t) = self.tiles.get_mut(&key) {
t.outline = outline;
if !t.near_long.is_empty() {
self.dirty_tiles.insert(key);
}
}
}
self.last = end;
Some(())
}
/// The band's outline: the last one finished. While the band has changed since, the parts it
/// changed are made again, until [`BAND_WORK_PER_CALL`] is spent in the call; when all are
/// made, the outline is joined from them ([`SmoothBand::make_outline`]).
fn outline(&mut self) -> Option<BezPath> {
while self.changed {
if !self.dirty_tiles.is_empty() || !self.dirty_long.is_empty() {
if self.work >= BAND_WORK_PER_CALL {
break;
}
if let Some(k) = self.dirty_tiles.pop_first() {
let (cut, work) = self.tile_cut(k);
self.work += work;
if let Some(t) = self.tiles.get_mut(&k) {
t.cut = cut;
}
} else if let Some(i) = self.dirty_long.pop_first() {
let (drawn, work) = self.long_drawn(i);
self.work += work;
if let Some(l) = self.long.get_mut(i) {
l.drawn = drawn;
}
}
} else {
self.shown = Some(self.make_outline());
self.changed = false;
}
}
self.shown.clone()
}
/// The band's outline: every square's, joined, then the parts that show of the two sides of
/// the band of each segment too long for the squares. A square near such a segment shows its
/// `cut` ([`SmoothBand::tile_cut`]), and such a segment its `drawn` ([`SmoothBand::long_drawn`]).
fn make_outline(&self) -> BezPath {
let tiles = self.tiles.values().flat_map(|t| (if t.near_long.is_empty() { &t.outline } else { &t.cut }).elements().iter().copied());
BezPath::from_vec(tiles.chain(self.long.iter().flat_map(|l| l.drawn.elements().iter().copied())).collect())
}
/// The outline of square `key` less its parts inside the band of a long segment near it (more
/// than a thousandth of the radius in), and the work that took.
fn tile_cut(&self, key: (i64, i64)) -> (BezPath, usize) {
let Some(t) = self.tiles.get(&key) else { return (BezPath::new(), 1) };
let (r, eps) = (self.radius, 1e-3 * self.radius);
let near: Vec<&LongSegment> = t.near_long.iter().filter_map(|&i| self.long.get(i)).collect();
let mut out = BezPath::new();
let mut pen = None;
let mut work = 1;
for seg in t.outline.segments() {
// The pieces of the segment between the points where it crosses a side of the band
// of a long segment, each left out when it lies inside that band.
let mut ts: Vec<f64> = near
.iter()
.flat_map(|l| l.sides(r))
.flat_map(|line| seg.intersect_line(line))
.map(|x| x.segment_t)
.filter(|t| *t > 0.0 && *t < 1.0)
.collect();
ts.extend([0.0, 1.0]);
ts.sort_by(f64::total_cmp);
ts.dedup();
work += near.len() * ts.len();
for w in ts.windows(2) {
let piece = if ts.len() == 2 { seg } else { seg.subsegment(w[0]..w[1]) };
let m = piece.eval(0.5);
if near.iter().any(|l| distance_to_segment(m, l.a, l.b) < r - eps) {
pen = None;
} else {
push_seg(&mut out, &mut pen, piece);
}
}
}
(out, work)
}
/// The parts that show of the two sides of the band of long segment `i`, outside the squares'
/// part of it, and the work that took. Left out: the parts inside the band area of a square,
/// inside the band of another long segment (more than a thousandth of the radius in), and on
/// a side of an earlier long segment, which already shows. Only the squares and long segments
/// near it are checked.
fn long_drawn(&self, i: usize) -> (BezPath, usize) {
let Some(l) = self.long.get(i) else { return (BezPath::new(), 1) };
let (r, eps) = (self.radius, 1e-3 * self.radius);
let others: Vec<(usize, &LongSegment)> = l.meets.iter().filter_map(|&j| self.long.get(j).map(|o| (j, o))).collect();
// Whether a square's band area holds `q`.
let in_tiles = |q: Point| {
self.tile_of(q).and_then(|k| self.tiles.get(&k)).is_some_and(|t| vectorcraft_geom::Shape::winding(&t.area.to_bezpath(), q) != 0)
};
let mut out = BezPath::new();
let mut work = 1;
for line in l.sides(r) {
// Where the side crosses the band area of a square it passes, a side of another long
// segment's band, or the end of a side it runs along.
let mut ts = vec![0.0, 1.0];
for t in l.near.iter().filter_map(|k| self.tiles.get(k)) {
ts.extend(t.area.to_bezpath().segments().flat_map(|seg| seg.intersect_line(line)).map(|x| x.line_t));
}
// The stretches of the side inside the band of another long segment, and those on a
// side of an earlier one.
let mut inside = vec![];
let mut along = vec![];
for &(j, other) in &others {
inside.extend(capsule_span(line, other.a, other.b, r - eps));
for s in other.sides(r) {
ts.extend(PathSeg::Line(s).intersect_line(line).into_iter().map(|x| x.line_t));
if j < i {
ts.extend(
[s.p0, s.p1]
.into_iter()
.filter(|&p| distance_to_segment(p, line.p0, line.p1) <= eps)
.map(|p| (p - line.p0).dot(line.p1 - line.p0) / (line.p1 - line.p0).hypot2()),
);
along.extend(capsule_span(line, s.p0, s.p1, eps));
}
}
}
let (inside, along) = (merge_spans(inside), merge_spans(along));
ts.retain(|t| (0.0..=1.0).contains(t));
ts.sort_by(f64::total_cmp);
ts.dedup();
work += l.near.len() + others.len() + ts.len();
let mut pen = None;
for w in ts.windows(2) {
let (p, q) = (line.p0.lerp(line.p1, w[0]), line.p0.lerp(line.p1, w[1]));
let mid = 0.5 * (w[0] + w[1]);
let hidden = in_tiles(p.midpoint(q)) || spans_cover(&inside, mid, mid) || spans_cover(&along, w[0], w[1]);
if !hidden {
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
}
}
}
(out, work)
}
/// The outline of the band's part in square `key`, less the parts along a side of the square
/// (within a thousandth of the radius) where the band goes on across that side: where the
/// square across it holds band area a thousandth of the radius past it.
fn tile_outline(&self, key: (i64, i64)) -> BezPath {
let Some(tile) = self.tiles.get(&key) else { return BezPath::new() };
let (rect, eps) = (self.tile_rect(key), 1e-3 * self.radius);
let (ix, iy) = key;
// A point's distance across a side (x for a side that runs up and down) and along it.
let split = |upright: bool, p: Point| if upright { (p.x, p.y) } else { (p.y, p.x) };
// Each side with band area across it: whether it runs up and down, where it runs, the
// direction away from this square, the area across, and where along the side that area's
// outline has a point on the side (where the area across can start or stop).
let sides: Vec<_> = [
(true, rect.x0, (ix - 1, iy), -1.0),
(true, rect.x1, (ix + 1, iy), 1.0),
(false, rect.y0, (ix, iy - 1), -1.0),
(false, rect.y1, (ix, iy + 1), 1.0),
]
.into_iter()
.filter_map(|(upright, at, k, away)| {
let area = self.tiles.get(&k)?.area.to_bezpath();
let cuts: Vec<f64> = area
.segments()
.flat_map(|s| [s.start(), s.end()])
.map(|p| split(upright, p))
.filter(|&(off, _)| (off - at).abs() <= eps)
.map(|(_, along)| along)
.collect();
Some((upright, at, away, area, cuts))
})
.collect();
let mut out = BezPath::new();
let mut pen = None;
for seg in tile.area.to_bezpath().segments() {
let (a, b, mid) = (seg.start(), seg.end(), seg.eval(0.5));
let Some((upright, at, away, area, cuts)) = sides.iter().find(|s| [a, b, mid].iter().all(|&p| (split(s.0, p).0 - s.1).abs() <= eps))
else {
push_seg(&mut out, &mut pen, seg);
continue;
};
// The parts of the segment between the points where the area across starts or stops
// (more than `eps` from its ends), each kept unless the area across holds the point
// `eps` past the side beside it: the square across holds band area there exactly when
// its own edge of the band lies farther out than `eps`, and so shows.
let (la, lb) = (split(*upright, a).1, split(*upright, b).1);
let mut ts: Vec<f64> = cuts
.iter()
.filter(|&&c| (c - la) * (c - lb) < 0.0 && (c - la).abs() > eps && (c - lb).abs() > eps)
.map(|&c| (c - la) / (lb - la))
.collect();
ts.sort_by(f64::total_cmp);
let whole = ts.is_empty();
ts.insert(0, 0.0);
ts.push(1.0);
for w in ts.windows(2) {
let (p, q) = (a.lerp(b, w[0]), a.lerp(b, w[1]));
let m = if whole { mid } else { p.midpoint(q) };
let past = at + away * eps;
let probe = if *upright { Point::new(past, m.y) } else { Point::new(m.x, past) };
if vectorcraft_geom::Shape::winding(area, probe) != 0 {
pen = None;
} else if whole {
push_seg(&mut out, &mut pen, seg);
} else {
push_seg(&mut out, &mut pen, PathSeg::Line(vectorcraft_geom::Line::new(p, q)));
}
}
}
out
}
}
/// Add `seg` to `out`, starting a new subpath unless it starts at `pen`, where the last one ended.
fn push_seg(out: &mut BezPath, pen: &mut Option<Point>, seg: PathSeg) {
let a = seg.start();
if *pen != Some(a) {
out.move_to(a);
}
match seg {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
*pen = Some(seg.end());
}
/// How far `q` is from the segment from `a` to `b`.
fn distance_to_segment(q: Point, a: Point, b: Point) -> f64 {
let d = b - a;
let t = if d.hypot2() > 0.0 { ((q - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
q.distance(a + d * t)
}
/// How far the segment from `a` to `b` is from the segment from `c` to `d`.
fn segment_distance(a: Point, b: Point, c: Point, d: Point) -> f64 {
let side = |p: Point, q: Point, s: Point| (q - p).cross(s - p);
if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 {
return 0.0;
}
distance_to_segment(a, c, d).min(distance_to_segment(b, c, d)).min(distance_to_segment(c, a, b)).min(distance_to_segment(d, a, b))
}
/// The stretch of `line`, as `line_t` from and to (0 at its start, 1 at its end, and beyond),
/// within `rad` of the segment from `a` to `b`; `None` when no point of the line is.
fn capsule_span(line: vectorcraft_geom::Line, a: Point, b: Point, rad: f64) -> Option<(f64, f64)> {
let (p, d) = (line.p0, line.p1 - line.p0);
let dd = d.hypot2();
if dd <= 0.0 || rad.is_nan() || rad < 0.0 {
return None;
}
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
// The round ends.
for c in [a, b] {
let f = p - c;
let (half_b, k) = (d.dot(f), f.hypot2() - rad * rad);
let disc = half_b * half_b - dd * k;
if disc >= 0.0 {
let root = disc.sqrt();
lo = lo.min((-half_b - root) / dd);
hi = hi.max((-half_b + root) / dd);
}
}
// The part between them: along the segment from 0 to its length, and across it within `rad`.
let e = b - a;
let len = e.hypot();
if len > 0.0 {
let u = e / len;
let n = Vec2::new(-u.y, u.x);
let (mut s0, mut s1) = (f64::NEG_INFINITY, f64::INFINITY);
for (at, rate, min, max) in [((p - a).dot(u), d.dot(u), 0.0, len), ((p - a).dot(n), d.dot(n), -rad, rad)] {
if rate == 0.0 {
if at < min || at > max {
s1 = f64::NEG_INFINITY;
}
} else {
let (x, y) = ((min - at) / rate, (max - at) / rate);
s0 = s0.max(x.min(y));
s1 = s1.min(x.max(y));
}
}
if s0 <= s1 {
lo = lo.min(s0);
hi = hi.max(s1);
}
}
(lo <= hi).then_some((lo, hi))
}
/// `spans` sorted and joined where they overlap.
fn merge_spans(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match out.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// Whether one of `spans` (from [`merge_spans`]) holds everything from `from` to `to`.
fn spans_cover(spans: &[(f64, f64)], from: f64, to: f64) -> bool {
let i = spans.partition_point(|s| s.0 <= from);
i > 0 && spans.get(i - 1).is_some_and(|s| s.1 >= to)
}
/// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's
@@ -214,6 +822,8 @@ impl GestureTool {
snap: DrawSnap::default(),
pressed: Point::ZERO,
tip_at: None,
band: Cell::new(None),
band_pending: Cell::new(false),
}
}
@@ -349,6 +959,56 @@ impl GestureTool {
self.poses.clear();
self.previewed = 0;
self.shift_line = None;
self.band.set(None);
self.band_pending.set(false);
}
/// The outline of the band a smoothing drag smooths: every point within `radius` of the drag,
/// round at both ends. The band shown last is kept and grown by the steps dragged since, at
/// most [`BAND_STEP`] points at a time and [`BAND_STEPS_PER_CALL`] steps a call; a new radius
/// (the zoom changed) or another drag starts it again. While the band covers less of the drag
/// than it can, [`Tool::overlays_pending`] holds, so the host draws again and the band reaches
/// the whole drag with the pointer still. When a step cannot be added whole, its segments are
/// added one at a time: a segment longer than [`BAND_TILE_SAMPLES`] half squares is kept
/// outside the squares, and one whose outlines cannot be combined adds nothing. Either way the
/// band goes on past it. The work on such long segments stops for the call once
/// [`BAND_WORK_PER_CALL`] is spent, and the drag is added to the band only once the outline of
/// what it holds is finished.
fn smooth_band(&self, radius: f64) -> Option<BezPath> {
let pts = &self.points;
let &first = pts.first()?;
let mut b = self
.band
.take()
.filter(|b| b.radius == radius && b.first == first && b.used.checked_sub(1).and_then(|i| pts.get(i)) == Some(&b.last))
.unwrap_or_else(|| SmoothBand::new(first, radius));
b.work = 0;
let mut outline = b.outline();
if !b.changed {
for _ in 0..BAND_STEPS_PER_CALL {
let Some(from) = b.used.checked_sub(1).filter(|_| b.used < pts.len() && b.work < BAND_WORK_PER_CALL) else { break };
let mut to = (from + BAND_STEP).min(pts.len() - 1);
let Some(piece) = pts.get(from..=to) else { break };
if b.grow(piece).is_none() {
for (k, w) in piece.windows(2).enumerate() {
if b.work >= BAND_WORK_PER_CALL {
to = from + k;
break;
}
if b.too_long(w[0], w[1]) {
b.add_long(w[0], w[1]);
} else if b.grow(w).is_none() {
b.last = w[1];
}
}
}
b.used = to + 1;
}
outline = b.outline();
}
self.band_pending.set(b.used < pts.len() || b.changed);
self.band.set(Some(b));
outline.filter(|o| !o.elements().is_empty())
}
/// Does the tool's Size take Fixed or Pressure (the Blob Brush and the Eraser)?
@@ -726,6 +1386,11 @@ impl Tool for GestureTool {
fn wants_samples(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser")
}
/// A smoothing drag's band outlined again after a zoom change reaches the whole drag over
/// several frames.
fn overlays_pending(&self) -> bool {
self.band_pending.get()
}
/// `[` and `]` are the Blob Brush's and the Eraser's: their size (#1661).
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) && matches!(self.id, "blobBrush" | "eraser")
@@ -776,12 +1441,21 @@ impl Tool for GestureTool {
if began { stale.into_iter().chain([Action::Commit]).collect() } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
self.band_pending.set(false);
let mut o = self.snap.guides().to_vec();
// A path being drawn shows itself, except with the Pencil's Live preview off.
if !self.active || self.points.len() < 2 || (self.began && self.previews()) {
o.extend(self.tip_outline(cx));
return o;
}
// A smoothing drag with a selected path it can smooth also shows, dashed, the band its
// release smooths.
if (self.id == "smooth" || self.smoothing)
&& has_selected_smooth_path(cx)
&& let Some(path) = self.smooth_band(cx.tol(SMOOTH_RADIUS))
{
o.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: true });
}
// A Blob Brush drag that smooths shows the Smooth tool's thin line.
let width = match self.id {
"blobBrush" | "eraser" if !self.smoothing => (self.size * cx.zoom) as f32,
+435 -2
View File
@@ -1037,6 +1037,439 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() {
}
}
/// The dashed paths among the overlays.
fn bands(t: &GestureTool, cx: &ToolContext) -> Vec<vectorcraft_geom::BezPath> {
t.overlays(cx).into_iter().filter_map(|o| if let Overlay::Path { path, dashed: true, .. } = o { Some(path) } else { None }).collect()
}
/// How far `q` is from the polyline through `pts`.
fn dist_to_drag(q: Point, pts: &[Point]) -> f64 {
pts.windows(2)
.map(|w| {
let ab = w[1] - w[0];
let t = if ab.hypot2() > 0.0 { ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
q.distance(w[0] + ab * t)
})
.fold(f64::INFINITY, f64::min)
}
/// Check the band a smoothing drag through the tool's points shows at radius `r`: one dashed path,
/// every point of which is `r` from the drag, around an area that holds every point nearer than `r`
/// and none farther. Returns the path.
fn check_band(t: &GestureTool, cx: &ToolContext, r: f64) -> vectorcraft_geom::BezPath {
use vectorcraft_geom::kurbo::Shape;
let pts = &t.points;
let found = bands(t, cx);
let [outline] = found.as_slice() else { panic!("one band, got {found:?}") };
for seg in outline.segments() {
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, pts);
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
}
}
let kept = t.band.take();
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
t.band.set(kept);
let b = outline.bounding_box();
let mut probes = 0;
for i in 0..=80 {
for j in 0..=80 {
let q = Point::new(b.x0 - r + (b.width() + 2.0 * r) * f64::from(i) / 80.0, b.y0 - r + (b.height() + 2.0 * r) * f64::from(j) / 80.0);
let dq = dist_to_drag(q, pts);
let inside = areas.iter().any(|a| a.contains(q));
if dq < 0.97 * r {
assert!(inside, "{q:?} {dq} from the drag is inside the band, radius {r}");
probes += 1;
} else if dq > 1.03 * r {
assert!(!inside, "{q:?} {dq} from the drag is outside the band, radius {r}");
}
}
}
assert!(probes > 100, "{probes}");
outline.clone()
}
/// While a smoothing drag has a selected path to smooth, it shows dashed the band its release smooths:
/// everything within the radius it sends of the drag, round at both ends, around the drag's thin
/// line. Alt smoothing with the Pencil, Paintbrush and Blob Brush shows it too; a drag with nothing
/// selected and a Blob Brush stroke that paints do not.
#[test]
fn smoothing_drag_shows_the_band_it_smooths() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let none = Selection::default();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let pts = [(50.0, 150.0), (150.0, 150.0)];
for zoom in [1.0, 2.0, 0.5, 200.0] {
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
cx.zoom = zoom;
let r = match drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()).last().and_then(|a| a.first()) {
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["radius"].as_f64().unwrap_or(0.0),
a => panic!("expected path.smoothRegion, got: {a:?}"),
};
for (id, alt_smooth, m) in [("smooth", false, Mods::default()), ("pencil", true, alt), ("paintbrush", false, alt), ("blobBrush", false, alt)]
{
let mut t = GestureTool::new(id);
t.set_option("altSmooth", &json!(alt_smooth));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(m));
let band = check_band(&t, &cx, r);
let b = band.bounding_box();
let near = |a: f64, w: f64| (a - w).abs() < 0.02 * r;
assert!(near(b.x0, 50.0 - r) && near(b.x1, 150.0 + r) && near(b.y0, 150.0 - r) && near(b.y1, 150.0 + r), "{id} at zoom {zoom}: {b:?}");
// The whole outline shows: two sides and two half circles.
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 200.0 + 2.0 * std::f64::consts::PI * r;
assert!((length - want).abs() < 0.02 * want, "{id} at zoom {zoom}: outline {length}, band {want}");
assert!(
t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, dashed: false, .. } if *width == 1.0)),
"{id}: the drag's line"
);
}
// Nothing selected: the release smooths nothing, so no band shows.
let mut empty = crate::testutil::cx(&d, &none, &p);
empty.zoom = zoom;
let mut t = GestureTool::new("smooth");
t.pointer(&empty, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
t.pointer(&empty, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
assert!(bands(&t, &empty).is_empty());
// A Blob Brush stroke that paints shows its own area, not the band.
let mut t = GestureTool::new("blobBrush");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1));
assert!(bands(&t, &cx).is_empty());
}
}
/// The band shown while a long drag goes on, grown step by step, is one outline that holds every
/// point within the radius of the drag and none farther, whether the drag crosses itself or runs
/// on across the page; a zoom change mid-drag with the pointer still asks for frames until the band
/// covers the whole drag at the new radius, and the next drag does not keep the last one's band.
#[test]
fn smoothing_band_follows_a_long_drag() {
use vectorcraft_geom::kurbo::Shape;
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
// A curve that crosses itself, and one that runs on across the page.
let crossing: Vec<Point> =
(0..240).map(|i| f64::from(i) * 0.05).map(|a| Point::new(250.0 + 80.0 * (a * 1.3).sin(), 250.0 + 60.0 * (a * 0.7).cos())).collect();
let across: Vec<Point> =
(0..900).map(f64::from).map(|i| Point::new(20.0 + 0.5 * i, 250.0 + 100.0 * (i * 0.05).sin() + 30.0 * (i * 0.37).sin())).collect();
for pts in [crossing, across] {
cx.zoom = 1.0;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
assert_eq!(bands(&t, &cx).len(), 1);
}
check_band(&t, &cx, cx.tol(12.0));
assert!(!t.overlays_pending(), "the band covers the drag");
// The view zooms in mid-drag while the pointer holds still: the tool asks for frames until
// the band covers the whole drag at the new radius.
cx.zoom = 2.0;
let mut frames = 1;
bands(&t, &cx);
while t.overlays_pending() {
assert!(frames <= pts.len(), "the band reaches the whole drag");
bands(&t, &cx);
frames += 1;
}
let n = t.points.len();
assert_eq!(frames, (n - 1).div_ceil(BAND_STEP * BAND_STEPS_PER_CALL), "frames for {n} points");
check_band(&t, &cx, cx.tol(12.0));
assert!(!t.overlays_pending());
let last = pts[pts.len() - 1];
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, last.x, last.y));
// The next drag shows only its own band.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 450.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 420.0, 450.0));
let b = check_band(&t, &cx, cx.tol(12.0)).bounding_box();
assert!(b.x0 > 390.0 && b.y0 > 440.0, "{b:?}");
}
}
/// A segment of a drag too long for the squares at the current zoom (a jump drawn zoomed out,
/// viewed zoomed in, or one reaching far off the page) shows as the two sides of its band, and the
/// band goes on past it: the parts dragged before and after it show, and nothing stays pending.
/// Two such segments that cross show no edge inside each other's band, and one that runs back
/// along another shows its sides once.
#[test]
fn smoothing_band_shows_a_segment_too_long_for_its_squares() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let before = [Point::new(100.0, 100.0), Point::new(130.0, 100.0)];
let after = [Point::new(20_100.0, 100.0), Point::new(20_100.0, 130.0), Point::new(20_130.0, 130.0)];
let far = [Point::new(1e17, 100.0)];
let crossing = [(100.0, 100.0), (30_100.0, 100.0), (30_100.0, 130.0), (15_100.0, -20_000.0), (15_130.0, -20_000.0)].map(Point::from);
let back = [(100.0, 100.0), (30_100.0, 100.0), (130.0, 100.0)].map(Point::from);
for (case, pts) in [
("jump", [&before[..], &after[..]].concat()),
("far", [&before[..], &far[..], &after[..]].concat()),
("crossing", crossing.to_vec()),
("back", back.to_vec()),
] {
// Drawn zoomed out, where the jump is about 42 half squares, then viewed at 1,000%, where
// it is more than 4,096.
cx.zoom = 0.1;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
bands(&t, &cx);
}
cx.zoom = 10.0;
let r = cx.tol(12.0);
let mut frames = 0;
let mut found = bands(&t, &cx);
while t.overlays_pending() {
assert!(frames < pts.len(), "{case}: the band reaches the whole drag");
found = bands(&t, &cx);
frames += 1;
}
let [outline] = found.as_slice() else { panic!("{case}: one band, got {found:?}") };
for seg in outline.segments() {
for s in [0.0, 0.25, 0.5, 0.75, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, &pts);
assert!((dq - r).abs() < 0.02 * r, "{case}: {q:?} on the outline is {dq} from the drag, radius {r}");
}
}
// The band of each long segment shows on both of its sides, as far as the release reaches,
// except where the band of another part of the drag holds the side (on the page: a point
// on a segment to 1e17 resolves only to about 10 units).
let on_outline = |q: Point| {
outline.segments().any(|seg| {
(0..=8).any(|k| seg.eval(f64::from(k) / 8.0).distance(q) < 0.02 * r) || dist_to_drag(q, &[seg.start(), seg.end()]) < 0.02 * r
})
};
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) > 10_000.0 && w[0].x < 1e6 && w[1].x < 1e6) {
let d = w[1] - w[0];
let n = vectorcraft_geom::kurbo::Vec2::new(-d.y, d.x) * (r / d.hypot());
for k in 1..40 {
for q in [w[0].lerp(w[1], f64::from(k) / 40.0) + n, w[0].lerp(w[1], f64::from(k) / 40.0) - n] {
if dist_to_drag(q, &pts) > 0.98 * r {
assert!(on_outline(q), "{case}: {q:?} on a side of {w:?} shows");
}
}
}
}
if case == "back" {
// Running back along the jump draws its sides once: two sides and two half circles.
let length: f64 = outline.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 2.0 * 30_000.0 + 2.0 * std::f64::consts::PI * r;
assert!((length - want).abs() < 0.01 * want, "{case}: outline {length}, band {want}");
}
let kept = t.band.take();
let areas: Vec<_> = kept.iter().flat_map(|b| b.tiles.values().map(|t| t.area.to_bezpath())).collect();
t.band.set(kept);
// Every point near each short part dragged on either side of a long segment is in the band.
for w in pts.windows(2).filter(|w| w[0].distance(w[1]) < 1_000.0) {
for q in (0..=20).map(|k| w[0].lerp(w[1], f64::from(k) / 20.0)) {
for (dx, dy) in [(0.0, 0.0), (0.9, 0.0), (-0.9, 0.0), (0.0, 0.9), (0.0, -0.9)] {
let probe = q + vectorcraft_geom::kurbo::Vec2::new(dx * r, dy * r);
assert!(areas.iter().any(|a| a.contains(probe)), "{case}: {probe:?} near the drag is in the band");
}
}
}
}
}
/// A drag drawn zoomed out whose every segment is too long for the squares once the view zooms in,
/// and whose segments all cross one another, is outlined over several calls: no call does more
/// than about [`BAND_WORK_PER_CALL`] of work on the long segments, and the band then shows in full.
#[test]
fn smoothing_band_of_many_long_segments_is_made_in_bounded_calls() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
// A star whose chords nearly all pass through its middle, 4,000 units each.
let step = std::f64::consts::PI * (1.0 - 1.0 / 40.3);
let pts: Vec<Point> = (0..161).map(|k| f64::from(k) * step).map(|a| Point::new(5000.0 + 2000.0 * a.cos(), 5000.0 + 2000.0 * a.sin())).collect();
cx.zoom = 0.05;
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for q in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
bands(&t, &cx);
}
cx.zoom = 640.0;
let r = cx.tol(SMOOTH_RADIUS);
let work = |t: &GestureTool| {
let kept = t.band.take();
let w = kept.as_ref().map_or(0, |b| b.work);
t.band.set(kept);
w
};
let mut found = bands(&t, &cx);
let mut frames = 1;
let mut most = work(&t);
while t.overlays_pending() {
assert!(frames < 10_000, "the band reaches the whole drag");
found = bands(&t, &cx);
most = most.max(work(&t));
frames += 1;
}
assert!(most <= 2 * BAND_WORK_PER_CALL, "{most} work in one call");
assert!(frames > 2, "{frames} calls");
let kept = t.band.take();
let long = kept.as_ref().map_or(0, |b| b.long.len());
t.band.set(kept);
assert_eq!(long, pts.len() - 1, "every segment is long");
let [outline] = found.as_slice() else { panic!("one band, got {}", found.len()) };
for seg in outline.segments().step_by(7) {
for s in [0.0, 0.5, 1.0] {
let q = seg.eval(s);
let dq = dist_to_drag(q, &pts);
assert!((dq - r).abs() < 0.02 * r, "{q:?} on the outline is {dq} from the drag, radius {r}");
}
}
}
/// The band is kept in squares; a straight drag whose band edge runs along a side of a square, or
/// just inside or outside it, still shows that edge, and the sides between two squares the band
/// fills do not show.
#[test]
fn smoothing_band_shows_an_edge_along_a_square_side() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for zoom in [1.0, 3.0] {
cx.zoom = zoom;
let r = cx.tol(SMOOTH_RADIUS);
let side = BAND_TILE_RADII * r;
// A side of the squares near 230 (across and down: the grid is square).
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
let eps = 1e-3 * r;
for off in [0.0, 0.5 * eps, -0.5 * eps, 1.5 * eps, -1.5 * eps] {
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
let c = at + edge + off;
let ends = if upright {
[Point::new(c, 100.0), Point::new(c, 100.0 + 2.5 * side)]
} else {
[Point::new(100.0, c), Point::new(100.0 + 2.5 * side, c)]
};
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, ends[0].x, ends[0].y));
for i in 1..=20 {
let q = ends[0].lerp(ends[1], f64::from(i) / 20.0);
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
}
let band = check_band(&t, &cx, r);
// Two sides and two half circles, each shown once.
let length: f64 = band.segments().map(|s| s.arclen(1e-6 * r)).sum();
let want = 2.0 * 2.5 * side + 2.0 * std::f64::consts::PI * r;
assert!(
(length - want).abs() < 0.02 * want,
"zoom {zoom}, edge {edge}, off {off}, upright {upright}: outline {length}, band {want}, {:?}",
band.bounding_box()
);
}
}
}
}
/// A drag whose band edge runs along a side of a square and then turns across it shows the edge
/// up to the turn and no line where the band goes on across the side.
#[test]
fn smoothing_band_shows_no_seam_where_it_turns_across_a_square_side() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
for zoom in [1.0, 3.0] {
cx.zoom = zoom;
let r = cx.tol(SMOOTH_RADIUS);
let side = BAND_TILE_RADII * r;
let at = (BAND_TILE_OFFSET + (230.0 / side - BAND_TILE_OFFSET).round()) * side;
let eps = 1e-3 * r;
for off in [0.0, 0.5 * eps, -0.5 * eps] {
for (edge, upright) in [(-r, false), (r, false), (-r, true), (r, true)] {
let c = at + edge + off;
// Along the side, then across it.
let turn = 100.0 + 1.5 * side;
let corners = [(100.0, c), (turn, c), (turn, c - edge.signum() * 2.0 * side)];
let pts: Vec<Point> = corners.into_iter().map(|(u, v)| if upright { Point::new(v, u) } else { Point::new(u, v) }).collect();
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y));
for w in pts.windows(2) {
for i in 1..=12 {
let q = w[0].lerp(w[1], f64::from(i) / 12.0);
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x, q.y));
}
}
check_band(&t, &cx, r);
}
}
}
}
/// One straight piece of a square's outline along a side, where the band goes on across only part
/// of it, shows only the part with nothing across.
#[test]
fn smoothing_band_square_shows_only_the_open_part_of_a_side() {
use vectorcraft_geom::kurbo::{ParamCurveArclen, Shape};
let mut band = SmoothBand::new(Point::ZERO, 10.0);
let r = band.tile_rect((0, 0));
let (x, y) = (r.x0, r.y1);
let area = |x0: f64, y0: f64, x1: f64, y1: f64| vectorcraft_geom::shapes::rectangle(Rect::new(x0, y0, x1, y1));
let tile = |area| BandTile { area, outline: vectorcraft_geom::BezPath::new(), near_long: vec![], cut: vectorcraft_geom::BezPath::new() };
// Along the bottom side from 10 to 60, with the band across it from 40 to 60.
band.tiles.insert((0, 0), tile(area(x + 10.0, y - 20.0, x + 60.0, y)));
band.tiles.insert((0, 1), tile(area(x + 40.0, y, x + 60.0, y + 20.0)));
let outline = band.tile_outline((0, 0));
let along: f64 = outline.segments().filter(|s| s.bounding_box().height() < 1e-9 && (s.start().y - y).abs() < 1e-9).map(|s| s.arclen(1e-9)).sum();
assert!((along - 30.0).abs() < 1e-6, "{along} of the side shows: {}", outline.to_svg());
for s in outline.segments() {
assert!(!((s.start().y - y).abs() < 1e-9 && (s.end().y - y).abs() < 1e-9 && s.start().x.max(s.end().x) > x + 40.0 + 1e-9), "{s:?}");
}
}
/// A selection with no path the Smooth tool can change (a two-anchor line) shows no band.
#[test]
fn smoothing_drag_shows_no_band_without_a_path_it_smooths() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
]));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("smooth");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 300.0));
let o = t.overlays(&cx);
assert!(!o.iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "{o:?}");
}
/// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush.
#[test]
fn alt_smoothing_uses_the_smooth_tools_fidelity() {
@@ -1665,7 +2098,7 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() {
/// The Blob Brush's Angle and Roundness (Blob Brush Tool Options, 0° and 100% by default) go with
/// its stroke, kept within −180–180° and 0–100%; while it drags, a tip that is not round shows the
/// outline of the area it covers with the length label, and an Alt drag that smooths shows the
/// Smooth tool's thin line.
/// Smooth tool's thin line and the dashed band it smooths.
#[test]
fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
let (d, id) = doc_with_rect();
@@ -1704,7 +2137,7 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0).with_mods(alt));
let o = t.overlays(&cx);
let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") };
let [Overlay::Path { dashed: true, .. }, Overlay::Path { path, width, dashed: false, .. }] = &o[..] else { panic!("{o:?}") };
assert_eq!(*width, 1.0);
assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}");
}
+6
View File
@@ -136,6 +136,12 @@ pub trait Tool: Send {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
vec![]
}
/// Did the last [`Tool::overlays`] leave part of its overlays for later calls (the band of a
/// long smoothing drag, outlined again after a zoom change)? The host then draws again on the
/// next frame, even when the pointer holds still.
fn overlays_pending(&self) -> bool {
false
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Arrow
}
+4
View File
@@ -1266,6 +1266,10 @@ fn selection_visuals(
} else {
vec![]
};
// The tool shows the rest of its overlays on the next frame.
if app.session.tool_overlays_pending() {
painter.ctx().request_repaint();
}
// Gap Options' Preview shows the gaps it finds.
overlays.extend(crate::dialogs::live_paint_gaps::overlays(app));
if app.ui.view.edges && !revolve_edit {
+39 -12
View File
@@ -12,7 +12,9 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape,
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect);
// A modal dialog takes the canvas's presses, finger taps, long touches and view navigation, from the frame it opens.
let modal = crate::panel_docking::modal_open(app, ui.ctx());
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect, modal);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::at(rect, &v, app.canvas_ui_zoom);
// A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`),
@@ -21,11 +23,11 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
let view = app.view_info();
let (_, backquote_before) = backquote_seen(ui);
let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before };
touch_gestures(app, ui, rect, view);
let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before, modal };
touch_gestures(app, ui, rect, view, modal);
// Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the
// tool's press is dropped, as a right-click presses nothing.
if resp.long_touched() {
// tool's press is dropped, as a right-click presses nothing. Under a modal dialog it drops nothing.
if resp.long_touched() && !modal {
drop_press(app, ui, view);
}
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
@@ -84,9 +86,11 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
// A finger pressing with others down, or with another landing in its frame, presses nothing
// (`touch_gestures` made the press a gesture, #4198).
// (`touch_gestures` made the press a gesture, #4198). While a modal dialog is open no press
// reaches the tools or pans, also in the frame it opens, before its backdrop covers the canvas.
if pointer.primary_pressed()
&& resp.hovered()
&& !f.modal
&& !middle_pan
&& drag != Some(Drag::Gesture)
&& let Some(p) = at
@@ -110,6 +114,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
}
} else if drag.is_none()
&& resp.hovered()
&& !f.modal
&& pointer.button_pressed(egui::PointerButton::Middle)
&& let Some(start) = hover
{
@@ -169,6 +174,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
select_named_artboard(app, i, shift || release_shift);
}
if resp.double_clicked()
&& !f.modal
&& let Some(p) = at
{
double_click(app, ui, &f, p);
@@ -190,21 +196,37 @@ struct Pointing<'a> {
view: vectorcraft_engine::ViewInfo,
/// Whether Backtick was held at the end of the frame before ([`backquote_seen`]).
backquote_before: Option<bool>,
/// Whether a modal dialog is open ([`crate::panel_docking::modal_open`]).
modal: bool,
}
/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the
/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. While a modal dialog is open
/// (`modal`), and for the rest of a wheel turn made under it, the view does not move. Returns the pointer, the
/// modifiers, whether Space is held and where the pointer hovers over the canvas.
fn wheel_view(
app: &mut VectorcraftApp,
ui: &Ui,
resp: &egui::Response,
rect: egui::Rect,
modal: bool,
) -> (egui::PointerState, egui::Modifiers, bool, Option<Pos2>) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
// A wheel turn that goes on under a modal dialog moves nothing after the dialog closes either: what egui lets out
// of it later is dropped until egui ends the turn.
// `carried` is set only while a turn that went on under the dialog is still scrolling, so a new turn
// made after it closes moves the view at once.
let carried_id = egui::Id::unique("canvas-wheel-muted");
let was_carried = ui.data(|d| d.get_temp::<bool>(carried_id)).unwrap_or(false);
let carried = (modal || was_carried) && ui.input(egui::InputState::is_scrolling);
if carried != was_carried {
ui.data_mut(|d| d.insert_temp(carried_id, carried));
}
let muted = modal || carried;
let navigates = resp.hovered() && !muted;
// A docked field may be drawn later and has not consumed its wheel event yet. Only canvas
// hover may start a zoom glide; existing glides still decay outside the canvas in `wheel`.
let animate = animated_zoom(&app.session.prefs) && resp.hovered();
let animate = animated_zoom(&app.session.prefs) && navigates;
let turn_id = egui::Id::unique("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
@@ -216,6 +238,10 @@ fn wheel_view(
wheel(i, &app.session.prefs, wheel_zooms, animate, line, rect.height(), &mut turn),
)
});
// A zoom glide still running when a modal dialog opens stops.
if muted {
turn.glide = 0.0;
}
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
@@ -226,7 +252,7 @@ fn wheel_view(
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
// tool's events below take their document points from the view this moves to.
if resp.hovered() {
if navigates {
let ui_zoom = app.canvas_ui_zoom;
if (factor - 1.0).abs() > 1e-6
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
@@ -247,10 +273,11 @@ fn wheel_view(
}
/// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, drag or hover, and a
/// drag does not pan the view. A drag that began before the mode was turned on ends as usual.
/// drag does not pan the view. A drag that began before the mode was turned on ends as usual. While a modal dialog is
/// open, a click does not step.
fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option<Drag>, at: Option<Pos2>) {
let (resp, pointer) = (f.resp, f.pointer);
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some();
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some() && !f.modal;
// `press` is this frame's new press, or else the press held so far. A quick tap can be
// pressed and released in one frame.
let press = if pressed { Some(Drag::Present) } else { drag };
@@ -258,7 +285,7 @@ fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option<Dr
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
// A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release
// does not move the view to the next artboard.
if press == Some(Drag::Present) && resp.clicked() {
if press == Some(Drag::Present) && resp.clicked() && !f.modal {
crate::shortcuts::presentation_step(app, true);
}
} else if pressed {
+272
View File
@@ -1030,6 +1030,278 @@ fn two_finger_tap_in_touch_switches_selection_and_drawing_modes() {
assert_eq!(count(&app), before - 1, "Essentials: undone");
}
/// One frame of the canvas with `events`, with the modal backdrop drawn over it when `backdrop` is set, as the app
/// draws it for an open dialog.
fn modal_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, backdrop: bool) {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
show(app, ui);
if backdrop {
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
}
});
out.textures_delta.clear();
}
/// Finger `n` touching at `pos`.
fn finger(n: u64, phase: egui::TouchPhase, pos: Pos2) -> egui::Event {
egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }
}
/// The primary button pressed or released at `pos`.
fn finger_button(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// A tap of `fingers` fingers beside `c`, the first also pressing as the pointer and moving a little, in frames drawn
/// with the modal backdrop when `backdrop` is set ([`modal_frame`]).
fn finger_tap(app: &mut VectorcraftApp, ctx: &egui::Context, c: Pos2, fingers: u64, backdrop: bool) {
use egui::TouchPhase::{End, Move, Start};
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
let moved = at(0) + vec2(6.0, 6.0);
modal_frame(app, ctx, vec![finger(0, Start, at(0)), egui::Event::PointerMoved(at(0)), finger_button(at(0), true)], backdrop);
modal_frame(app, ctx, vec![finger(0, Move, moved), egui::Event::PointerMoved(moved)], backdrop);
modal_frame(app, ctx, (1..fingers).map(|n| finger(n, Start, at(n))).collect(), backdrop);
modal_frame(app, ctx, vec![finger(0, End, at(0)), finger_button(moved, false), egui::Event::PointerGone], backdrop);
modal_frame(app, ctx, (1..fingers).map(|n| finger(n, End, at(n))).collect(), backdrop);
modal_frame(app, ctx, vec![], backdrop);
}
/// While a modal is open, a two-finger tap on the canvas does not undo, a three-finger tap does not redo, and in the
/// Touch workspace a two-finger tap does not switch modes: with the app's dialog open, before its backdrop is drawn
/// (the frame it opens) and after, and under a modal backdrop alone.
#[test]
fn modal_blocks_two_and_three_finger_canvas_taps() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
modal_frame(&mut app, &ctx, vec![], false);
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
let c = app.canvas_rect.unwrap().center();
let settle = |app: &mut VectorcraftApp| {
app.ui.dialog = None;
modal_frame(app, &ctx, vec![], false);
modal_frame(app, &ctx, vec![], false);
};
for (dialog, backdrop) in [(true, true), (true, false), (false, true)] {
let open = |app: &mut VectorcraftApp| app.ui.dialog = dialog.then(|| crate::state::Dialog::new("preferences", json!({})));
open(&mut app);
finger_tap(&mut app, &ctx, c, 2, backdrop);
assert_eq!(count(&app), 1, "dialog {dialog}, backdrop {backdrop}: two fingers undo nothing");
settle(&mut app);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(count(&app), 0);
open(&mut app);
finger_tap(&mut app, &ctx, c, 3, backdrop);
assert_eq!(count(&app), 0, "dialog {dialog}, backdrop {backdrop}: three fingers redo nothing");
settle(&mut app);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(count(&app), 1);
}
crate::workspaces::switch(&mut app.ui, crate::workspaces::TOUCH).unwrap();
app.select_tool("selection");
modal_frame(&mut app, &ctx, vec![], false);
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
finger_tap(&mut app, &ctx, c, 2, true);
assert_eq!((app.session.tool_id(), count(&app)), ("selection", 1), "Touch: no switch to drawing mode under the dialog");
settle(&mut app);
finger_tap(&mut app, &ctx, c, 2, false);
assert_ne!(app.session.tool_id(), "selection", "closed: two fingers switch to drawing mode again");
}
/// Fingers down on the canvas when a modal dialog opens make no tap: lifting them while the dialog is open, or after it
/// closes, runs nothing. A tap begun after it closes undoes again.
#[test]
fn opening_a_modal_discards_a_pending_canvas_tap() {
use egui::TouchPhase::{End, Start};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
modal_frame(&mut app, &ctx, vec![], false);
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
let c = app.canvas_rect.unwrap().center();
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
modal_frame(&mut app, &ctx, vec![finger(0, Start, at(0)), egui::Event::PointerMoved(at(0)), finger_button(at(0), true)], false);
modal_frame(&mut app, &ctx, vec![finger(1, Start, at(1))], false);
// The dialog opens in a frame with no touch, and one finger lifts while it is open.
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
modal_frame(&mut app, &ctx, vec![], true);
modal_frame(&mut app, &ctx, vec![finger(0, End, at(0)), finger_button(at(0), false), egui::Event::PointerGone], true);
assert_eq!(count(&app), 1, "nothing undone while the dialog is open");
// The other finger lifts after it closes.
app.ui.dialog = None;
modal_frame(&mut app, &ctx, vec![], false);
modal_frame(&mut app, &ctx, vec![], false);
modal_frame(&mut app, &ctx, vec![finger(1, End, at(1))], false);
modal_frame(&mut app, &ctx, vec![], false);
assert_eq!(count(&app), 1, "the tap begun before the dialog runs nothing after it closes");
finger_tap(&mut app, &ctx, c, 2, false);
assert_eq!(count(&app), 0, "a tap begun after it closes undoes");
}
/// While a modal is open and the canvas is not yet covered by its backdrop (the frame a dialog opens), a click in
/// Presentation Mode does not step to the next artboard, and a pinch or a wheel does not move the view. Closed, both
/// act again.
#[test]
fn modal_blocks_presentation_tap_and_touch_navigation() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap();
let ctx = egui::Context::default();
let dialog = || Some(crate::state::Dialog::new("preferences", json!({})));
app.ui.screen_mode = 3;
modal_frame(&mut app, &ctx, vec![], false);
let c = app.canvas_rect.unwrap().center();
let click = |app: &mut VectorcraftApp| {
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(c)], false);
modal_frame(app, &ctx, vec![finger_button(c, true)], false);
modal_frame(app, &ctx, vec![finger_button(c, false)], false);
modal_frame(app, &ctx, vec![], false);
};
let at = |app: &VectorcraftApp| crate::panels::artboards::selected(app, 2);
assert_eq!(at(&app), 0);
app.ui.dialog = dialog();
click(&mut app);
assert_eq!(at(&app), 0, "no step under the dialog");
app.ui.dialog = None;
click(&mut app);
assert_eq!(at(&app), 1, "closed: a click steps");
app.ui.screen_mode = 0;
modal_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)], false);
let view = |app: &VectorcraftApp| {
let v = app.view().unwrap();
(v.zoom, v.center)
};
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 40.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let before = view(&app);
app.ui.dialog = dialog();
modal_frame(&mut app, &ctx, vec![egui::Event::Zoom(2.0)], false);
modal_frame(&mut app, &ctx, vec![wheel.clone()], false);
for _ in 0..30 {
modal_frame(&mut app, &ctx, vec![], false);
}
assert_eq!(view(&app), before, "the view does not move under the dialog");
// A wheel turn of whole lines, which egui lets out over several frames, made just before the dialog closes.
let lines = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 3.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
modal_frame(&mut app, &ctx, vec![lines], false);
app.ui.dialog = None;
for _ in 0..30 {
modal_frame(&mut app, &ctx, vec![], false);
}
assert_eq!(view(&app), before, "the rest of a wheel turn made under the dialog does not move the view after it closes");
modal_frame(&mut app, &ctx, vec![egui::Event::Zoom(2.0)], false);
assert_ne!(view(&app).0, before.0, "closed: a pinch zooms");
let zoomed = view(&app);
modal_frame(&mut app, &ctx, vec![wheel.clone()], false);
for _ in 0..30 {
modal_frame(&mut app, &ctx, vec![], false);
}
assert_ne!(view(&app), zoomed, "closed: a new wheel turn moves the view");
// A dialog opened and closed with no wheel turn under it: a wheel turn in the first frame after it closes moves
// the view.
let scrolled = view(&app);
app.ui.dialog = dialog();
modal_frame(&mut app, &ctx, vec![], false);
app.ui.dialog = None;
modal_frame(&mut app, &ctx, vec![wheel], false);
for _ in 0..30 {
modal_frame(&mut app, &ctx, vec![], false);
}
assert_ne!(view(&app), scrolled, "a wheel turn just after the dialog closes moves the view");
}
/// While a modal is open, a pen or finger held still on the canvas does not drop the press it began.
#[test]
fn modal_long_touch_does_not_cancel_canvas_press() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
let tick = |app: &mut VectorcraftApp, time, events| {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
tick(&mut app, 0.0, vec![]);
let pos = app.canvas_rect.unwrap().center();
tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5));
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)));
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
tick(&mut app, 1.5, vec![]);
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)), "the press is kept under the dialog");
}
/// While a modal is open and its backdrop does not yet cover the canvas (the frame a dialog opens), a drag on the canvas
/// draws nothing, a two-finger tap with a drawing tool draws nothing, a middle-button drag does not pan and a
/// double-click on type does not start editing it. Closed, a drag draws again.
#[test]
fn modal_blocks_new_canvas_presses() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
app.select_tool("rectangle");
let ctx = egui::Context::default();
modal_frame(&mut app, &ctx, vec![], false);
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
let from = app.canvas_rect.unwrap().center() + vec2(0.0, 80.0);
let drag = |app: &mut VectorcraftApp, button: egui::PointerButton| {
let to = from + vec2(60.0, 40.0);
let b = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(from), b(from, true)], false);
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(from + vec2(30.0, 20.0))], false);
modal_frame(app, &ctx, vec![egui::Event::PointerMoved(to)], false);
modal_frame(app, &ctx, vec![b(to, false)], false);
modal_frame(app, &ctx, vec![], false);
};
let before = count(&app);
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({})));
drag(&mut app, egui::PointerButton::Primary);
assert_eq!(count(&app), before, "a drag with the Rectangle tool draws nothing under the dialog");
finger_tap(&mut app, &ctx, from, 2, false);
assert_eq!(count(&app), before, "a two-finger tap with the Rectangle tool draws nothing under the dialog");
let center = app.view().unwrap().center;
drag(&mut app, egui::PointerButton::Middle);
assert_eq!(app.view().unwrap().center, center, "a middle-button drag does not pan under the dialog");
app.select_tool("selection");
modal_frame(&mut app, &ctx, vec![], false);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
let at = xf.to_screen(b.center());
let button = |pressed| finger_button(at, pressed);
modal_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)], false);
modal_frame(&mut app, &ctx, vec![], false);
assert_eq!(app.session.tool_id(), "selection", "a double-click on type does not edit it under the dialog");
app.ui.dialog = None;
app.select_tool("rectangle");
modal_frame(&mut app, &ctx, vec![], false);
modal_frame(&mut app, &ctx, vec![], false);
drag(&mut app, egui::PointerButton::Primary);
assert_eq!(count(&app), before + 1, "closed: a drag draws");
}
/// Switch Selection and Drawing Modes from the command: Pencil when no drawing tool was chosen yet,
/// a view tool is left for Selection without becoming the drawing tool, and every selection tool
/// switches to drawing mode.
+9 -2
View File
@@ -534,10 +534,18 @@ pub(super) fn wheel(
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. In the Touch
/// workspace a tap of two fingers switches between selection and drawing modes instead.
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
///
/// While a modal dialog is open (`modal`) the canvas takes no finger contacts: no tap runs a command and no gesture
/// begins. The fingers already down are forgotten, so lifting them, during the dialog or after it closes, runs nothing.
pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo, modal: bool) {
if !app.session.prefs.touch_gestures {
return;
}
let key = egui::Id::unique("canvas-touch");
if modal {
ui.data_mut(|d| d.remove::<crate::touch::Taps>(key));
return;
}
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
let t = i.events.iter().filter_map(|e| match e {
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
@@ -548,7 +556,6 @@ pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect
if touches.is_empty() {
return;
}
let key = egui::Id::unique("canvas-touch");
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
let mut tap = None;
for (id, phase, pos) in touches {
+13 -3
View File
@@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// the layout.
if let Some(fields) = ui.as_object_mut() {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
leave_out_unreadable_paths(app, fields);
}
json!({
"scriptRunning": app.session.script_running(),
@@ -1091,8 +1091,10 @@ fn leave_out_unreadable_values(v: &mut Value) {
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths
/// in recorded actions' steps outside the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
/// in recorded actions' steps outside the read root. The file selected in an open JavaScript Files
/// dialog is named as `svgInteractivity.info` names the active document's linked files. The UI state
/// itself is not changed.
fn leave_out_unreadable_paths(app: &VectorcraftApp, ui: &mut serde_json::Map<String, Value>) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
}
@@ -1115,6 +1117,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
{
leave_out_unreadable_folders(fields);
}
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
&& dialog.get("kind").and_then(Value::as_str) == Some(crate::dialogs::javascript_files::KIND)
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
&& let Some(Value::String(selected)) = fields.get_mut("selected")
&& let Some(st) = app.session.active()
{
*selected = vectorcraft_engine::cmd::links::shown_script(st, selected).into_owned();
}
}
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written
+2
View File
@@ -60,6 +60,7 @@ pub mod graphic_style_options;
pub mod halftone;
pub mod hyphenation;
pub mod import_pdf;
pub mod indexed_color;
pub mod javascript_files;
pub mod justification;
pub mod layer_options;
@@ -347,6 +348,7 @@ registry! {
ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC,
ColorBalance: [color_balance::KIND] => color_balance::SPEC,
Saturate: [saturate::KIND] => saturate::SPEC,
IndexedColor: [indexed_color::KIND] => indexed_color::SPEC,
SaveSwatchLibrary: [save_swatch_library::KIND] => save_swatch_library::SPEC,
TileEdgeColor: [tile_edge_color::KIND] => tile_edge_color::SPEC,
EyedropperOptions: [eyedropper::KIND] => eyedropper::SPEC,
+135
View File
@@ -0,0 +1,135 @@
//! Edit → Edit Colors → Convert to Indexed Color: the palette (reduction and colors) and the
//! dither of the selection's embedded images, previewed live on the canvas; OK keeps the preview
//! as one undo step (`edit.colors.toIndexed`).
//!
//! Fields: `reduction`, `colors`, `dither`, `ditherAmount` and `preview`.
use serde_json::{Value, json};
use vectorcraft_render::encode::quantize::{Dither, Reduction};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Convert to Indexed Color.
pub const KIND: &str = "indexedColor";
const CMD: &str = "edit.colors.toIndexed";
/// The reductions besides those of a PNG-8 export, as (value, label).
const MORE_REDUCTIONS: [(&str, &str); 2] = [("uniform", "Uniform"), ("document", "Document Swatches")];
/// Label column and dropdown widths.
const WIDTHS: (f32, f32) = (110.0, 160.0);
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Convert to Indexed Color").into(), body, confirm, preview: true, min_width: 320.0, ..DialogSpec::FORM };
/// Open the dialog with the defaults of `edit.colors.toIndexed`.
pub fn open(app: &mut VectorcraftApp) {
let fields = json!({"reduction": "selective", "colors": 256, "dither": "diffusion", "ditherAmount": 100, "preview": true});
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
/// Whether reduction `r` takes a number of colors.
fn takes_colors(r: &str) -> bool {
!matches!(r, "blackWhite" | "document")
}
/// The fewest colors reduction `r` takes.
fn least_colors(r: &str) -> f64 {
if r == "uniform" { 9.0 } else { 2.0 }
}
/// `edit.colors.toIndexed` parameters: the reduction, its colors when it takes them, and the dither.
fn params(d: &Dialog) -> Value {
let reduction = d.str("reduction");
let dither = d.str("dither");
let mut p = json!({"reduction": reduction, "dither": dither});
if takes_colors(&reduction) {
p["colors"] = json!(d.f64("colors", 256.0).round().clamp(least_colors(&reduction), 256.0) as u16);
}
if dither != Dither::None.id() {
p["ditherAmount"] = json!(d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0));
}
p
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let reductions: Vec<(&str, &str)> = Reduction::ALL.iter().map(|r| (r.id(), r.label())).chain(MORE_REDUCTIONS).collect();
form::choice(ui, d, "reduction", tl!("Reduction:"), WIDTHS, &reductions);
let reduction = d.str("reduction");
if takes_colors(&reduction) {
// The field shows the number the command gets: at least the reduction's fewest.
let least = least_colors(&reduction);
let n = d.f64("colors", 256.0).round().clamp(least, 256.0);
d.fields.insert("colors".into(), json!(n as u16));
widgets::label_row(ui, tl!("Colors:"), WIDTHS.0, |ui| {
if let Some(n) = widgets::range_field(ui, "indexed-colors", n, least..=256.0, "", 0, 60.0) {
d.fields.insert("colors".into(), json!(n.round() as u16));
}
});
}
let dithers: Vec<(&str, &str)> = Dither::ALL.iter().map(|x| (x.id(), x.label())).collect();
form::choice(ui, d, "dither", tl!("Dither:"), WIDTHS, &dithers);
if d.str("dither") != Dither::None.id() {
widgets::label_row(ui, tl!("Amount:"), WIDTHS.0, |ui| {
if let Some(n) = widgets::range_field(ui, "indexed-dither-amount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 60.0) {
d.fields.insert("ditherAmount".into(), json!(n.round()));
}
});
}
let p = params(d);
form::preview(app, ui, d, "Convert to Indexed Color", CMD, p);
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn commits_the_chosen_palette_as_one_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let mut png = Vec::new();
let img = image::RgbaImage::from_raw(2, 1, vec![200, 30, 90, 255, 20, 40, 220, 255]).unwrap();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let id = app
.session
.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject {
key: "pic".into(),
width: 2,
height: 1,
xf: Default::default(),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap();
let undo = app.session.doc().unwrap().history.undo.len();
app.run("ui.indexedColorDialog", json!({})).unwrap();
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
fields.insert("reduction".into(), json!("blackWhite"));
fields.insert("dither".into(), json!("none"));
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
let vectorcraft_doc::NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("not an image") };
let blob = &st.doc.images[&im.key];
assert!(blob.indexed_png());
assert_eq!(blob.rgba().unwrap().2, [0, 0, 0, 255, 0, 0, 0, 255], "black and white only");
}
}
+60 -5
View File
@@ -6,8 +6,12 @@
//! linked file. Add and Remove act at once. The dialog has only Close.
//!
//! Fields: `file` (the path or URL to link), `selected` (a linked file).
//!
//! In a frame automation drives with read roots in force, the list names a file outside them by
//! its file name alone, as `svgInteractivity.info` does; `selected` keeps the file as linked.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::links::shown_script;
use super::DialogSpec;
use crate::picks::PickRequest;
@@ -63,18 +67,36 @@ enum Action {
Remove,
}
/// The active document's linked files, each as it is linked and as the list shows it
/// ([`shown_script`]).
fn listed(app: &VectorcraftApp) -> Vec<(String, String)> {
app.session.active().map_or_else(Vec::new, |st| st.doc.svg_scripts.iter().map(|f| (f.clone(), shown_script(st, f).into_owned())).collect())
}
/// The row of `files` ([`listed`]) that `selected` names: the one row linked or shown as it. A
/// click selects a file as it is linked; a request may also give it as the list shows it. A name
/// that fits several rows selects none, as `svgInteractivity.removeFile` refuses it.
fn selected_row(files: &[(String, String)], selected: &str) -> Option<usize> {
let mut rows = files.iter().enumerate().filter(|(_, (linked, shown))| linked == selected || shown == selected);
match (rows.next(), rows.next()) {
(Some((i, _)), None) => Some(i),
_ => None,
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let files: Vec<String> = app.session.active().map(|st| st.doc.svg_scripts.clone()).unwrap_or_default();
let files = listed(app);
let selected = d.str("selected");
let row = selected_row(&files, &selected);
let mut act = None;
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("javascript-files").min_scrolled_height(140.0).max_height(140.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(140.0);
// Paths and URLs are data: shown as they are.
for f in &files {
if ui.selectable_label(*f == selected, f.as_str()).clicked() {
act = Some(Action::Select(f.clone()));
for (i, (linked, shown)) in files.iter().enumerate() {
if ui.selectable_label(row == Some(i), shown.as_str()).clicked() {
act = Some(Action::Select(linked.clone()));
}
}
});
@@ -94,7 +116,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if ui.add_enabled_ui(!d.str("file").trim().is_empty(), |ui| widgets::flat_button(ui, tl!("Add"), 64.0)).inner.clicked() {
act = Some(Action::Add);
}
if ui.add_enabled_ui(files.contains(&selected), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
if ui.add_enabled_ui(row.is_some(), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
act = Some(Action::Remove);
}
});
@@ -164,6 +186,39 @@ mod tests {
assert!(texts.iter().any(|t| t == "https://example.com/b.js"), "the field shows `file`: {texts:?}");
}
/// In a frame drawn with read roots in force, the list names a linked file outside them by its
/// file name alone, and that name unlinks it; the person sees the whole path.
#[test]
fn automation_javascript_files_dialog_hides_outside_root_paths() {
let base = std::env::temp_dir().join(format!("vc-js-dialog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
let outside = base.join("outside").join("menu.js").to_string_lossy().into_owned();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("svgInteractivity.addFile", &json!({ "file": outside })).unwrap();
let mut d = Dialog::new(KIND, json!({ "file": "", "selected": "" }));
let texts = frame(&mut app, &mut d);
assert!(texts.contains(&outside), "{texts:?}");
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let texts = vectorcraft_engine::file_access::confine(roots.as_ref(), || frame(&mut app, &mut d));
assert!(texts.iter().any(|t| t == "menu.js") && !texts.iter().any(|t| t.contains("vc-js-dialog")), "{texts:?}");
// A row keeps the file as linked, so a click selects it by its whole path, which stays
// selected in the person's list; a name a request gives selects it too.
let rows = vectorcraft_engine::file_access::confine(roots.as_ref(), || listed(&app));
assert_eq!(rows, [(outside.clone(), "menu.js".to_string())]);
assert_eq!((selected_row(&rows, &outside), selected_row(&rows, "menu.js")), (Some(0), Some(0)));
// A name two rows show selects neither.
let twins = [rows[0].clone(), ("/elsewhere/menu.js".to_string(), "menu.js".to_string())];
assert_eq!((selected_row(&twins, "menu.js"), selected_row(&twins, "/elsewhere/menu.js")), (None, Some(1)));
assert_eq!(listed(&app), [(outside.clone(), outside.clone())], "the person's");
// Remove sends the selected name.
let removed = vectorcraft_engine::file_access::confine(roots.as_ref(), || app.run("svgInteractivity.removeFile", json!({"file": "menu.js"})));
assert!(removed.is_ok(), "{removed:?}");
assert!(app.session.active().is_some_and(|st| st.doc.svg_scripts.is_empty()));
let _ = std::fs::remove_dir_all(base);
}
/// Choose… puts the picked file in File or URL as a `file:///` URL, with no `\` and with
/// spaces percent-encoded.
#[test]
+1
View File
@@ -428,6 +428,7 @@ fn dialogs_do_not_stretch_to_the_screen() {
("ui.colorGuideOptions", json!({})),
("ui.colorBalanceDialog", json!({})),
("ui.saturateDialog", json!({})),
("ui.indexedColorDialog", json!({})),
("ui.saveSwatchLibrary", json!({})),
("ui.flattenTransparencyDialog", json!({})),
("ui.expandDialog", json!({})),
+2
View File
@@ -614,6 +614,8 @@ font-technology Variable Променливи
Convert to Artboards Конвертиране в работни области
Convert to CMYK Конвертиране в CMYK
Convert to Grayscale Конвертиране в ска̀ла на сивото
Convert to Indexed Color Конвертиране в индексирани цветове
Convert to Indexed Color… Конвертиране в индексирани цветове…
Convert To Outlines Конвертиране в контури
Convert To Point Type Конвертиране в точков текст
Convert to RGB Конвертиране в RGB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -103,6 +103,8 @@
Blend Vertically Stopi okomito
Convert to CMYK Pretvori u CMYK
Convert to Grayscale Pretvori u nijanse sive
Convert to Indexed Color Pretvori u indeksirane boje
Convert to Indexed Color… Pretvori u indeksirane boje…
Convert to RGB Pretvori u RGB
Invert Colors Obrni boje
Overprint Black… Pretisak crne…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -906,6 +906,8 @@ antiAlias Sharp Nítid
Convert to destination (preserve numbers) Converteix al destí (conserva els números)
Convert to Global Converteix en global
Convert to Grayscale Converteix a escala de grisos
Convert to Indexed Color Converteix a color indexat
Convert to Indexed Color… Converteix a color indexat…
Convert To Outlines Converteix en contorns
Convert To Point Type Converteix en text de punt
Convert to RGB Converteix a RGB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -108,6 +108,8 @@
Blend Vertically Prolnout svisle
Convert to CMYK Převést na CMYK
Convert to Grayscale Převést na stupně šedi
Convert to Indexed Color Převést na indexované barvy
Convert to Indexed Color… Převést na indexované barvy…
Convert to RGB Převést na RGB
Invert Colors Invertovat barvy
Overprint Black… Přetisknout černou…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -96,6 +96,8 @@
Blend Vertically Bland lodret
Convert to CMYK Konvertér til CMYK
Convert to Grayscale Konvertér til gråtoner
Convert to Indexed Color Konvertér til indekserede farver
Convert to Indexed Color… Konvertér til indekserede farver…
Convert to RGB Konvertér til RGB
Invert Colors Invertér farver
Overprint Black… Overprint sort…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -607,6 +607,8 @@ font-technology Variable Variabel
Convert to Artboards In Zeichenflächen umwandeln
Convert to CMYK In CMYK konvertieren
Convert to Grayscale In Graustufen konvertieren
Convert to Indexed Color In indizierte Farben konvertieren
Convert to Indexed Color… In indizierte Farben konvertieren…
Convert To Outlines In Pfade umwandeln
Convert To Point Type In Punkttext umwandeln
Convert to RGB In RGB konvertieren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -588,6 +588,8 @@ font-technology Variable Μεταβλητές
Convert to Artboards Μετατροπή σε πίνακες σχεδίασης
Convert to CMYK Μετατροπή σε CMYK
Convert to Grayscale Μετατροπή σε κλίμακα του γκρι
Convert to Indexed Color Μετατροπή σε χρώματα με ευρετήριο
Convert to Indexed Color… Μετατροπή σε χρώματα με ευρετήριο…
Convert To Outlines Μετατροπή σε περιγράμματα
Convert To Point Type Μετατροπή σε κείμενο σημείου
Convert to RGB Μετατροπή σε RGB
Can't render this file because it is too large.
+2
View File
@@ -629,6 +629,8 @@ font-technology Variable Variables
Convert to Artboards Convertir en mesas de trabajo
Convert to CMYK Convertir a CMYK
Convert to Grayscale Convertir a escala de grises
Convert to Indexed Color Convertir a color indexado
Convert to Indexed Color… Convertir a color indexado…
Convert To Outlines Convertir en contornos
Convert To Point Type Convertir en texto de punto
Convert to RGB Convertir a RGB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -116,6 +116,8 @@
Blend Vertically Sulauta pystysuunnassa
Convert to CMYK Muunna CMYK:ksi
Convert to Grayscale Muunna harmaasävyiseksi
Convert to Indexed Color Muunna indeksoiduiksi väreiksi
Convert to Indexed Color… Muunna indeksoiduiksi väreiksi…
Convert to RGB Muunna RGB:ksi
Invert Colors Käännä värit
Overprint Black… Päällepainata musta…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -630,6 +630,8 @@ font-technology Variable Variables
Convert to Artboards Convertir en plans de travail
Convert to CMYK Convertir en CMJN
Convert to Grayscale Convertir en niveaux de gris
Convert to Indexed Color Convertir en couleurs indexées
Convert to Indexed Color… Convertir en couleurs indexées…
Convert To Outlines Vectoriser
Convert To Point Type Convertir en texte de point
Convert to RGB Convertir en RVB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+69 -67
View File
@@ -866,7 +866,7 @@ antiAlias Sharp शार्प
Close All सभी बंद करें
Close Gaps With Paths पाथ के साथ अंतराल बंद करें
Close Library लाइब्रेरी
Close or Join बंद करें या शामिल हों
Close or Join बंद करें या जोड़ें
Close Panel पैनल बंद करें
Close Path बंद पाथ
Close Path: बंद पाथ:
@@ -1036,6 +1036,8 @@ antiAlias Sharp शार्प
Convert to destination (preserve numbers) गंतव्य में कनवर्ट करें (संख्या संरक्षित करें)
Convert to Global ग्लोबल में कनवर्ट करें
Convert to Grayscale ग्रेस्केल में बदलें
Convert to Indexed Color अनुक्रमित रंग में बदलें
Convert to Indexed Color… अनुक्रमित रंग में बदलें…
Convert To Outlines कन्वर्ट करने के लिए रूपरेखा
Convert to Parametric Shape पैरामीट्रिक शेप में कन्वर्ट करें
Convert To Point Type पॉइंट टाइप में कनवर्ट करें
@@ -1232,7 +1234,7 @@ antiAlias Sharp शार्प
Default Position डिफ़ॉल्ट स्थिति
Default Printer डिफ़ॉल्ट प्रिंटर
Default Swatches डिफ़ॉल्ट स्वॉचेस
Define a variable first पहले एक चर को परिभाषित करें
Define a variable first पहले एक वेरिएबल परिभाषित करें
Define Grid… ग्रिड परिभाषित…
Define Perspective Grid पर्सपेक्टिव ग्रिड डिफ़ाइन करें
Define Perspective Grid… पर्सपेक्टिव ग्रिड डिफ़ाइन करें…
@@ -1308,7 +1310,7 @@ antiAlias Sharp शार्प
Delete this saved preset इस सेव किए गए प्रीसेट को हटा दें
Delete Toolbar टूलबार हटाएं
Delete Transparency Flattener Preset पारदर्शिता फ़्लैटनर प्रीसेट हटाएँ
Delete Variable चर हटाएँ
Delete Variable वेरिएबल हटाएँ
Delete Variables वेरिएबल्स हटाएँ
Delete View व्यू हटाएँ
Delete Width Point चौड़ाई बिंदु हटाएँ
@@ -2251,7 +2253,7 @@ antiAlias Sharp शार्प
Highlight Substituted Fonts हाईलाइट सबस्टिट्यूटेड फ़ॉन्ट
Highlight Substituted Glyphs बदले हुए ग्लिफ़ हाइलाइट करें
Highlight Tracking हाइलाइट ट्रैकिंग
Highlight Variable उच्च प्रकाश परिवर्तनीय
Highlight Variable वेरिएबल हाइलाइट करें
Highlight: हाइलाइट:
Hindi हिन्दी
History हिस्ट्री
@@ -2440,9 +2442,9 @@ antiAlias Sharp शार्प
Join जोड़ें
Join our Discord हमारे डिसकॉर्ड में शामिल हों
Join Our Discord हमारे डिसकॉर्ड में शामिल हों
Join Point Text पॉइंट टेक्स्ट में शामिल हों
Join Point Text पॉइंट टेक्स्ट जोड़ें
Join the ArtCraft community on Discord (discord.gg/artcraft) Discord (discord.gg/artcraft) पर ArtCraft समुदाय में शामिल हों
Join the lines of each paragraph प्रत्येक पैराग्राफ की लाइनों में शामिल हों
Join the lines of each paragraph हर पैराग्राफ़ की लाइनें जोड़ें
Join Tool जॉइन टूल
Joins जुड़ना
Joins: जुड़ना:
@@ -2644,7 +2646,7 @@ antiAlias Sharp शार्प
List Preferences सूची प्राथमिकताएं
List Slices सूची स्लाइस
List Toolbars सूची टूलबार
List Variables सूची चर
List Variables वेरिएबल्स सूचीबद्ध करें
List View सूची दृश्य
List Workspaces सूची वर्कस्पेस
Live curve fitting लाइव वक्र फिटिंग
@@ -2667,8 +2669,8 @@ antiAlias Sharp शार्प
Load SVG into Clipboard SVG को क्लिपबोर्ड में लोड करें
Load Text into Clipboard क्लिपबोर्ड में टेक्स्ट लोड करें
Load Texture… लोड बनावट…
Load Variable Library लोड परिवर्तनीय लाइब्रेरी
Load Variable Library… लोड परिवर्तनीय लाइब्रेरी…
Load Variable Library वेरिएबल लाइब्रेरी लोड करें
Load Variable Library… वेरिएबल लाइब्रेरी लोड करें…
Loaded Texture भारित बनावट
Loads native code मूल कोड लोड करता है
Local Libraries स्थानीय लाइब्रेरी
@@ -2690,7 +2692,7 @@ antiAlias Sharp शार्प
Lock Selected लॉक सिलेक्टेड
Lock Slices स्लाइस लॉक करें
Lock Station Point लॉक स्टेशन पॉइंट
Lock Variables लॉक चर
Lock Variables वेरिएबल्स लॉक करें
Locked लॉक्ड
Logo 1000×1000 लोगो 1000 × 1000
Long लंबा
@@ -3007,8 +3009,8 @@ antiAlias Sharp शार्प
New theme from the current color वर्तमान रंग से नया विषय
New Toolbar नया टूलबार
New Toolbar… नया टूलबार…
New Variable नया परिवर्तनीय
New Variable… नई परिवर्तनीय…
New Variable नया वेरिएबल
New Variable… नया वेरिएबल…
New View नया दृश्य
New View… नया दृश्य…
New Window नई विंडो
@@ -3073,7 +3075,7 @@ antiAlias Sharp शार्प
No text selected कोई टेक्स्ट नहीं चुना गया
No Thumbnails कोई थंबनेल नहीं
No unused symbols अप्रयुक्त सिंबल
no variable to rename नाम बदलने के लिए कोई चर नहीं
no variable to rename नाम बदलने के लिए कोई वेरिएबल नहीं
Noise नॉइज़
Nonbreaking Hyphen न टूटने वाला हाइफ़न
Nonbreaking Space न टूटने वाला स्पेस
@@ -3734,7 +3736,7 @@ antiAlias Sharp शार्प
Rays रे
Read Action Set File एक्शन सेट फ़ाइल पढ़ें
Read Shortcut Set File शॉर्टकट सेट फ़ाइल पढ़ें
Reads or sets environment variables पर्यावरण चर पढ़ता है या सेट करता है
Reads or sets environment variables एनवायरनमेंट वेरिएबल्स पढ़ता है या सेट करता है
Ready तैयार
Real name वास्तविक नाम
Real World: रियल वर्ल्ड:
@@ -3909,7 +3911,7 @@ antiAlias Sharp शार्प
Rename Preset प्रीसेट का नाम बदलें
Rename Style स्टाइल का नाम बदलें
Rename Toolbar टूलबार नाम दें
Rename Variable चर का नाम बदलें
Rename Variable वेरिएबल का नाम बदलें
Rename View व्यू का नाम बदलें
Rename Workspace वर्कस्पेस का नाम बदलें
Rename… नाम बदलें…
@@ -4205,7 +4207,7 @@ antiAlias Sharp शार्प
Save To: यहाँ सेव करें:
Save Transparency Flattener Preset ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें
Save Transparency Flattener Preset… ट्रांसपेरेंसी फ़्लैटनर प्रीसेट सेव करें…
Save Variable Library… वैरिएबल लाइब्रेरी सेव करें…
Save Variable Library… वेरिएबल लाइब्रेरी सेव करें…
Save… सेव…
Saved सेव किए गए
SAVED सेव किए गए
@@ -5377,11 +5379,11 @@ antiAlias Sharp शार्प
V Spacing: वी स्पेसिंग:
V: V:
Value Axis मूल्य एक्सिस
Variable चर
Variable वेरिएबल
Variable Font वेरिएबल फ़ॉन्ट
Variable Options वैरिएबल ऑप्शन्स
Variable Options… परिवर्तनीय ऑप्शन्स…
Variable Width Profile वैरिएबल चौड़ाई प्रोफ़ाइल
Variable Options वेरिएबल ऑप्शन्स
Variable Options… वेरिएबल ऑप्शन्स…
Variable Width Profile वेरिएबल चौड़ाई प्रोफ़ाइल
Variables वेरिएबल्स
Variation वैरिएशन
Variation: भिन्नता:
@@ -6030,7 +6032,7 @@ antiAlias Sharp शार्प
@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है।
@msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए
@msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए।
@msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है
@msg `ends` applies to joining exactly two paths `ends` ठीक दो पाथ को जोड़ने पर लागू होता है
@msg `event` must be one of {_1} `event` is one of the {_1}
@msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v}
@msg `expand` must be true or false `expand` को true या false होना चाहिए
@@ -6246,7 +6248,7 @@ antiAlias Sharp शार्प
@msg a multi-layer mask needs one clipping path बहुलेयर मास्क को एक क्लिपिंग पाथ की आवश्यकता होती है
@msg a multi-layer mask's operators are out of order बहुलेयर मास्क ऑपरेटर क्रम से बाहर हैं
@msg a name names one asset: give multiple: false, or one object एक नाम एक ही एसेट को दर्शाता है: multiple: false दें, या एक ऑब्जेक्ट
@msg a new document refuses this file because of these variables: {names} एक नया डॉक्यूमेंट इन चरों के कारण इस फ़ाइल को मना कर देता है: {names}
@msg a new document refuses this file because of these variables: {names} इन वेरिएबल्स के कारण नया डॉक्यूमेंट इस फ़ाइल को स्वीकार नहीं करता: {names}
@msg a new document won't read these variables back with their kind: {names} नया डॉक्यूमेंट इन वेरिएबल्स का प्रकार वापस नहीं पढ़ पाएगा: {names}
@msg a page can't be read एक पेज पढ़ा नहीं जा सकता
@msg a page is missing एक पेज गायब हो रहा है
@@ -6298,8 +6300,8 @@ antiAlias Sharp शार्प
@msg a toolbar name has at most {MAX_NAME} characters टूलबार नाम में सबसे {MAX_NAME} वर्ण हैं
@msg a value has the wrong type for the operator एक मूल्य ऑपरेटर के लिए गलत प्रकार है
@msg a value is outside the range the operator takes एक मूल्य सीमा के बाहर है जो ऑपरेटर लेता है
@msg a variable library holds text variables, and the document has none एक परिवर्तनीय लाइब्रेरी टेक्स्ट चर रखती है, और डॉक्यूमेंट में कोई नहीं है
@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार की एक चर ऑब्जेक्ट्स के लिए बाध्य नहीं किया जा सकता है
@msg a variable library holds text variables, and the document has none वेरिएबल लाइब्रेरी में टेक्स्ट वेरिएबल होते हैं, लेकिन डॉक्यूमेंट में कोई नहीं है
@msg a variable of unknown kind can't be bound to objects अज्ञात प्रकार का वेरिएबल ऑब्जेक्ट्स से नहीं बाँधा जा सकता
@msg a view named `{_1}` exists `{_1}` नामक दृश्य मौजूद है
@msg a view named `{name}` exists `{name}` नामक दृश्य मौजूद है
@msg a workspace keeps at most {MAX} toolbars एक वर्कस्पेस सबसे {MAX} टूलबार में रहता है
@@ -6438,7 +6440,7 @@ antiAlias Sharp शार्प
@msg batches cannot nest बैच घोंसला नहीं कर सकते
@msg before must name a panel before: पहले एक पैनल का नाम देना चाहिए
@msg below and above must name a panel floating in another group नीचे और ऊपर एक पैनल दूसरे समूह में चल रहा है नाम होना चाहिए
@msg bind something to a variable first पहले एक चर के लिए कुछ बांधना
@msg bind something to a variable first पहले किसी ऑब्जेक्ट को वेरिएबल से बाँधें
@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed होना चाहिए 0-{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}}
@msg Blend colors: select at least three objects with filled colours मिश्रित रंग: भरे हुए रंगों के साथ कम से कम तीन ऑब्जेक्ट चुनें
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP फ़ाइलें RGB या ग्रेस्केल होती हैं: colorModel को rgb या gray रखें
@@ -6512,20 +6514,20 @@ antiAlias Sharp शार्प
@msg colour groups can't go into colour groups रंग समूह रंग समूहों में नहीं जा सकते हैं
@msg colour, mode and spot apply to solid-colour swatches only रंग, मोड और स्पॉट केवल ठोस रंग वाले स्वॉच पर लागू होते हैं
@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read कॉलम {column}: `{cell}` ग्राफ़ डेटा फ़ाइल का `%` कॉलम है, जिसे पढ़ा नहीं जाता
@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` नाम `{name}`, जो एक {_1} चर है
@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` नाम कोई चर नहीं
@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: चर नाम {MAX_NAME} अक्षरों से लंबा है
@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable कॉलम {column}: `{cell}` में `{name}` नाम का {_1} वेरिएबल है
@msg column {column}: `{cell}` names no variable कॉलम {column}: `{cell}` किसी वेरिएबल का नाम नहीं है
@msg column {column}: the variable name is longer than {MAX_NAME} characters कॉलम {column}: वेरिएबल का नाम {MAX_NAME} कैरेक्टर से लंबा है
@msg column docks a set as a dock column, with no anchor or icon कॉलम एक डॉक कॉलम के रूप में सेट करता है, जिसमें कोई एंकर या आइकन नहीं होता है
@msg column must be true or a number from 1 column को true या 1 या उससे बड़ी संख्या होना चाहिए
@msg column: true floats the whole column and takes no onto, above or below column: true पूरे कॉलम को फ़्लोट करता है और onto, above या below नहीं लेता
@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}`
@msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों में वेरिएबल `{name}` का नाम है
@msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है
@msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1}
@msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है
@msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश
@msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command`
@msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए।
@msg compound {_1} must contain exactly one open path to join यौगिक {_1} में शामिल होने के लिए बिल्कुल एक खुला रास्ता होना चाहिए
@msg compound {_1} must contain exactly one open path to join जोड़ने के लिए कंपाउंड {_1} में ठीक एक खुला पाथ होना चाहिए
@msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं
@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं
@msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है
@@ -6689,11 +6691,11 @@ antiAlias Sharp शार्प
@msg data must be an object of key: value, not {v} data: डेटा कुंजी का एक ऑब्जेक्ट होना चाहिए: मान, नहीं {v}
@msg Data Recovery can't keep copies in {path}: {e} डेटा रिकवरी {path} में प्रतियां नहीं रख सकती: {e}
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) डेटा रिकवरी की प्रतिलिपियाँ रखने के लिए कहीं नहीं है: निर्धारित प्राथमिकता → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder)
@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक नियंत्रण चरित्र होता है, जो एक परिवर्तनीय लाइब्रेरी पकड़ नहीं सकता है
@msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक कंट्रोल कैरेक्टर है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता
@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare डेटा सेट `{name}` `{var}` के लिए एक मान रखता है, जिसे लाइब्रेरी घोषित नहीं करती है
@msg data set `{name}` holds two values for `{var}` डेटा सेट `{name}` में `{var}` के लिए दो मान हैं।
@msg data set `{name}` is declared twice डेटा सेट `{name}` को दो बार घोषित किया गया है
@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` शुरू होता है या व्हाइट स्पेस के साथ समाप्त होता है, जो एक परिवर्तनीय लाइब्रेरी को नहीं पकड़ सकता है
@msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` की शुरुआत या अंत में व्हाइटस्पेस है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता
@msg data takes at most {MAX_DATA} keys at once data: डेटा एक बार में सबसे {MAX_DATA} कुंजी लेता है
@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none प्रोग्राम पढ़ने वाले डेटा को क्षतिग्रस्त या छोटा काट दिया जाता है, या प्रोग्राम एक फ़ाइल में एक स्थिति के लिए पूछता है जिसमें कोई नहीं है।
@msg dataset `{name}` already exists डेटासेट `{name}` पहले से मौजूद है
@@ -6701,7 +6703,7 @@ antiAlias Sharp शार्प
@msg date `{d}`: YYYY-MM-DD दिनांक `{d}`: YYYYY-MM-DD
@msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3})
@msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है
@msg define a variable first पहले एक चर को परिभाषित करना
@msg define a variable first पहले एक वेरिएबल परिभाषित करें
@msg detect must be true or false detect को true या false होना चाहिए
@msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है
@msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है
@@ -7021,9 +7023,9 @@ antiAlias Sharp शार्प
@msg its Windows metafile preview was opened instead, which may simplify the art इसके बजाय विंडोज मेटाफ़ाइल प्रीव्यू ओपन किया गया था, जो कला को सरल बना सकता है
@msg its zlib compression is damaged इसके zlib संपीड़न क्षतिग्रस्त है
@msg its Zstandard compression is damaged इसके Zstandard संपीड़न क्षतिग्रस्त है
@msg join needs open paths join: खुले रास्ते की जरूरत
@msg join two different paths दो अलग-अलग पाथ में शामिल हों
@msg Join: a subpath went missing जॉइन: कोई सबपाथ नहीं मिला
@msg join needs open paths जोड़ने के लिए खुले पाथ चाहिए
@msg join two different paths दो अलग पाथ जोड़ें
@msg Join: a subpath went missing जोड़ते समय एक सबपाथ नहीं मिला
@msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए
@msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps}
@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए
@@ -7050,7 +7052,7 @@ antiAlias Sharp शार्प
@msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण
@msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes लाइन {line}: `{var}` के लिए मूल्य {MAX_GRAPH_CSV} बाइट्स से अधिक है
@msg line {line}: value for `{var}` must be true or false लाइन {line}: `{var}` के लिए मान true या false होना चाहिए
@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` के लिए मान इसके परिवर्तनीय प्रकार से मेल खाता होना चाहिए
@msg line {line}: value for `{var}` must match its variable kind लाइन {line}: `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए
@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf या crlf
@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel लिंक किए गए चित्रों से पता चलता है कि फ़ाइल में सेव किए जाने वाले निम्न-रिज़ॉल्यूशन प्रीव्यू को उनकी फ़ाइलों को मिलने तक: लिंक पैनल में उन्हें फिर से लिंक करें
@msg linked pictures with no preview in the file show a placeholder until their files are found: relink them in the Links panel फ़ाइल में कोई प्रीव्यू के साथ जुड़े चित्र एक प्लेसहोल्डर दिखाते हैं जब तक कि उनकी फ़ाइलें नहीं मिलती: उन्हें लिंक पैनल में फिर से लिंक करें
@@ -7165,7 +7167,7 @@ antiAlias Sharp शार्प
@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2})
@msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक
@msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक
@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है।
@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक वेरिएबल्स: डेटा सेट आयात अधिकतम {MAX_CSV_COLS} वेरिएबल्स पढ़ता है
@msg more than {MAX_CSV_ROWS} data rows {MAX_CSV_ROWS} डेटा पंक्तियों से अधिक
@msg more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS} {MAX_CSV_ROWS} डेटा सेट से अधिक: आयात डेटा सेट अधिकांश {MAX_CSV_ROWS} पर पढ़ा जाता है।
@msg more than {MAX_FILES} images {MAX_FILES} इमेज से अधिक
@@ -7421,10 +7423,10 @@ antiAlias Sharp शार्प
@msg no user library folder here यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं
@msg no user library folder here (save with a path, or without one to get the data) यहाँ कोई यूज़र लाइब्रेरी फ़ोल्डर नहीं है (पाथ के साथ सेव करें, या डेटा पाने के लिए बिना पाथ के)
@msg no user workspace `{from}` कोई उपयोगकर्ता वर्कस्पेस `{from}`
@msg no variable named `{from}` कोई चर नाम `{from}`
@msg no variable named `{name}` कोई चर नाम `{name}`
@msg no variable named `{v}` कोई चर नाम `{v}`
@msg no variable named `{var}` कोई चर नाम `{var}`
@msg no variable named `{from}` `{from}` नाम का कोई वेरिएबल नहीं है
@msg no variable named `{name}` `{name}` नाम का कोई वेरिएबल नहीं है
@msg no variable named `{v}` `{v}` नाम का कोई वेरिएबल नहीं है
@msg no variable named `{var}` `{var}` नाम का कोई वेरिएबल नहीं है
@msg no view named `{name}` `{name}` नाम का कोई दृश्य
@msg node {_1} is not a graph node {_1} एक graph नहीं है
@msg node {_1} is not an image node {_1} एक इमेज नहीं है
@@ -7483,7 +7485,7 @@ antiAlias Sharp शार्प
@msg not JSON: {e} JSON नहीं है: {e}
@msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें
@msg nothing encoded कुछ भी नहीं
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है।
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} से कुछ भी लागू नहीं हुआ: उसमें मान वाले किसी भी वेरिएबल से कोई ऑब्जेक्ट नहीं बाँधा गया है
@msg nothing prints कुछ भी प्रिंट
@msg nothing selected कुछ भी नहीं चुना
@msg nothing selected is in a group कुछ भी नहीं चुना गया है एक समूह में
@@ -7534,7 +7536,7 @@ antiAlias Sharp शार्प
@msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1}
@msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1}
@msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, परिवर्तनीय चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
@msg objects with several fills or strokes, variable-width strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, वेरिएबल चौड़ाई वाले स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है
@msg offset out of range सीमा से बाहर ऑफसेट
@msg Offset Path: {e} ऑफसेट पाथ: {e}
@msg Offset Path: select paths ऑफसेट पाथ: सिलेक्शन पाथ
@@ -7691,7 +7693,7 @@ antiAlias Sharp शार्प
@msg redraw needs id redraw: ई.डी. की आवश्यकता
@msg redraw within must be a finite distance of 0 or more भीतर वापस लेना 0 या उससे अधिक की एक सीमित दूरी होना चाहिए
@msg registration must be 0..8 registration को 0..8 होना चाहिए
@msg Rejoin Path Pieces: too many paths to rejoin at once Rejoin पाथ के टुकड़े: एक बार में फिर से जुड़ने के लिए कई पाथ
@msg Rejoin Path Pieces: too many paths to rejoin at once पाथ के हिस्से फिर से जोड़ें: एक बार में फिर से जोड़ने के लिए बहुत अधिक पाथ हैं
@msg release a layer or a group एक लेयर या एक समूह को छोड़ दें
@msg Release Guides: no guides रिलीज़ गाइड: कोई गाइड नहीं
@msg Release: no layer to put the rectangle in रिलीज: आयत को डालने के लिए कोई लेयर नहीं
@@ -7758,7 +7760,7 @@ antiAlias Sharp शार्प
@msg select a path एक पाथ चुनें
@msg select a repeat दोहराने सिलेक्ट करें
@msg select a Shaper group एक शेपर समूह चुनें
@msg select a stroke with a variable width एक चर चौड़ाई के साथ एक स्ट्रोक सिलेक्ट करें
@msg select a stroke with a variable width वेरिएबल चौड़ाई वाला स्ट्रोक सिलेक्ट करें
@msg select a symbol instance एक सिंबल उदाहरण चुनें
@msg select a text object एक टेक्स्ट ऑब्जेक्ट चुनें
@msg select an envelope एक लिफाफे सिलेक्ट करें
@@ -7803,7 +7805,7 @@ antiAlias Sharp शार्प
@msg select at least two objects कम से कम दो ऑब्जेक्ट चुनें
@msg Select dimensions to update अद्यतन करने के लिए आयाम सिलेक्ट करें
@msg select exactly two objects वास्तव में दो ऑब्जेक्ट चुनें
@msg select exactly two path ends to join वास्तव में दो पाथ में शामिल होने के लिए समाप्त होता है चुनें
@msg select exactly two path ends to join जोड़ने के लिए ठीक दो पाथ एंड पॉइंट्स सिलेक्ट करें
@msg Select horizontal point or area type whose text fits ऐसा क्षैतिज पॉइंट टाइप या एरिया टाइप सिलेक्ट करें जिसका टेक्स्ट फ़िट होता है
@msg select instances of one symbol एक सिंबल के उदाहरण चुनें
@msg select members of a compound shape एक मिश्रित आकार के सदस्यों सिलेक्ट करें
@@ -7820,7 +7822,7 @@ antiAlias Sharp शार्प
@msg select paths पाथ चुनें
@msg select paths or anchor points पाथ या एंकर अंक चुनें
@msg select paths or compound paths पाथ या यौगिक पाथ सिलेक्ट करें
@msg select paths to join शामिल होने के लिए पाथ सिलेक्ट करें
@msg select paths to join जोड़ने के लिए पाथ सिलेक्ट करें
@msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें
@msg select paths, or segments, to divide विभाजित करने के लिए पाथ, या खंड चुनें
@msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें
@@ -7849,7 +7851,7 @@ antiAlias Sharp शार्प
@msg select the objects to attach to the path ऑब्जेक्ट्स का सिलेक्ट करने के लिए संलग्न करने के लिए पाथ
@msg select the objects to bind ऑब्जेक्ट्स को बांधने के लिए चुनें
@msg select the objects to transform in perspective ऑब्जेक्ट्स को पर्सपेक्टिव में बदलने के लिए चुनें
@msg select two open path ends, or two open paths, to average and join दो खुले पाथ सिलेक्ट करें, या दो खुले रास्ते, औसतन और जुड़ना
@msg select two open path ends, or two open paths, to average and join औसत निकालकर जोड़ने के लिए दो खुले पाथ एंड पॉइंट्स या दो खुले पाथ सिलेक्ट करें
@msg select two or more graphic styles to merge दो या अधिक ग्राफिक स्टाइल्स का सिलेक्ट करने के लिए विलय
@msg select two or more objects to distribute वितरित करने के लिए दो या दो से अधिक ऑब्जेक्ट चुनें
@msg select two or more objects to intertwine दो या दो से अधिक ऑब्जेक्ट्स सिलेक्ट करें
@@ -7906,7 +7908,7 @@ antiAlias Sharp शार्प
@msg strokeOptions must be an object of stroke.set options strokeOptions को stroke.set ऑप्शन्स का ऑब्जेक्ट होना चाहिए
@msg strokeWidth must be a positive number of pixels strokeWidth को पिक्सेल की धनात्मक संख्या होना चाहिए
@msg subpath {si} is not an open subpath उपपाथ {si} एक खुला उपपाथ नहीं है
@msg subpaths required when joining the same path एक ही पाथ में शामिल होने पर आवश्यक उप-पाथ
@msg subpaths required when joining the same path एक ही पाथ को जोड़ने के लिए सबपाथ चाहिए
@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG UTF-8, UTF-16 या ISO 8859-1 नहीं है।
@msg SVG parse error: {0} SVG पार्स त्रुटि: {0}
@msg swatchConflict must be "merge" or "add", not {v} swatchConflict को "merge" या "add" होना चाहिए, {v} नहीं
@@ -8061,9 +8063,9 @@ antiAlias Sharp शार्प
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं
@msg the file's preview header points outside the file फ़ाइल के प्रीव्यू शीर्षलेख फ़ाइल के बाहर अंक
@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल के परिवर्तनीय बंधन सभी नहीं पढ़े गए थे (बाइंडिंग्स स्किप: {_1}): वे ऑब्जेक्ट अपने चर के लिए बाध्य नहीं हैं
@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल के परिवर्तनीय लाइब्रेरी को पढ़ा नहीं जा सकता: चर पैनल इसे सूचीबद्ध नहीं करता है
@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की परिवर्तनीय लाइब्रेरी आंशिक रूप से पढ़ी गई थी (प्रवेशियों को छोड़ दिया: {_1}): वेरिएबल्स पैनल बाकी हिस्सों को सूचीबद्ध करता है
@msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल की कुछ वेरिएबल बाइंडिंग नहीं पढ़ी गईं (छोड़ी गई बाइंडिंग: {_1}): वे ऑब्जेक्ट अपने वेरिएबल्स से नहीं बँधे हैं
@msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल की वेरिएबल लाइब्रेरी पढ़ी नहीं जा सकी: वेरिएबल्स पैनल उसे सूचीबद्ध नहीं करता
@msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की वेरिएबल लाइब्रेरी आंशिक रूप से पढ़ी गई (छोड़ी गई प्रविष्टियाँ: {_1}): वेरिएबल्स पैनल शेष प्रविष्टियाँ दिखाता है
@msg the files come to more than 1 GB: add fewer at a time फ़ाइलों को 1 GB से अधिक करने के लिए आते हैं: एक समय में कम जोड़ें
@msg the first character of a line has no character before it to kern: it moves only up and down (dy) एक पंक्ति के पहले चरित्र में बर्न से पहले कोई चरित्र नहीं है: यह केवल ऊपर और नीचे चला जाता है (डी)
@msg the font {_1} is not available फ़ॉन्ट {_1} उपलब्ध नहीं है
@@ -8076,7 +8078,7 @@ antiAlias Sharp शार्प
@msg The graphics were lost and couldn't be restarted. ग्राफिक्स खो गए थे और फिर से शुरू नहीं किया जा सकता था।
@msg The graphics were lost and have been restarted ग्राफिक्स खो गए थे और फिर से शुरू किया गया है
@msg the guide is on a hidden or locked layer गाइड एक छिपे हुए या बंद लेयर पर है
@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस चर `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें
@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them हेडर `{cell}` के लिए `{name}` वापस वेरिएबल `{other}` के रूप में पढ़ेगा: उनमें से एक का नाम बदलें
@msg the header `{header}` for `{name}` would read back as the variable `{other}`: rename one of them `{name}` का हेडर `{header}` दोबारा पढ़ने पर वेरिएबल `{other}` बन जाएगा: किसी एक का नाम बदलें
@msg the image `{path}` isn't followed: {r} इमेज `{path}` का पालन नहीं किया गया है: {r}
@msg the image can't be read as a texture इमेज को बनावट के रूप में नहीं पढ़ा जा सकता है
@@ -8241,14 +8243,14 @@ antiAlias Sharp शार्प
@msg the trailer has no size ट्रेलर में कोई आकार नहीं है
@msg the type object is flattened to nothing फ़्लैटन करने पर टाइप ऑब्जेक्ट में कुछ नहीं रहता
@msg the value of axis `{tag}` must be a number or null धुरी का मूल्य `{tag}` होना चाहिए एक नंबर या null होना चाहिए
@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_DATASETS} डेटा सेट से अधिक घोषित करता है
@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets वेरिएबल लाइब्रेरी में {MAX_LIBRARY_DATASETS} से अधिक डेटा सेट घोषित हैं
@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities वेरिएबल लाइब्रेरी {MAX_LIBRARY_ENTITIES} संस्थाओं से अधिक की घोषणा करती है।
@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_VARIABLES} चर से अधिक घोषित करता है।
@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables वेरिएबल लाइब्रेरी {MAX_LIBRARY_VARIABLES} से अधिक वेरिएबल्स घोषित करती है
@msg the variable library declares no variables वेरिएबल लाइब्रेरी ने कोई वेरिएबल घोषित नहीं किया है
@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes परिवर्तनीय लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट्स से बड़ा है
@msg the variable library is not UTF-8 चर लाइब्रेरी UTF-8 नहीं है
@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी {MAX_LIBRARY_BYTES} बाइट से बड़ी है
@msg the variable library is not UTF-8 वेरिएबल लाइब्रेरी UTF-8 में नहीं है
@msg the variable library is not well-formed XML: {e} वेरिएबल लाइब्रेरी अच्छी तरह से निर्मित XML नहीं है: {e}
@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep परिवर्तनीय लाइब्रेरी घोंसला तत्वों से अधिक {MAX_LIBRARY_NESTING} गहरा
@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep वेरिएबल लाइब्रेरी में एलिमेंट {MAX_LIBRARY_NESTING} स्तर से अधिक गहराई तक नेस्ट किए गए हैं
@msg the variable library's document type declaration holds something other than entities of plain text वेरिएबल लाइब्रेरी के डॉक्यूमेंट प्रकार की घोषणा सादे टेक्स्ट की संस्थाओं के अलावा कुछ अन्य रखती है
@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी की संस्थाएं {MAX_LIBRARY_BYTES} बाइट्स से अधिक विस्तार करती हैं
@msg the window has no dock विंडो में कोई डॉक नहीं है
@@ -8467,14 +8469,14 @@ antiAlias Sharp शार्प
@msg unsupported: {0} unsupported: {0}
@msg use paths or compound paths पाथ या यौगिक पाथ का उपयोग करें
@msg useArtboards must be true or false, not {v} useArtboards को true या false होना चाहिए, {v} नहीं
@msg value for `{var}` must match its variable kind `{var}` के लिए मूल्य अपने परिवर्तनीय प्रकार से मेल खाता होना चाहिए
@msg variable `{_1}` is already defined चर `{_1}` पहले से ही परिभाषित किया गया है
@msg value for `{var}` must match its variable kind `{var}` का मान वेरिएबल के प्रकार से मेल खाना चाहिए
@msg variable `{_1}` is already defined वेरिएबल `{_1}` पहले से परिभाषित है
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` वेरिएबल `{name}` वेरिएबल लाइब्रेरी में सेव किया नहीं जा सकता: नाम अक्षर या `_` से शुरू करें, स्पेस और `-`, `_`, `.` के अलावा अन्य चिह्न न रखें
@msg variable `{name}` has no `trait` चर `{name}` में कोई `trait` नहीं है
@msg variable `{name}` is already defined चर `{name}` को पहले से ही परिभाषित किया गया है
@msg variable `{name}` is declared twice परिवर्तनीय `{name}` को दो बार घोषित किया गया है
@msg variable `{to}` already exists चर `{to}` पहले से मौजूद
@msg variables are locked चर लॉक कर रहे हैं
@msg variable `{name}` has no `trait` वेरिएबल `{name}` में `trait` नहीं है
@msg variable `{name}` is already defined वेरिएबल `{name}` पहले से परिभाषित है
@msg variable `{name}` is declared twice वेरिएबल `{name}` दो बार घोषित है
@msg variable `{to}` already exists वेरिएबल `{to}` पहले से मौजूद है
@msg variables are locked वेरिएबल्स लॉक हैं
@msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds}
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है
@msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म)
@@ -8644,7 +8646,7 @@ file-filter Swatch libraries स्वॉच लाइब्रेरीज़
file-filter Templates टेम्पलेट्स
file-filter VectorCraft Template वेक्टर क्राफ्ट टेम्पलेट
font-script Japanese जापानी
font-technology Variable चर
font-technology Variable वेरिएबल
gradient-kind Radial रेडियल
imageTrace Outline रूपरेखा
lighting Light लाइट
Can't render this file because it is too large.
+2
View File
@@ -820,6 +820,8 @@ font-technology Variable Változó
Convert to destination (preserve numbers) Átalakítás a célprofilra (az értékek megőrzésével)
Convert to Global Átalakítás globálissá
Convert to Grayscale Átalakítás szürkeárnyalatossá
Convert to Indexed Color Átalakítás indexelt színűvé
Convert to Indexed Color… Átalakítás indexelt színűvé…
Convert To Outlines Átalakítás útvonallá
Convert To Point Type Átalakítás pontszöveggé
Convert to RGB Átalakítás RGB-re
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -96,6 +96,8 @@
Blend Vertically Blend vertikal
Convert to CMYK Konversi ke CMYK
Convert to Grayscale Konversi ke skala kelabu
Convert to Indexed Color Konversi ke warna terindeks
Convert to Indexed Color… Konversi ke warna terindeks…
Convert to RGB Konversi ke RGB
Invert Colors Balikkan warna
Overprint Black… Overprint hitam…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
96
97
98
99
100
101
102
103
+2
View File
@@ -629,6 +629,8 @@ font-technology Variable Variabili
Convert to Artboards Converti in tavole da disegno
Convert to CMYK Converti in CMYK
Convert to Grayscale Converti in scala di grigi
Convert to Indexed Color Converti in colore indicizzato
Convert to Indexed Color… Converti in colore indicizzato…
Convert To Outlines Converti in contorni
Convert To Point Type Converti in testo per punti
Convert to RGB Converti in RGB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -182,6 +182,8 @@ digits Malayalam マラヤーラム数字
Blend Vertically 上下にブレンド
Convert to CMYK CMYK に変換
Convert to Grayscale グレースケールに変換
Convert to Indexed Color インデックスカラーに変換
Convert to Indexed Color… インデックスカラーに変換…
Convert to RGB RGB に変換
Invert Colors カラー反転
Overprint Black… オーバープリントブラック…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -107,6 +107,8 @@
Blend Vertically ვერტიკალურად შერევა
Convert to CMYK CMYK-ად გარდაქმნა
Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა
Convert to Indexed Color ინდექსირებულ ფერებად გარდაქმნა
Convert to Indexed Color… ინდექსირებულ ფერებად გარდაქმნა…
Convert to RGB RGB-ად გარდაქმნა
Invert Colors ფერების ინვერსია
Overprint Black… შავის ზედდაბეჭდვა…
Can't render this file because it is too large.
+2
View File
@@ -175,6 +175,8 @@ digits Malayalam 말라얄람어
Blend Vertically 세로 혼합
Convert to CMYK CMYK로 변환
Convert to Grayscale 회색조로 변환
Convert to Indexed Color 인덱스 색상으로 변환
Convert to Indexed Color… 인덱스 색상으로 변환…
Convert to RGB RGB로 변환
Invert Colors 색상 반전
Overprint Black… 검정 겹쳐찍기…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -598,6 +598,8 @@
Blend Vertically Verticaal mengen
Convert to CMYK Converteren naar CMYK
Convert to Grayscale Converteren naar grijswaarden
Convert to Indexed Color Converteren naar geïndexeerde kleuren
Convert to Indexed Color… Converteren naar geïndexeerde kleuren…
Convert to RGB Converteren naar RGB
Invert Colors Kleuren omkeren
Overprint Black… Zwart overprinten…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -111,6 +111,8 @@
Blend Vertically Przejście w pionie
Convert to CMYK Konwertuj na CMYK
Convert to Grayscale Konwertuj na skalę szarości
Convert to Indexed Color Konwertuj na kolory indeksowane
Convert to Indexed Color… Konwertuj na kolory indeksowane…
Convert to RGB Konwertuj na RGB
Invert Colors Odwróć kolory
Overprint Black… Nadruk czerni…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -573,6 +573,8 @@ antiAlias Sharp Nítido
Convert to Artboards Converter em Mesas de Trabalho
Convert to CMYK Converter para CMYK
Convert to Grayscale Converter para Escala de Cinza
Convert to Indexed Color Converter para Cores Indexadas
Convert to Indexed Color… Converter para Cores Indexadas…
Convert to RGB Converter para RGB
Convert to Shape Converter em Forma
Convert to sRGB Converter para sRGB
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -627,6 +627,8 @@ font-technology Variable Вариативные
Convert to Artboards Преобразовать в монтажные области
Convert to CMYK Преобразовать в CMYK
Convert to Grayscale Преобразовать в оттенки серого
Convert to Indexed Color Преобразовать в индексированные цвета
Convert to Indexed Color… Преобразовать в индексированные цвета…
Convert To Outlines Преобразовать в контуры
Convert To Point Type Преобразовать в точечный текст
Convert to RGB Преобразовать в RGB
Can't render this file because it is too large.
+2
View File
@@ -616,6 +616,8 @@ font-technology Variable Variabla
Convert to Artboards Konvertera till ritytor
Convert to CMYK Konvertera till CMYK
Convert to Grayscale Konvertera till gråskala
Convert to Indexed Color Konvertera till indexerade färger
Convert to Indexed Color… Konvertera till indexerade färger…
Convert To Outlines Konvertera till konturbanor
Convert To Point Type Konvertera till punkttext
Convert to RGB Konvertera till RGB
Can't render this file because it is too large.
+30
View File
@@ -63,6 +63,36 @@ fn hindi_rows_use_the_glossary_term_for_what_their_english_names() {
assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n"));
}
/// Every Hindi row that names a variable says वेरिएबल, never चर, परिवर्तनीय or वैरिएबल, and joining
/// paths, points, type or lines is जोड़ें, never शामिल हों, which stays for joining the community.
#[test]
fn hindi_rows_use_one_term_for_a_variable_and_for_joining_paths() {
let hi = Lang::from_code("hi").expect("hi registered");
let (entries, _) = parse_entries(hi.0.source);
let mut wrong = Vec::new();
for (_, src, translation) in &entries {
let english = src.to_lowercase();
let words: Vec<&str> = english.split(|c: char| !c.is_ascii_alphabetic()).collect();
let hindi: Vec<&str> = translation.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation() || "।…".contains(c)).collect();
let names = |list: &[&str]| words.iter().any(|w| list.contains(w));
let other_variable = hindi.iter().any(|w| ["चर", "चरों", "परिवर्तनीय"].contains(w)) || translation.contains("वैरिएबल");
if names(&["variable", "variables"]) && (other_variable || !translation.contains("वेरिएबल")) {
wrong.push(format!("variable in {src:?} -> {translation:?}"));
}
if names(&["join", "joining"]) && !names(&["discord"]) && translation.contains("शामिल") {
wrong.push(format!("join in {src:?} -> {translation:?}"));
}
}
assert!(wrong.is_empty(), "{} Hindi rows use the wrong term:\n{}", wrong.len(), wrong.join("\n"));
assert_eq!(tr(hi, "Variable"), "वेरिएबल");
assert_eq!(tr(hi, "Load Variable Library…"), "वेरिएबल लाइब्रेरी लोड करें…");
assert_eq!(tr(hi, "Join"), "जोड़ें");
assert_eq!(tr(hi, "Close or Join"), "बंद करें या जोड़ें");
assert_eq!(message(hi, "join needs open paths"), "जोड़ने के लिए खुले पाथ चाहिए");
assert_eq!(message(hi, "Join: a subpath went missing"), "जोड़ते समय एक सबपाथ नहीं मिला");
assert_eq!(tr(hi, "Join our Discord"), "हमारे डिसकॉर्ड में शामिल हों");
}
/// Hindi messages and labels keep the condition, quantity and clauses of their English.
#[test]
fn hindi_rows_keep_the_meaning_of_their_english() {
+2
View File
@@ -608,6 +608,8 @@ font-technology Variable Değişken
Convert to Artboards Çalışma Yüzeylerine Dönüştür
Convert to CMYK CMYK'ye dönüştür
Convert to Grayscale Gri Tonlamaya Dönüştür
Convert to Indexed Color Dizinlenmiş Renge Dönüştür
Convert to Indexed Color… Dizinlenmiş Renge Dönüştür…
Convert To Outlines Anahatlara Dönüştür
Convert To Point Type Nokta Türüne Dönüştür
Convert to RGB RGB'ye dönüştür
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -623,6 +623,8 @@ font-technology Variable Варіативні
Convert to Artboards Перетворити на монтажні області
Convert to CMYK Перетворити на CMYK
Convert to Grayscale Перетворити на відтінки сірого
Convert to Indexed Color Перетворити на індексовані кольори
Convert to Indexed Color… Перетворити на індексовані кольори…
Convert To Outlines Перетворити на контури
Convert To Point Type Перетворити на точковий текст
Convert to RGB Перетворити на RGB
Can't render this file because it is too large.
+2
View File
@@ -721,6 +721,8 @@ font-technology Variable Biến thiên
Convert to Artboards Chuyển thành bảng vẽ
Convert to CMYK Chuyển sang CMYK
Convert to Grayscale Chuyển sang thang xám
Convert to Indexed Color Chuyển sang màu chỉ mục
Convert to Indexed Color… Chuyển sang màu chỉ mục…
Convert To Outlines Chuyển thành đường bao
Convert To Point Type Chuyển sang chữ điểm
Convert to RGB Chuyển sang RGB
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -575,6 +575,8 @@ antiAlias Sharp 锐利
Convert to Artboards 转换为画板
Convert to CMYK 转换为 CMYK
Convert to Grayscale 转换为灰度
Convert to Indexed Color 转换为索引颜色
Convert to Indexed Color… 转换为索引颜色…
Convert To Outlines 转换为轮廓
Convert To Point Type 转换为点文字
Convert to RGB 转换为 RGB
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -591,6 +591,8 @@ font-technology Variable 可變
Convert to Artboards 轉換為工作區域
Convert to CMYK 轉換為 CMYK
Convert to Grayscale 轉換為灰階
Convert to Indexed Color 轉換為索引色彩
Convert to Indexed Color… 轉換為索引色彩…
Convert To Outlines 轉換為外框
Convert To Point Type 轉換為點狀文字
Convert to RGB 轉換為 RGB
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -814,6 +814,10 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
crate::dialogs::saturate::open(app);
Ok(Value::Null)
}
"ui.indexedColorDialog" => {
crate::dialogs::indexed_color::open(app);
Ok(Value::Null)
}
"window.swatchLibrary" => crate::panels::swatches::open_library(app, p),
"window.swatchLibrary.other" => crate::panels::swatches::other_library(app, s("path")),
"ui.saveSwatchLibrary" => {
+1 -1
View File
@@ -286,7 +286,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.newBrush" => app.session.active().is_some(),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.hyphenation" | "ui.justification" | "ui.tateChuYokoSettings" => crate::panels::character::text_style(app).is_some(),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.colorBalanceDialog" | "ui.saturateDialog" | "ui.indexedColorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
"ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
+1
View File
@@ -121,6 +121,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("Blend Vertically", "edit.colors.blendVertically"),
c("Convert to CMYK", "edit.colors.toCMYK"),
c("Convert to Grayscale", "edit.colors.toGrayscale"),
c("Convert to Indexed Color…", "ui.indexedColorDialog"),
c("Convert to RGB", "edit.colors.toRGB"),
c("Invert Colors", "edit.colors.invert"),
cp(
+6
View File
@@ -946,6 +946,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step",
),
(
"ui.indexedColorDialog",
"Convert to Indexed Color…",
"",
"{} open Convert to Indexed Color for the selection (dialog `indexedColor`, fields `reduction`, `colors` 2..256, `dither`, `ditherAmount` 0..100, `preview`): previews live, OK runs edit.colors.toIndexed as one undo step",
),
(
"window.swatchLibrary",
"Swatch Library",
+24
View File
@@ -545,6 +545,30 @@ fn ui_inspect_checks_a_folder_preference_as_written() {
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` names the file selected in JavaScript Files by its file name alone when it is
/// outside the read root, and keeps one inside it; the person sees the whole path.
#[test]
fn ui_inspect_hides_a_selected_javascript_file_outside_the_read_root() {
let (mut app, tx, base) = confined("inspect-javascript-files");
let (inside, outside) = (s(base.join("in/menu.js")), s(base.join("outside/menu.js")));
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
for f in [&inside, &outside] {
app.run("svgInteractivity.addFile", json!({ "file": f })).unwrap();
}
let kind = crate::dialogs::javascript_files::KIND;
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": outside})));
let selected = |v: &Value| v["ui"]["dialog"]["fields"]["selected"].clone();
assert_eq!(selected(&crate::control::inspect(&app, &egui::Context::default())), json!(outside), "the person's");
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(selected(&r["result"]), "menu.js", "{r}");
assert!(!r.to_string().contains(&json!(outside).to_string().trim_matches('"').to_string()), "{r}");
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": inside})));
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(selected(&r["result"]), json!(inside), "{r}");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("selected")), Some(inside), "the dialog is not changed");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working
/// folder as the read root, a path outside it with a space before it reads as a name in the working
/// folder, and is still left out, because the app trims it and uses the path outside.
+11 -1
View File
@@ -32,6 +32,14 @@ every clause such as "all" or "what follows it". Type as a text object is टा
in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms
and the point unit in every row.
A variable is वेरिएबल (plural वेरिएबल्स) in every sense: a data-merge variable and the
variable library, a variable font, a variable stroke width and an environment variable. It is
never चर, परिवर्तनीय or वैरिएबल. Joining paths, anchor points, point type or the lines of a
paragraph is जोड़ें (जोड़ने के लिए in a purpose clause); शामिल हों is kept for joining the
community. The stroke corner names (Bevel Join, Miter Join, Round Join) and the Join tool keep
जॉइन. `hindi_rows_use_one_term_for_a_variable_and_for_joining_paths` checks both terms in every
row.
## Terminology
| English | Hindi |
@@ -52,6 +60,8 @@ and the point unit in every row.
| Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन |
| Appearance / Graphic style | अपीयरेंस / ग्राफ़िक स्टाइल |
| Symbol / Envelope | सिंबल / एन्वेलप |
| Variable / Variables / Variable library | वेरिएबल / वेरिएबल्स / वेरिएबल लाइब्रेरी |
| Join (paths, points, type, lines) | जोड़ें |
| Clipping mask / Opacity mask | क्लिपिंग मास्क / ओपेसिटी मास्क |
| Image Trace / Live Paint | इमेज ट्रेस / लाइव पेंट |
| Shape Builder / Pathfinder | शेप बिल्डर / पाथफ़ाइंडर |
@@ -69,7 +79,7 @@ The draft has 7,877 unique rows, including 2,537 `@msg`, 45 `@msgplural` and 38
Its source set combines the sibling catalogs with the current test scanner's UI/undo and error
message dumps, so it includes runtime labels outside the literal scanner. Row count is not a
percentage of the application's entire interface.
On 2026-11-13 the catalog has 8,732 rows, including 3,003 `@msg`, 47 `@msgplural` and 41
On 2026-11-13 the catalog has 8,782 rows, including 3,014 `@msg`, 47 `@msgplural` and 41
`@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in
messages for meaning, dropped clauses, English left in place and the save, swatch, crop,
resolution, path and type terms. It is not a native proofread.
+1 -1
View File
@@ -29,7 +29,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker
| English | `en` | source, 100% | all | Latin | — | full | — |
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
| Hindi | `hi` | 8,732 rows (2026-11-13): 3,003 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
| French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) |
| Portuguese | `pt-br` | 6,106 catalog entries (counted 2026-10-31 on `main` at `d26dd933a`): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) |
+39 -7
View File
@@ -230,6 +230,10 @@ the suite) keep the agent to the folders you give it:
value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given
as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export
for Screens last used) when that folder is outside them. The document itself is not changed.
- `svgInteractivity.info`, `svgInteractivity.addFile` and `svgInteractivity.removeFile` name a linked JavaScript
file outside the read roots by its file name alone in `files`, as `document.json` does, and `removeFile` takes that
name for it (see SVG interactivity). In a frame a request drives, the JavaScript Files dialog lists such a file by
its file name, and `ui_inspect` gives the file selected in the dialog the same way.
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
@@ -3860,11 +3864,32 @@ with one gray channel, so a PDF writes it in gray.
`edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest
end). Without one, or with `null`, it answers `` missing number `intensity` ``; with a value that is not a number, such as
`"40"`, it answers `` `intensity` must be a number, not "40" ``. Either way it changes nothing.
The `edit.colors.*` commands, `edit.colors.overprintBlack` and `addon.color.filter` answer a `fill`, `stroke`,
`includeImages`, `includePatterns`, `convert`, `remove`, `includeCmyBlacks` or `includeSpotBlacks` that is not true or
false with `` `fill` must be true or false ``, naming the key, a `percentage` that is not a number with
`` `percentage` must be a number ``, and a `mode` that is not a string with `` `mode` must be a string ``. They change
nothing and record no undo step. A null counts as a key left out.
The `edit.colors.*` commands other than `edit.colors.toIndexed`, `edit.colors.overprintBlack` and
`addon.color.filter` answer a `fill`, `stroke`, `includeImages`, `includePatterns`, `convert`, `remove`,
`includeCmyBlacks` or `includeSpotBlacks` that is not true or false with `` `fill` must be true or false ``, naming the
key, a `percentage` that is not a number with `` `percentage` must be a number ``, and a `mode` that is not a string
with `` `mode` must be a string ``. They change nothing and record no undo step. A null counts as a key left out.
`edit.colors.toIndexed` (Edit › Edit Colors › Convert to Indexed Color…) stores each embedded image in the selection (or
`ids`), in groups too, as an indexed-color PNG: a palette of at most 256 RGB colors, with each pixel the index of one.
`reduction` chooses the palette: `selective` (the default), `perceptual`, `adaptive`, `web`, `gray` and `blackWhite`
choose it as a PNG-8 export does; `uniform` takes evenly spaced levels of red, green and blue (2 to 6 levels, the most
whose cube fits in `colors` with one entry left for transparency); `document` takes the colors of the document's
swatches (at most 255). `colors` (2 to 256, default 256) is the most palette entries, the transparent one included;
`uniform` needs at least 9, and `blackWhite` and `document` refuse it. `dither` is `diffusion` (the default), `pattern`,
`noise` or `none`, and `ditherAmount` is 0 to 100 (default 100). Reduction and dither ids are read as a PNG-8 export
reads them, ignoring case. Pixels under half opacity become one transparent entry and the others become opaque in their
own color. Linked images, colorized image fills and the images of color glyphs in outlined type keep their pixels. Each
image that changes becomes a new image, in one undo step for all of them. An image that is already an indexed PNG and
would show the same pixels is left as it is, and no undo step is recorded when no image changes. `notChanged` counts the
locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet. An image of more than
134,217,728 pixels (33,554,432 in the web app) is refused with an error, read from the file's header before any pixel is
decoded, and nothing changes. A value out of range or of the wrong type, such as `"colors": "8"`, is refused with an
error and changes nothing.
```json
{"name":"run_command","arguments":{"command":"edit.colors.toIndexed","params":{"reduction":"adaptive","colors":32,"dither":"pattern"}}}
```
The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`.
When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it
@@ -4883,7 +4908,12 @@ files) and `eventNames` (the 19 events the panel lists, from `onfocusin` to `ons
event's script where they have one (an empty `script` removes it); `svgInteractivity.removeEvent {event, ids?}` and
`svgInteractivity.clearEvents {ids?}` remove one event or all of them. Each is one undo step.
`svgInteractivity.addFile {file}` and `svgInteractivity.removeFile {file}` link and unlink a JavaScript file (a path
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `ui.javascriptFilesDialog {}`
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `addFile` takes a file as
already linked only when the same text is linked. With automation read roots in force, `info`, `addFile` and
`removeFile` name a linked file outside them by its file name alone in `files` (a relative path is taken from the
document's folder, and a URL is kept), and `removeFile` also unlinks such a file by that name. When the name fits
more than one linked file (two such files, or such a file and a file linked as that name), `removeFile` returns an
error and unlinks nothing. `ui.javascriptFilesDialog {}`
opens the JavaScript Files dialog, whose Choose… puts a picked file in as a `file:///` URL. SVG export writes each event
as an event attribute on the object's element (`onclick="…"`; with the SVG Tiny 1.2 profile it leaves them out with a
warning) and each file as `<script xlink:href>`. SVG import reads the event attributes the panel lists and the
@@ -5795,7 +5825,9 @@ From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Pers
drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it
chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print
Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools
are VectorCraft's own rules. A tap in Presentation Mode runs nothing.
are VectorCraft's own rules. A tap in Presentation Mode runs nothing. Under VectorCraft's own rule, a tap on the canvas
while a modal dialog is open runs nothing, and fingers already down when it opens make no tap when they lift, during
the dialog or after it closes.
`gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid
draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document.
+6 -2
View File
@@ -834,6 +834,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `<script xlink:href>`, SVG import reads both back as text without running them, and the native format keeps them (`svgInteractivity.*`).
- With automation read roots in force, `svgInteractivity.info`, `addFile` and `removeFile`, the JavaScript Files dialog in a frame a request drives and `ui_inspect`'s selected file name a linked JavaScript file outside the roots by its file name alone, as `document.json` does; `removeFile` unlinks it by that name and refuses a name that fits more than one linked file (`tests/interactivity.rs`).
- Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. A focus click released more than 1 pt from its press keeps the caret, and a tap within 1 pt still selects all (#6659; short-drag and jitter-tap regressions in `tests/numfields.rs`).
- Unit fields and length strings take `%` in arithmetic: `3cm*50%` reads as 1.5 cm, and `50pt+25%` reads as 62.5 pt.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools, the Type tools and the Artboard tool show it).
@@ -866,6 +867,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`).
- Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md).
- Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged.
- Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged.
- Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`).
- Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md).
- The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`).
@@ -1039,8 +1041,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size.
With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer.
With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`).
With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band.
While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
@@ -1195,6 +1197,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default).
On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`).
In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool chosen last (the Pencil tool when none has been chosen yet) and undoes nothing; outside Touch it still undoes, and a three-finger tap still redoes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/touch-workspace-in-illustrator.html); `tool.toggleTouchMode`; canvas test `two_finger_tap_in_touch_switches_selection_and_drawing_modes`).
While a modal dialog is open, from the frame it opens, the canvas takes no new presses, double-clicks, finger taps, long touches, pinches or wheel turns, and a click in Presentation Mode does not step to the next artboard: a two-finger tap undoes nothing, a three-finger tap redoes nothing, a two-finger tap in the Touch workspace switches no mode, and fingers already down when the dialog opens make no tap when they lift, during the dialog or after it closes. This is VectorCraft's own rule (canvas tests `modal_blocks_two_and_three_finger_canvas_taps`, `opening_a_modal_discards_a_pending_canvas_tap`, `modal_blocks_presentation_tap_and_touch_navigation`, `modal_long_touch_does_not_cancel_canvas_press` and `modal_blocks_new_canvas_presses`).
A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`).
The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more.
- **Geometry and effects:**
@@ -1444,6 +1447,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged.
- **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged.
- **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. A value that is not a number, such as `"40"`, is refused with `` `intensity` must be a number, not "40" ``, as in every command that needs a number (`tests::params::a_malformed_required_number_is_not_reported_as_missing`). `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them.
- **Convert to Indexed Color:** Edit › Edit Colors › Convert to Indexed Color… (`edit.colors.toIndexed`) stores the embedded images in the selection as indexed-color PNGs of at most 256 colors. The palette is chosen as for a PNG-8 export, as evenly spaced levels of red, green and blue, or from the document's swatches, with diffusion, pattern or noise dithering; pixels under half opacity become transparent. The dialog previews live, and OK is one undo step. This is VectorCraft's own command and counts toward no score. `tests::editcolors` and `encode::tests_palette` cover it. Scores and estimates unchanged.
- **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged.
- **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`.
- **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates.