Merge main into #1879

This commit is contained in:
bflatastic
2026-11-07 11:19:53 +07:00
26 changed files with 933 additions and 74 deletions
+1 -1
View File
@@ -568,7 +568,7 @@ impl TextPath {
/// The path (empty when `d` doesn't parse).
pub fn path(&self) -> figcraft_geom::BezPath {
figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default()
figcraft_geom::svg_path(&self.d).unwrap_or_default()
}
/// The start offset clamped into 0..1 (NaN: 0).
@@ -300,6 +300,12 @@ pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` itself (a link is not followed).
pub fn link_metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
std::fs::symlink_metadata(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` (links followed).
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
+4 -2
View File
@@ -194,7 +194,7 @@ fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
figcraft_geom::svg_path(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
@@ -213,7 +213,9 @@ pub(crate) struct PathShape {
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let mut path = figcraft_geom::svg_path(d).map_err(|e| {
if e.contains("too large") { bad(cmd, "`path` has coordinates too large to draw") } else { bad(cmd, "`path` is not valid SVG path data") }
})?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
+1 -1
View File
@@ -194,7 +194,7 @@ fn with(p: &Value, keys: &[&str], mut base: Value) -> Value {
}
/// An enabled library of the active document (`library` param), read from its file.
fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
pub(crate) fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
let key = req_string(cmd, p, "library")?;
let link = find_link(s.doc()?, &key)
.cloned()
@@ -231,12 +231,23 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
// Links are not followed: a folder handed to the publisher could otherwise put any file the
// publisher can read (a key, a token) in the bundle the directory then serves to everyone.
let linked = |p: &std::path::Path| crate::file_access::link_metadata(p).is_ok_and(|m| m.is_symlink());
if dir.as_ref().is_some_and(|d| linked(d)) {
return Err(bad(cmd, format!("the {ASSETS} folder is a link: only files inside the plugin's folder are published")));
}
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> = entries
.into_iter()
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
.collect();
let mut names: Vec<(String, std::path::PathBuf)> = Vec::new();
for p in entries {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if linked(&p) {
return Err(bad(cmd, format!("{ASSETS}/{name} is a link: only files inside the plugin's folder are published")));
}
if crate::file_access::link_metadata(&p).is_ok_and(|m| m.is_file()) {
names.push((name, p));
}
}
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
@@ -253,6 +253,31 @@ fn publishing_sends_a_checked_folder() {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn publishing_refuses_a_linked_asset_instead_of_bundling_what_it_points_at() {
let (mut s, d) = session();
let dir = temp("publish-link");
std::fs::create_dir_all(dir.join("build")).unwrap();
std::fs::create_dir_all(dir.join("assets")).unwrap();
std::fs::write(dir.join("build").join("tiny.wasm"), wasm()).unwrap();
std::fs::write(dir.join("figcraft-plugin.json"), json!({"id": ID, "name": "Tiny", "version": "1.0.0", "main": "build/tiny.wasm"}).to_string())
.unwrap();
// A private file outside the plugin folder, reached through a link in its assets.
let secret = temp("publish-link-secret").join("token.txt");
std::fs::write(&secret, b"per-user-secret").unwrap();
std::os::unix::fs::symlink(&secret, dir.join("assets").join("notes.txt")).unwrap();
let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string();
assert!(e.contains("link"), "{e}");
assert!(d.lock().unwrap().plugins.is_empty(), "nothing was sent");
// The whole assets folder as a link is refused the same way.
std::fs::remove_file(dir.join("assets").join("notes.txt")).unwrap();
std::fs::remove_dir(dir.join("assets")).unwrap();
std::os::unix::fs::symlink(secret.parent().unwrap(), dir.join("assets")).unwrap();
let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string();
assert!(e.contains("link"), "{e}");
}
#[test]
fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() {
let (mut s, d) = session();
@@ -5,14 +5,14 @@
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue};
use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind, VariableId, VariableType, VariableValue};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::{rebind_paint_alpha, unbind_slot};
use crate::params::{get, paint_of, parse, req_string, targets};
use crate::{CommandSpec, Result, Session, bad, spec};
use crate::params::{get, paint_of, parse, req_string, string, targets};
use crate::{CommandSpec, EngineError, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -127,10 +127,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"selection.applyColorStyle",
"Apply selection color style",
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
"{targets, style (id or name), library? (an enabled library: id, name or path; the style is then one it publishes, copied in linked and read-only)}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
true,
apply_style
),
spec!(
"selection.createColorStyle",
"Create selection color style",
"{targets, name, description?} → {id}; makes a color style of the paint the targets share and applies it to them as selection.applyColorStyle does, keeping the variables bound to the paint; a name another style has, a target whose list holds other paints, or targets bound to different variables are refused",
true,
create_style
),
spec!(
"selection.bindColorVariable",
"Apply selection color variable",
@@ -205,15 +212,96 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.applyColorStyle";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "style")?;
let name = if get(p, "library").is_some() { import_library_style(s, CMD, p, &name)? } else { name };
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(CMD, "a color style is required"));
}
for target in &targets {
link_style(s, &targets, &style)?;
Ok(json!({"ok": true}))
}
/// Copy the color style `key` (id, else name) that the enabled library `library` publishes into the
/// document, linked and read-only, and return the copy's id.
fn import_library_style(s: &mut Session, cmd: &str, p: &Value, key: &str) -> Result<String> {
let (link, lib) = crate::cmd::library::library(s, cmd, p)?;
let published = |st: &&Style| lib.manifest.style(&st.id).is_some_and(|x| x.published);
let styles = || lib.doc.styles.iter().filter(published);
let style = styles()
.find(|st| st.id == key)
.or_else(|| styles().find(|st| st.name == key))
.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no style `{key}`", link.name)))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(cmd, "a color style is required"));
}
let keys = BTreeSet::from([style.id.clone()]);
crate::library::import_styles(s.doc_mut()?, &lib, &keys, false)
.into_values()
.next()
.ok_or_else(|| EngineError::Other("the style didn't import".into()))
}
fn create_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.createColorStyle";
let targets = checked_targets(s, p, CMD)?;
let doc = s.doc()?;
let paint = targets.first().and_then(|t| t.paint(doc)).ok_or_else(|| bad(CMD, "a selection color target no longer exists"))?;
if !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
return Err(bad(CMD, "a solid or gradient paint is required"));
}
if targets.iter().any(|t| t.paint(doc).as_ref() != Some(&paint)) {
return Err(bad(CMD, "the targets no longer share one paint"));
}
if let Some(why) = create_refusal(doc, &targets) {
return Err(bad(CMD, why));
}
let name = crate::cmd::variables::clean_name(&req_string(CMD, p, "name")?).ok_or_else(|| bad(CMD, "name can't be empty"))?;
if doc.styles.iter().any(|st| st.name == name) {
return Err(bad(CMD, format!("a style named `{name}` already exists")));
}
let kind = StyleKind::Paint { paints: vec![paint] };
let bound = targets.first().map(|t| target_bindings(doc, t, &kind)).unwrap_or_default();
let doc = s.doc_mut()?;
let id = doc.new_style_id();
let style = Style { id: id.clone(), name, description: string(p, "description").unwrap_or_default(), kind, bound };
doc.styles.push(style.clone());
link_style(s, &targets, &style)?;
Ok(json!({"id": id}))
}
/// Why a color style can't be made from `targets` (`selection.createColorStyle`), if it can't: a
/// linked style replaces the whole fill or stroke list, so a target whose list holds other paints
/// would lose them. The new style takes over the variables bound to the paint, so targets bound to
/// different variables are refused too. The Selection colors menu leaves Create style out for such rows.
pub fn create_refusal(doc: &Document, targets: &[PaintTarget]) -> Option<&'static str> {
if targets.iter().any(|t| t.paints(doc).is_none_or(|paints| paints.len() != 1)) {
return Some("a style can only be made from a fill or stroke list that holds this one paint");
}
let paint = targets.first().and_then(|t| t.paint(doc))?;
let kind = StyleKind::Paint { paints: vec![paint] };
let mut bindings = targets.iter().map(|t| target_bindings(doc, t, &kind));
let first = bindings.next()?;
bindings.any(|b| b != first).then_some("the targets are bound to different variables")
}
/// The variable bindings a paint style of `kind` made from `target` takes over: those on the fill or
/// stroke list the target stands for. A text range with fills of its own holds none.
fn target_bindings(doc: &Document, target: &PaintTarget, kind: &StyleKind) -> BTreeMap<String, VariableId> {
if list_field(doc, target).is_none() {
return BTreeMap::new();
}
let field = if target.fills { "fillStyle" } else { "strokeStyle" };
doc.node(target.id).map(|n| crate::style_vars::from_layer(n, field, kind)).unwrap_or_default()
}
/// Apply paint style `style` to every target: a fill or stroke list is linked to it; a text range
/// takes its resolved paints and keeps its typography.
fn link_style(s: &mut Session, targets: &[PaintTarget], style: &Style) -> Result<()> {
for target in targets {
if let Some((start, end)) = target.range {
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
// values just as an explicit range fill does; never replace its font or size.
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, style, target.id).into_owned() else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
@@ -222,7 +310,7 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
}
}
Ok(json!({"ok": true}))
Ok(())
}
/// The binding key (`fills/<i>/color` or `strokes/<i>/color`) of the slot a target stands for, when the
+275 -4
View File
@@ -2,7 +2,7 @@ use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
Session,
selection_colors::{SelectionColor, collect},
selection_colors::{self, SelectionColor, collect},
};
use serde_json::{Value, json};
@@ -156,9 +156,14 @@ fn invalid_or_stale_targets_roll_back_atomically() {
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
for command in
["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"]
{
for command in [
"selection.setPaint",
"selection.selectColor",
"selection.applyColorStyle",
"selection.bindColorVariable",
"selection.detachColor",
"selection.createColorStyle",
] {
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
assert!(s.execute(command, &params).is_err());
}
@@ -421,3 +426,269 @@ fn scopes_filter_the_menu_but_the_command_binds_anyway() {
run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"}));
assert_eq!(fill(&s, a), "00FF00");
}
#[test]
fn a_style_created_from_a_row_links_its_fills_and_strokes_in_one_undo_step() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let id = run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand/Red"}))["id"].clone();
let doc = s.doc().unwrap();
let style = doc.style(id.as_str().unwrap()).unwrap();
assert_eq!(style.name, "Brand/Red");
assert!(matches!(&style.kind, figcraft_doc::StyleKind::Paint { paints } if paints.len() == 1 && paints[0] == red.paint));
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), id.as_str());
assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), id.as_str());
assert_eq!(doc.node(b).unwrap().styles.fill, None);
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.undo_depth().0, before.0 + 1);
run(&mut s, "style.update", json!({"style":id,"value":"#00ff00"}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
s.undo().unwrap();
s.undo().unwrap();
assert!(s.doc().unwrap().style("Brand/Red").is_none());
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
s.redo().unwrap();
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), id.as_str());
}
#[test]
fn a_created_style_keeps_the_rows_gradient_opacity_and_blend_mode() {
use figcraft_color::{BlendMode, Color, Gradient, GradientKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::BLACK));
paint.opacity = 0.5;
paint.blend = BlendMode::Multiply;
for id in [a, b] {
run(&mut s, "fill.set", json!({"id":id,"paint":paint}));
}
let row = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.paint == paint).unwrap();
assert_eq!(row.targets.len(), 2);
let id = run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}))["id"].clone();
let doc = s.doc().unwrap();
assert!(matches!(&doc.style("Shade").unwrap().kind, figcraft_doc::StyleKind::Paint { paints } if paints == &vec![paint.clone()]));
for n in [a, b] {
assert_eq!(doc.node(n).unwrap().fills, vec![paint.clone()]);
assert_eq!(doc.node(n).unwrap().styles.fill.as_deref(), id.as_str());
}
}
#[test]
fn creating_a_style_refuses_taken_or_empty_names_and_stale_rows_and_changes_nothing() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
for name in ["Brand", " Brand ", "", " "] {
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":name})).is_err(), "{name:?}");
}
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets})).is_err());
// One slot of the row changed since it was listed: the row no longer stands for one paint.
run(&mut s, "fill.set", json!({"id":b,"color":"#0000ff"}));
let before_stale = s.undo_depth();
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).is_err());
assert_eq!(s.undo_depth(), before_stale);
assert_eq!(s.undo_depth().0, before.0 + 1);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert_eq!(doc.node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
}
#[test]
fn creating_a_style_refuses_a_row_whose_list_holds_other_paints() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
run(&mut s, "fill.add", json!({"id":a,"paint":"#0000ff"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
let err = s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).unwrap_err();
assert!(err.to_string().contains("holds this one paint"), "{err}");
assert!(selection_colors::create_refusal(s.doc().unwrap(), &red.targets).is_some());
assert_eq!(s.undo_depth(), before);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert_eq!(doc.node(a).unwrap().fills.len(), 2, "the layer keeps its other fill");
assert!(doc.node(b).unwrap().styles.fill.is_none());
// The single-paint layer alone still makes a style.
let b_only: Vec<_> = red.targets.iter().filter(|t| t.id == b).cloned().collect();
assert!(selection_colors::create_refusal(s.doc().unwrap(), &b_only).is_none());
run(&mut s, "selection.createColorStyle", json!({"targets":b_only,"name":"Red"}));
assert!(s.doc().unwrap().node(b).unwrap().styles.fill.is_some());
}
#[test]
fn a_style_created_from_a_variable_bound_row_keeps_following_the_variable() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let v = theme_variable(&mut s, "Brand", "#ff0000");
for id in [a, b] {
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
}
let red = group(&s, &[a, b], "FF0000");
assert_eq!(red.targets.len(), 2);
run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand"}));
let style = s.doc().unwrap().style("Brand").unwrap();
assert_eq!(style.bound.get("fills/0/color").map(String::as_str), v.as_str());
run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "00FF00");
}
#[test]
fn a_style_created_from_a_gradient_keeps_its_stop_variables() {
use figcraft_color::{Color, Gradient, GradientKind, PaintKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
run(&mut s, "fill.set", json!({"id":a,"paint":paint}));
let v = theme_variable(&mut s, "Brand", "#ff0000");
run(&mut s, "variable.bind", json!({"id":a,"field":"fills/0/stops/0/color","variable":v}));
let row = collect(s.doc().unwrap(), &[a]).into_iter().next().unwrap();
run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}));
assert!(s.doc().unwrap().style("Shade").unwrap().bound.contains_key("fills/0/stops/0/color"));
run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"}));
let PaintKind::Gradient(g) = &s.doc().unwrap().node(a).unwrap().fills[0].kind else { panic!("not a gradient") };
assert_eq!(g.stops[0].color.to_hex(), "00FF00");
}
#[test]
fn creating_a_style_refuses_targets_bound_to_different_variables() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let v = theme_variable(&mut s, "Brand", "#ff0000");
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect();
assert_eq!(targets.len(), 2);
assert!(selection_colors::create_refusal(s.doc().unwrap(), &targets).is_some());
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
let err = s.execute("selection.createColorStyle", &json!({"targets":targets,"name":"Red"})).unwrap_err();
assert!(err.to_string().contains("different variables"), "{err}");
assert_eq!(s.undo_depth(), before);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert!(!doc.node(a).unwrap().bound.is_empty());
assert!(doc.node(b).unwrap().styles.fill.is_none());
}
#[test]
fn a_linked_row_switches_to_another_style() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
let accent = run(&mut s, "style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
let linked = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.style.is_some()).unwrap();
run(&mut s, "selection.applyColorStyle", json!({"targets":linked.targets,"style":"Accent"}));
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), accent.as_str());
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
#[allow(clippy::disallowed_methods)]
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A saved library file publishing the color style "Brand" (#0000ff) and the text style "Heading",
/// with the color style "_Draft" left unpublished; the fixture, the file and the library's id.
#[allow(clippy::disallowed_methods)]
fn color_library(tag: &str) -> (Fixture, std::path::PathBuf, Value) {
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-selection-colors-{tag}-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&fixture.0);
std::fs::create_dir(&fixture.0).unwrap();
let path = fixture.0.join("library.figcraft");
let mut publisher = Session::new();
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
run(&mut publisher, "style.create", json!({"type":"paint","name":"_Draft","value":"#00ff00"}));
let text = run(&mut publisher, "text.create", json!({"text":"Title"}))["id"].clone();
run(&mut publisher, "style.create", json!({"type":"text","name":"Heading","from":text}));
let published = run(&mut publisher, "library.publish", json!({}));
assert_eq!(published["styles"], 2);
run(&mut publisher, "file.saveAs", json!({"path":path}));
(fixture, path, published["id"].clone())
}
#[test]
fn a_row_applies_a_published_library_style_as_a_linked_copy_in_one_undo_step() {
let (_fixture, path, library) = color_library("apply");
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
run(&mut s, "library.add", json!({"path":path}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":"Brand","library":library}));
let doc = s.doc().unwrap();
let copy = doc.node(a).unwrap().styles.fill.clone().unwrap();
assert!(figcraft_engine::library::is_remote_style(doc, &copy));
assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), Some(copy.as_str()));
assert_eq!(fill(&s, a), "0000FF");
assert_eq!(fill(&s, b), "00FF00");
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
let doc = s.doc().unwrap();
assert!(doc.style(&copy).is_none());
assert_eq!(doc.node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
// Choosing it again reuses the one copy.
s.redo().unwrap();
let blue = group(&s, &[a, b], "0000FF");
run(&mut s, "selection.applyColorStyle", json!({"targets":blue.targets,"style":"Brand","library":library}));
assert_eq!(s.doc().unwrap().styles.len(), 1);
}
#[test]
fn library_styles_that_are_unpublished_not_colors_or_not_enabled_are_refused() {
let (_fixture, path, library) = color_library("refuse");
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
rectangle(&mut s, "#00ff00");
let red = group(&s, &[a], "FF0000");
assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":library})).is_err());
run(&mut s, "library.add", json!({"path":path}));
let before = s.undo_depth();
for (style, lib) in [("_Draft", library.clone()), ("Heading", library.clone()), ("Missing", library.clone()), ("Brand", json!("Elsewhere"))] {
assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":style,"library":lib})).is_err(), "{style}");
}
assert_eq!(s.undo_depth(), before);
assert!(s.doc().unwrap().styles.is_empty());
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
}
#[test]
fn a_library_that_is_not_a_string_is_refused_rather_than_linking_a_local_style() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
let red = group(&s, &[a], "FF0000");
let before = s.undo_depth();
for lib in [json!({}), json!([]), json!({"id":"Brand"})] {
let err = s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":lib})).unwrap_err();
assert!(err.to_string().contains("`library` must be a string"), "{lib}: {err}");
}
assert_eq!(s.undo_depth(), before);
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
}
+9 -2
View File
@@ -78,8 +78,13 @@ impl<'a> Mapper<'a> {
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?)));
let t = n.get("transform").and_then(matrix).unwrap_or_default();
// An automatic magnet (`AUTO`: the side is picked when the connector is drawn;
// `CENTER`: the end aims at the layer's middle) attaches to the side facing the
// other end, as a board connector drawn here does; the file's `relativePosition`
// is not where such an end sits (#1909).
let automatic = matches!(e.get("magnet").and_then(Kv::str), Some("AUTO" | "CENTER"));
match routing {
Routing::Straight => {
Routing::Straight if !automatic => {
let (rx, ry) = at.unwrap_or((0.5, 0.5));
RouteEnd::Point(t * Point::new(w * rx, h * ry))
}
@@ -453,7 +458,8 @@ fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPat
fits.then_some(ConnectorPath::Runs { across, at })
}
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
/// The side of its layer a connector end leaves from: the `magnet` the file names (`AUTO` and
/// `CENTER`: the side facing the other end, whatever `relativePosition` says; #1909), else the edge
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
match e.get("magnet").and_then(Kv::str) {
@@ -461,6 +467,7 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
Some("RIGHT") => Side::Right,
Some("BOTTOM") => Side::Bottom,
Some("LEFT") => Side::Left,
Some("AUTO" | "CENTER") => Side::Auto,
_ => match at {
Some((x, _)) if x <= 0.0 => Side::Left,
Some((x, _)) if x >= 1.0 => Side::Right,
+48
View File
@@ -666,6 +666,54 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
}
/// #1909: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER`
/// (the end aims at the layer's middle) leaves the side facing the other end, as a board connector
/// drawn here does, whatever `relativePosition` the file kept for it; a named magnet still wins.
#[test]
fn connectors_with_an_automatic_side_leave_the_side_facing_the_target() {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
// The ends keep a stale point on the left of A and the right of B, as the reporter's file did.
let end = |node, magnet: &'static str, x| o(vec![("endpointNodeID", g(node)), ("magnet", e(magnet)), ("relativePosition", xy(x, 0.5))]);
let joined = |id, name: &str, style: &'static str, start: &'static str, finish: &'static str| {
node(
id,
Some((1, "+")),
"CONNECTOR",
name,
vec![
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
("connectorStart", end(60, start, 0.0)),
("connectorEnd", end(61, finish, 1.0)),
("connectorLineStyle", e(style)),
],
)
};
nodes.extend([
box_at(60, "A", 0.0),
box_at(61, "B", 300.0),
joined(62, "Elbow", "ELBOWED", "AUTO", "AUTO"),
joined(63, "Line", "STRAIGHT", "CENTER", "CENTER"),
joined(64, "Curve", "CURVED", "AUTO", "AUTO"),
joined(65, "Named", "ELBOWED", "BOTTOM", "AUTO"),
]);
let (doc, _) = import(&canvas(&schema(), &message), "Sides").unwrap();
let ends = |name: &str| -> ((f64, f64), (f64, f64)) {
let world = doc.world_transform(named(&doc, name).id);
let pts: Vec<(f64, f64)> =
network_of(&doc, name).vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
(pts[0], pts[pts.len() - 1])
};
// From A's right side (facing B) to B's left side (facing A), not from the stale left point; each
// end starts `END_GAP` (4) outside its side.
for name in ["Elbow", "Line", "Curve"] {
assert_eq!(ends(name), ((104.0, 650.0), (296.0, 650.0)), "{name}");
}
// A named magnet keeps its side: the bottom of A; the automatic end still faces A.
assert_eq!(ends("Named"), ((50.0, 704.0), (296.0, 650.0)));
}
/// A connector with a label but no saved snapshot: a frame holding its line and its label,
/// centred on the line's middle, in black when the connector gives no paint.
#[test]
+2 -1
View File
@@ -29,6 +29,7 @@ enum StrokeJoin MITER BEVEL ROUND
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
enum ShapeWithTextType SQUARE ELLIPSE
enum ConnectorLineStyle ELBOWED STRAIGHT CURVED
enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER
enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS
enum NavigationType NAVIGATE OVERLAY
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
@@ -50,7 +51,7 @@ message VariableTarget VariableID id
message VariableSetID GUID guid, AssetRef assetRef
message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition, ConnectorMagnet magnet
message ConnectorControlPoint Vector position, Vector axis
message VariableData AnyValue value, DataType dataType
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
+1 -1
View File
@@ -3978,7 +3978,7 @@ fn vector_path(v: &Value, warnings: &mut Vec<String>) -> Option<VectorNetwork> {
/// Parse supported vector commands and report unrepresentable geometry.
fn vector_bezpath(v: &Value, warnings: &mut Vec<String>) -> Option<BezPath> {
if let Some(d) = v.get("content").and_then(Value::as_str) {
let path = BezPath::from_svg(d).ok().filter(|p| !p.elements().is_empty())?;
let path = figcraft_doc::geom::svg_path(d).ok().filter(|p| !p.elements().is_empty())?;
let origin = Affine::translate((-geometry(v, "x", 0.0), -geometry(v, "y", 0.0)));
return Some(origin * path);
}
+1 -1
View File
@@ -859,7 +859,7 @@ impl<'a> Ctx<'a> {
self.warn("very long vector paths were skipped");
continue;
}
match BezPath::from_svg(d) {
match figcraft_doc::geom::svg_path(d) {
Ok(bp) if sane(&bp) && out.elements().len() + bp.elements().len() <= MAX_PATH_ELEMENTS => {
winding.get_or_insert(if text(p, "windingRule") == Some("EVENODD") { WindingRule::EvenOdd } else { WindingRule::NonZero });
out.extend(bp);
+67 -7
View File
@@ -18,6 +18,62 @@ pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
pub type Radii = [f64; 4];
/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a
/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance,
/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds.
pub fn arc_tolerance(r: f64) -> f64 {
// An infinite radius (finite inputs can overflow on the way, 1e300 squared) gets an infinite
// tolerance: kurbo's count is then NaN, which it floors to its minimum of a few cubics.
if r.is_finite() { (r.abs() * 1e-6).max(0.01) } else { f64::INFINITY }
}
/// Largest coordinate, radius or other number SVG path data may carry ([`svg_path`]).
pub const MAX_SVG_COORD: f64 = 1e9;
/// `d` (SVG path data) as a path. A number past [`MAX_SVG_COORD`] is refused before parsing: the
/// parser turns an arc into cubics by its radius at a fixed tolerance, so a radius of 1e300 would
/// be unbounded work and memory on the way to geometry that is refused anyway.
pub fn svg_path(d: &str) -> Result<BezPath, String> {
if number_past(d, MAX_SVG_COORD) {
return Err(format!("path data holds a number too large (past {MAX_SVG_COORD:e})"));
}
BezPath::from_svg(d).map_err(|e| e.to_string())
}
/// Whether a number in SVG path data `d` lies past ±`max` (or isn't finite). Numbers are split as
/// the SVG grammar does (`1-2` and `.5.5` are two each; `1e-3` is one).
fn number_past(d: &str, max: f64) -> bool {
fn big(cur: &mut String, max: f64) -> bool {
let big = cur.parse::<f64>().is_ok_and(|v| v.is_nan() || v.abs() > max);
cur.clear();
big
}
let (mut cur, mut dot, mut exp) = (String::new(), false, false);
for c in d.chars() {
let continues = match c {
'0'..='9' => true,
'.' => !dot && !exp,
'e' | 'E' => !exp && cur.chars().any(|c| c.is_ascii_digit()),
'+' | '-' => cur.ends_with(['e', 'E']),
_ => false,
};
if continues {
dot |= c == '.';
exp |= matches!(c, 'e' | 'E');
cur.push(c);
continue;
}
if big(&mut cur, max) {
return true;
}
(dot, exp) = (c == '.', false);
if matches!(c, '+' | '-' | '.') {
cur.push(c);
}
}
big(&mut cur, max)
}
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
@@ -126,7 +182,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof:
}
if sweep.abs() > 1e-9 {
let arc = kurbo::Arc::new(center, (r, r), a0, sweep, 0.0);
arc.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
arc.to_cubic_beziers(arc_tolerance(r), |p1, p2, p| path.curve_to(p1, p2, p));
}
let end = at(0.0, p);
if a + b + c > 1e-9 {
@@ -260,7 +316,7 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat
None => path.line_to(k.p3),
}
if k.sweep.abs() > 1e-9 {
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(arc_tolerance(k.r), |p1, p2, q| path.curve_to(p1, p2, q));
}
match k.ease_out {
Some((h1, h2)) => path.curve_to(h1, h2, k.end),
@@ -400,25 +456,29 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let full = full_sweep(sweep);
let tol = arc_tolerance(rx.max(ry));
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
if full && inner <= 0.0 {
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
return whole(rx, ry).to_path(tol);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
path.extend(kurbo::Ellipse::new(c, (rx, ry), 0.0).path_elements(0.01));
let inner_path = kurbo::Ellipse::new(c, (rx * inner, ry * inner), 0.0).to_path(0.01);
path.extend(whole(rx, ry).path_elements(tol));
let inner_path = whole(rx * inner, ry * inner).to_path(tol);
path.extend(inner_path.reverse_subpaths());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
path.move_to(c + Vec2::new(rx * start.cos(), ry * start.sin()));
outer.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
outer.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
if inner > 0.0 {
let end = start + sweep;
path.line_to(c + Vec2::new(rx * inner * end.cos(), ry * inner * end.sin()));
let back = kurbo::Arc::new(c, (rx * inner, ry * inner), end, -sweep, 0.0);
back.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
back.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
} else {
path.line_to(c);
}
+46
View File
@@ -231,3 +231,49 @@ fn reverse_subpaths_preserves_open_and_closed() {
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);
assert!(big.elements().len() < 200, "{}", big.elements().len());
}
#[test]
fn a_huge_ring_is_a_few_cubics() {
let ring = ellipse(1e300, 1e300, 0.0, TAU, 0.5);
assert!(ring.elements().len() < 200, "{}", ring.elements().len());
}
#[test]
fn a_huge_smoothed_star_is_a_few_cubics() {
let star = smoothed_polygon(&star_points(1e300, 1e300, 5, 0.5), 1e299, 0.5);
assert!(star.elements().len() < 400, "{}", star.elements().len());
}
#[test]
fn a_huge_rounded_rect_is_a_few_cubics() {
let rect = rounded_rect(1e300, 1e300, [1e299; 4], 0.0);
assert!(rect.elements().len() < 100, "{}", rect.elements().len());
}
#[test]
fn an_infinite_radius_is_a_few_cubics() {
let mut n = 0;
kurbo::Arc::new((0.0, 0.0), (f64::INFINITY, 1.0), 0.0, 1.0, 0.0).to_cubic_beziers(arc_tolerance(f64::INFINITY), |_, _, _| n += 1);
assert!(n < 16, "{n}");
}
#[test]
fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
let start = std::time::Instant::now();
for d in ["M0 0 A1e300 1e300 0 0 1 1 1", "M0 0A1e300-1e300 0 0 1 1 1", "M 0 0 L 1e10 5", "M0 0L-2e9.5 0", "M0 0 L 1e999 0"] {
assert!(svg_path(d).is_err(), "{d}");
}
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
for d in ["M0 0 L 1e9 5 A 5 5 0 0 1 10 10 Z", "M0 0 L-2.5 0.5 L1e-3 -4e2 Z", "M10,20 l1,-2.5e+1"] {
assert!(svg_path(d).is_ok(), "{d}");
}
assert!(!number_past("M0 0L-2.5.5 1e-3-4e2z", 1e3));
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
assert!(number_past("M.5.5e10", 1e3));
}
+2 -1
View File
@@ -619,7 +619,8 @@ fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) ->
if rx > 0.0 && ry > 0.0 {
let c = Point::new(corner.x + sx * rx, corner.y + sy * ry);
p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin()));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0)
.to_cubic_beziers(figcraft_geom::arc_tolerance(rx.max(ry)), |a, b, e| p.curve_to(a, b, e));
} else {
p.line_to(corner);
}
+16 -4
View File
@@ -26,6 +26,14 @@ fn unit(h: u64, at: u32) -> f32 {
f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0
}
/// Cell positions are rounded to 1 / `STEPS_PER_CELL` of a cell before they're cut into cells. A
/// pixel centre can fall exactly on a cell edge (at a zoom of 1.7, a 1-unit cell is 1.7 pixels
/// across: every tenth edge does), and a view whose origin is a whole number of pixels off (a
/// patch, a band, a pan) reaches it with another rounding error. Unrounded, a row of grains lands
/// in the next cell; rounded, the edge moves 1 / 2²¹ of a cell aside, off the centres layers at
/// round positions and zooms put it on.
const STEPS_PER_CELL: f64 = 1_048_576.0;
/// Maps layer pixels to the grain cells under their centres.
struct Cells {
/// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`.
@@ -44,7 +52,8 @@ impl Cells {
fn at(&self, x: u32, y: u32) -> (i64, i64) {
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let [a, b, c, d, e, f] = self.m;
((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64)
let cell = |v: f64| ((v * STEPS_PER_CELL).round() / STEPS_PER_CELL).floor() as i64;
(cell(a * px + c * py + e), cell(b * px + d * py + f))
}
}
@@ -120,11 +129,14 @@ pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u3
let hv = hash(ix, iy, seed ^ 0x7E57_u32);
// A uniform point in the disc of radius r.
let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r);
let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist);
// The move in whole pixels, apart from `x` and `y`: the same pixel of the page moves
// the same way wherever the layer's pixels start.
let (mx, my) = ((0.5 + ang.cos() * dist).floor() as i64, (0.5 + ang.sin() * dist).floor() as i64);
let (fx, fy) = (i64::from(x) + mx, i64::from(y) + my);
let i = ((y * w + x) * 4) as usize;
let mut px = [0u8; 4];
if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h {
let j = (((fy as u32) * w + fx as u32) * 4) as usize;
if fx >= 0 && fy >= 0 && fx < i64::from(w) && fy < i64::from(h) {
let j = ((fy * i64::from(w) + fx) * 4) as usize;
if let Some(s) = src.get(j..j + 4) {
px.copy_from_slice(s);
}
+49
View File
@@ -96,6 +96,26 @@ fn noise_grains_have_their_size_and_stay_put() {
assert_eq!(px(&z, 23, 23), c);
}
/// Panning by whole pixels at a zoom where grain edges fall on pixel centres (1.7: a 1-unit grain
/// is 1.7 pixels, every tenth edge on a centre) reaches those edges from another origin, with
/// another rounding error: the grains still stay put.
#[test]
fn grains_stay_put_where_their_edges_fall_on_pixel_centres() {
let s = 1.7;
for (why, kind) in [
("noise", noise(Noise { density: 0.5, size: 0.5, color: Color::BLACK, ..Noise::default() })),
("texture", EffectKind::Texture(Texture { size: 0.5, radius: 12.0, clip_to_shape: false, ..Texture::default() })),
] {
let (d, r) = square(kind);
let a = render_at(&d, r, (0.0, 0.0), s);
for (dx, dy) in [(79u32, 62u32), (13, 7), (31, 47), (5, 99)] {
let b = render_at(&d, r, (f64::from(dx) / s, f64::from(dy) / s), s);
let off = (0..200 - dy).flat_map(|y| (0..200 - dx).map(move |x| (x, y))).filter(|&(x, y)| px(&a, x + dx, y + dy) != px(&b, x, y)).count();
assert_eq!(off, 0, "{why}, a pan by ({dx}, {dy}) pixels");
}
}
}
#[test]
fn noise_blend_mode_applies() {
let (d, r) = square(noise(Noise { density: 1.0, color: Color::WHITE, blend: figcraft_color::BlendMode::Multiply, ..Noise::default() }));
@@ -138,3 +158,32 @@ fn hostile_grain_values_do_not_crash() {
let _ = render_at(&d, r, (0.0, 0.0), 1.0);
}
}
/// Texture moves each pixel by whole pixels, the same in any layer: far from the layer's left edge
/// (past 1024 pixels), a move worked out in `f32` together with the pixel's index came out a
/// pixel off for some indices, so a pan changed which pixel a grain took. (The stripes' gradient
/// itself can differ by a level between the two renders; a grain taking the wrong pixel differs by
/// far more.)
#[test]
fn texture_moves_far_into_a_wide_layer_stay_put() {
use figcraft_color::{ColorStop, Gradient, GradientKind};
let mut d = Document::new("wide");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "Stripes", 0.0, 0.0, 2400.0, 60.0).unwrap();
let n = d.get_mut(r).unwrap();
// Black and white stripes, 3 units each: a pixel's neighbours differ.
let stops = (0..=800).map(|i| ColorStop { position: i as f32 / 800.0, color: if i % 2 == 0 { Color::BLACK } else { Color::WHITE } }).collect();
n.fills = vec![Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from: (0.0, 0.5), to: (1.0, 0.5), width: (0.0, 1.0) })];
n.effects =
vec![Effect { kind: EffectKind::Texture(Texture { size: 1.0, radius: 12.0, clip_to_shape: false, ..Texture::default() }), visible: true }];
let view = |x: f64, w: u32| View { origin: (x, -20.0), scale: 1.0, width: w, height: 100 };
let mut a = Pixmap::new(2440, 100).unwrap();
render_nodes_into(&d, &[r], &view(-20.0, 2440), &mut a);
for dx in [300u32, 1021, 1500] {
let mut b = Pixmap::new(900, 100).unwrap();
render_nodes_into(&d, &[r], &view(f64::from(dx) - 20.0, 900), &mut b);
let far = |p: [u8; 4], q: [u8; 4]| p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 8);
let off = (0..100).flat_map(|y| (0..900).map(move |x| (x, y))).filter(|&(x, y)| far(px(&a, x + dx, y), px(&b, x, y))).count();
assert_eq!(off, 0, "a pan by {dx} pixels");
}
}
+30
View File
@@ -569,3 +569,33 @@ fn pans_patch_backdrops_at_the_edges_that_moved_in() {
}
}
}
#[test]
fn grainy_layers_patch_as_they_render() {
fonts();
let texture = |clip_to_shape| EffectKind::Texture(figcraft_doc::Texture { size: 0.5, radius: 12.0, clip_to_shape, ..Default::default() });
let noise = EffectKind::Noise(figcraft_doc::Noise { size: 0.5, density: 0.4, color: Color::BLACK, ..Default::default() });
// At a zoom of 1.7, a grain of 0.5 is 1.7 pixels across: every tenth grain edge falls on a row
// of pixel centres, and a patch renders those rows from another origin than a full render.
for (why, kind) in [("texture", texture(false)), ("texture clipped to its shape", texture(true)), ("noise", noise)] {
let mut d = Document::new("grain");
let p = d.first_page().unwrap();
let e = d.create(p, NodeKind::Rectangle, "E", 60.0, 50.0, 120.0, 90.0).unwrap();
solid(d.get_mut(e).unwrap(), "E03030");
d.get_mut(e).unwrap().effects = vec![Effect { kind, visible: true }];
let m = d.create(p, NodeKind::Rectangle, "M", 220.0, 60.0, 30.0, 30.0).unwrap();
solid(d.get_mut(m).unwrap(), "20A060");
let doc = Arc::new(d);
let v = View { origin: (0.0, 0.0), scale: 1.7, width: 400, height: 300 };
let mut r = Retained::default();
check(&mut r, &doc, p, &v);
// Over the grainy layer, which is redrawn whole.
let over = edit(&doc, |d| d.get_mut(m).unwrap().transform = Affine::translate((100.0, 80.0)));
let (shown, pix, work) = r.render(&over, p, &v, &Options::default()).unwrap();
let pix = pix.clone();
let full = render_view(&over, p, &shown, &Options::default()).unwrap();
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(partial(work), "{why}: {work:?}");
assert!(worst <= 2, "{why}: {work:?} is {worst} levels off a full render");
}
}
+1
View File
@@ -2271,6 +2271,7 @@ status = "draft — needs native review"
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
"selection.bindColorVariable" = "Aplicar variable de color a la selección"
"selection.colors" = "Colores de la selección"
"selection.createColorStyle" = "Crear estilo de color"
"selection.detachColor" = "Separar color de la selección"
"selection.selectColor" = "Seleccionar capas con este color"
"selection.setPaint" = "Cambiar color de la selección"
+224 -31
View File
@@ -4,9 +4,10 @@
use std::sync::Arc;
use egui::{Id, Ui};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, StyleKind, VariableType};
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope};
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeId, PublishedLibrary, Style, StyleKind, VariableType};
use figcraft_engine::library;
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, create_refusal, in_scope};
use serde_json::json;
use crate::a11y::Tip;
@@ -65,13 +66,15 @@ pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, response: Opti
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled
/// during an interaction.
/// edits, style/variable detach/apply/bind, a style from this file or an enabled library, Create
/// style, and “select layers with this color”; disabled during an interaction.
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
let Ok(ds) = app.session.ds() else { return };
let interacting = ds.interaction.is_some();
let colors = colors(ui, ds.uid, doc, ids, interacting);
let uid = ds.uid;
let colors = colors(ui, uid, doc, ids, interacting);
if colors.len() < 2 {
ui.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
return;
}
let t = Tokens::get(ui.ctx());
@@ -92,7 +95,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
.as_ref()
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str())));
let width = ui.available_width() - 60.0;
// A linked row has a Detach button beside its Styles button.
let buttons = if linked_name.is_some() { 92.0 } else { 60.0 };
let width = ui.available_width() - buttons;
let mut paint = group.paint.clone();
if let Some(scrub) = &scrubbing
&& scrub.targets == group.targets
@@ -159,29 +164,15 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
});
app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint}));
}
widgets::tight_end(ui, |ui| {
if linked_name.is_some() {
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
app.run("selection.detachColor", json!({"targets": group.targets}));
}
} else {
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
crate::right::menu_popup(&styles).show(|ui| {
let mut any = false;
for style in &doc.styles {
if !matches!(style.kind, StyleKind::Paint { .. }) {
continue;
}
any = true;
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
app.ui.picker = None;
ui.close();
}
}
if !any {
widgets::label(ui, tr("No color styles"), t.text_secondary);
widgets::tight_end_n(ui, if linked_name.is_some() { 3 } else { 2 }, |ui| {
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
crate::right::menu_popup(&styles).show(|ui| {
style_items(app, ui, doc, group, &t);
if linked_name.is_none() {
ui.separator();
if create_refusal(doc, &group.targets).is_none() && crate::right::menu_item(ui, tr("Create style"), "") {
open_create_form(app, ui, doc, group);
ui.close();
}
let variables: Vec<_> = doc
.variables
@@ -204,7 +195,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
ui.close();
}
}
});
}
});
if linked_name.is_some() {
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
app.run("selection.detachColor", json!({"targets": group.targets}));
}
}
if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() {
app.run("selection.selectColor", json!({"targets": group.targets}));
@@ -217,6 +214,106 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
});
ui.add_space(8.0);
widgets::separator(ui);
create_form(app, ui.ctx(), uid);
}
/// The paint styles a row's Styles menu offers: this file's (not library copies), then each enabled
/// library's published ones under its name (a library this session hasn't read offers none). A choice
/// applies the style to the row's targets.
fn style_items(app: &mut FigApp, ui: &mut Ui, doc: &Document, group: &SelectionColor, t: &Tokens) {
let paint = |s: &&Style| matches!(s.kind, StyleKind::Paint { .. });
let local: Vec<&Style> = library::local_styles(doc).filter(paint).collect();
let libs = crate::variable_ui::libraries(app, doc);
let offered: Vec<(&PublishedLibrary, Vec<&Style>)> = libs
.iter()
.map(|l| (l.as_ref(), l.doc.styles.iter().filter(|s| l.manifest.style(&s.id).is_some_and(|x| x.published)).filter(paint).collect::<Vec<_>>()))
.filter(|(_, list)| !list.is_empty())
.collect();
if local.is_empty() && offered.is_empty() {
widgets::label(ui, tr("No color styles"), t.text_secondary);
}
for style in local {
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
app.ui.picker = None;
ui.close();
}
}
for (lib, list) in offered {
ui.separator();
widgets::label(ui, &lib.manifest.name, t.text_secondary);
for style in list {
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id, "library": lib.manifest.id}));
app.ui.picker = None;
ui.close();
}
}
}
}
/// The Create style window's state: the document and row it was opened for, and the name typed.
#[derive(Clone)]
struct CreateForm {
document: u64,
targets: Vec<PaintTarget>,
paint: Paint,
name: String,
}
fn create_form_id() -> Id {
Id::unique("selection_color_create_style")
}
/// Open the Create style window for `group`, with the next free "Color n" name.
fn open_create_form(app: &mut FigApp, ui: &Ui, doc: &Document, group: &SelectionColor) {
let Ok(ds) = app.session.ds() else { return };
let form = CreateForm {
document: ds.uid,
targets: group.targets.clone(),
paint: group.paint.clone(),
name: crate::style_picker::next_name(doc, "Color"),
};
app.ui.picker = None;
ui.data_mut(|d| d.insert_temp(create_form_id(), form));
}
/// The Create style window: the row's paint, a name, Cancel and Create (Enter creates). Create runs
/// `selection.createColorStyle`; when it fails (a name in use, a row that changed) the window stays
/// open with the name for correction.
fn create_form(app: &mut FigApp, ctx: &egui::Context, document: u64) {
let Some(mut form) = ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())) else { return };
if form.document != document {
ctx.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
return;
}
let (mut open, mut cancel, mut create) = (true, false, false);
crate::dialogs::window(tr("Create style"), ctx).id(create_form_id().with("window")).open(&mut open).show(ctx, |ui| {
ui.set_width(240.0);
ui.horizontal(|ui| {
widgets::swatch(ui, &form.paint, 24.0);
ui.label(widgets::paint_name(&form.paint));
});
ui.add_space(6.0);
let name = ui.add(egui::TextEdit::singleline(&mut form.name).hint_text(tr("Name")).desired_width(220.0));
widgets::select_all_on_focus(ui, &name, &form.name);
if ui.memory(|m| m.focused().is_none()) {
name.request_focus();
}
ui.add_space(10.0);
let ready = !form.name.trim().is_empty();
ui.horizontal(|ui| {
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready;
});
});
let made = create && app.run("selection.createColorStyle", json!({"targets": form.targets, "name": form.name.trim()})).is_some();
if open && !cancel && !made {
ctx.data_mut(|d| d.insert_temp(create_form_id(), form));
} else {
ctx.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
}
}
#[cfg(test)]
@@ -546,4 +643,100 @@ mod tests {
assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF"));
assert!(p.app.ui.last_error.is_none());
}
fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 {
texts.iter().rev().find(|(t, _)| t == s).unwrap_or_else(|| panic!("{s:?} isn't drawn")).1
}
#[test]
fn styles_menu_offers_an_enabled_librarys_published_color_styles() {
let dir = std::env::temp_dir().join(format!("figcraft-selection-colors-panel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let path = dir.join("library.figcraft");
let mut publisher = Session::new();
publisher.execute("style.create", &json!({"type":"paint","name":"Ocean","value":"#00ffff"})).unwrap();
publisher.execute("style.create", &json!({"type":"paint","name":"_Draft","value":"#ffff00"})).unwrap();
publisher.execute("library.publish", &json!({})).unwrap();
publisher.execute("file.saveAs", &json!({"path":path})).unwrap();
let (mut p, a, b) = Panel::new();
p.app.run("library.add", json!({"path":path}));
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let texts = p.frame(vec![]);
let library_name = p.app.session.doc().unwrap().libraries.enabled[0].name.clone();
assert!(texts.iter().any(|(s, _)| *s == library_name), "library styles are listed under the library's name");
assert!(!texts.iter().any(|(s, _)| s == "_Draft"), "unpublished styles aren't offered");
p.click(text_at(&texts, "Ocean"));
let _ = std::fs::remove_dir_all(&dir);
let doc = p.app.session.doc().unwrap();
let copy = doc.node(a).unwrap().styles.fill.clone().unwrap();
assert!(library::is_remote_style(doc, &copy));
assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FFFF");
assert!(doc.node(b).unwrap().styles.fill.is_none());
assert!(p.app.ui.last_error.is_none());
// The copy is listed under its library, not as one of this file's styles.
let blue = text_at(&p.frame(vec![]), "0000FF");
p.click(Pos2::new(228.0, blue.y));
let texts = p.frame(vec![]);
assert_eq!(texts.iter().filter(|(s, _)| s == "Ocean").count(), 2, "the linked row's name and the library's entry");
}
#[test]
fn create_style_names_the_row_links_it_and_stays_open_on_a_taken_name() {
let (mut p, a, b) = Panel::new();
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let create = text_at(&p.frame(vec![]), "Create style");
p.click(create);
for _ in 0..2 {
p.frame(vec![]);
}
let form = p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).expect("Create style opens its window");
assert_eq!(form.name, "Color 1");
// Another style takes the name first: Create fails and the window stays for a new name.
p.app.run("style.create", json!({"type":"paint","name":"Color 1","value":"#00ff00"}));
let button = text_at(&p.frame(vec![]), "Create");
p.click(button);
assert!(p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).is_some());
assert!(p.app.session.doc().unwrap().node(a).unwrap().styles.fill.is_none());
p.ctx.data_mut(|d| d.get_temp_mut_or_insert_with(create_form_id(), || form.clone()).name = "Brand".into());
let before = p.app.session.undo_depth();
let button = text_at(&p.frame(vec![]), "Create");
p.click(button);
assert!(p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).is_none());
let doc = p.app.session.doc().unwrap();
let brand = doc.style("Brand").unwrap().id.clone();
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), Some(brand.as_str()));
assert!(doc.node(b).unwrap().styles.fill.is_none());
assert_eq!(p.app.session.undo_depth().0, before.0 + 1);
assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Brand"));
}
#[test]
fn create_style_is_left_out_for_a_row_whose_list_holds_other_paints() {
let (mut p, a, _) = Panel::new();
p.app.run("fill.add", json!({"id":a,"paint":"#00ff00"}));
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let texts = p.frame(vec![]);
assert!(texts.iter().any(|(s, _)| s == "No color styles"), "the Styles menu opens");
assert!(!texts.iter().any(|(s, _)| s == "Create style"));
}
#[test]
fn a_linked_rows_styles_menu_switches_its_style() {
let (mut p, a, _) = Panel::new();
p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
p.app.run("style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}));
p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
let row = text_at(&p.frame(vec![]), "Brand");
p.click(Pos2::new(196.0, row.y));
let texts = p.frame(vec![]);
assert!(!texts.iter().any(|(s, _)| s == "Create style"), "a linked row has a style already");
p.click(text_at(&texts, "Accent"));
let doc = p.app.session.doc().unwrap();
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), doc.style("Accent").map(|s| s.id.as_str()));
assert!(p.app.ui.last_error.is_none());
}
}
+6 -1
View File
@@ -963,7 +963,12 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d
/// of them ending at the section's 8 pt margin, whatever the row has before it. (The columns around
/// them keep the 8 pt gutter.)
pub fn tight_end<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let group = 24.0 * 2.0 + 4.0;
tight_end_n(ui, 2, add)
}
/// `tight_end` for a group of `count` 24 pt buttons.
pub fn tight_end_n<R>(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R) -> R {
let group = 24.0 * count as f32 + 4.0 * count.saturating_sub(1) as f32;
let avail = ui.available_rect_before_wrap();
let rect = Rect::from_min_max(pos2((avail.max.x - group).max(avail.min.x), avail.min.y), avail.max);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
+1 -1
View File
@@ -68,7 +68,7 @@ pub(crate) fn color_at(stops: &[(f64, Color)], t: f64) -> Color {
/// the seam where the gradient starts over kept as a hard step.
pub(crate) fn turn_stops(stops: &[(f64, Color)], shift: f64) -> Vec<(f64, Color)> {
let d = shift.rem_euclid(1.0);
if !d.is_finite() || d < 1e-9 || d > 1.0 - 1e-9 || stops.is_empty() {
if !d.is_finite() || !(1e-9..=1.0 - 1e-9).contains(&d) || stops.is_empty() {
return stops.to_vec();
}
let seam = 1.0 - d;
+3 -3
View File
@@ -77,7 +77,7 @@ fn every_gradient_samples_where_figcraft_samples_it() {
let stops: Vec<_> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color)).collect();
let (a, b) = (color_at(&stops, want), color_at(&grad.stops, got));
// Away from the sweep's seam a turn's worth either side is the same colour.
let near_seam = kind == GradientKind::Angular && (want < 1e-3 || want > 1.0 - 1e-3);
let near_seam = kind == GradientKind::Angular && !want.is_nan() && !(1e-3..=1.0 - 1e-3).contains(&want);
if !near_seam {
assert!(
(a.r - b.r).abs() < 1e-3 && (a.g - b.g).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3,
@@ -107,7 +107,7 @@ fn the_written_gradient_matches_the_rendered_pixels() {
for x in (2..98).step_by(3) {
let pt = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let ft = figcraft_t(kind, f, pt);
if kind == GradientKind::Angular && (ft < 0.03 || ft > 0.97 || (ft - 0.6).abs() < 0.01) {
if kind == GradientKind::Angular && !ft.is_nan() && (!(0.03..=0.97).contains(&ft) || (ft - 0.6).abs() < 0.01) {
continue;
}
let (t, grad) = vd_t(&out, pt).unwrap();
@@ -149,7 +149,7 @@ fn turned_stops_keep_every_colour_where_it_was() {
for i in 1..200 {
let t = i as f64 / 200.0;
let moved = (t + shift).rem_euclid(1.0);
if moved < 1e-6 || moved > 1.0 - 1e-6 {
if !moved.is_nan() && !(1e-6..=1.0 - 1e-6).contains(&moved) {
continue;
}
let (a, b) = (color_at(&stops, t), color_at(&turned, moved));
File diff suppressed because one or more lines are too long
+2
View File
@@ -78,6 +78,8 @@ interaction feel not yet compared.
| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) |
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) |
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) |
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) |
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes |
## Tools