Merge main into #2244

This commit is contained in:
bflatastic
2026-11-12 11:17:56 +07:00
60 changed files with 1423 additions and 79 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -1453,8 +1453,10 @@ pub struct PrintSettings {
/// is printed this far out, to be trimmed off.
pub bleed: f64,
/// Crop marks outside the bleed, at the trim's corners.
#[serde(alias = "crop_marks")]
pub crop_marks: bool,
/// Bleed marks outside the bleed, at the bleed's corners.
#[serde(alias = "bleed_marks")]
pub bleed_marks: bool,
/// CMYK colour (converted from RGB) instead of RGB.
pub cmyk: bool,
@@ -1523,25 +1525,32 @@ pub struct ExportSetting {
pub print: PrintSettings,
/// Only the layer itself (on), or every layer that shows in its area (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "contents_only")]
pub contents_only: bool,
/// The layer's own box, cutting off what it paints outside it (shadows, outside strokes).
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "include_bounding_box")]
pub include_bounding_box: bool,
/// SVG: text as outlines (on) or as `<text>` elements (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_outline_text")]
pub svg_outline_text: bool,
/// SVG: layer names as `id` attributes.
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "svg_id_attribute")]
pub svg_id_attribute: bool,
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_simplify_stroke")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
#[serde(alias = "vd_compat")]
pub vd_compat: VdCompat,
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
/// sRGB, whatever this says.
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
#[serde(alias = "color_profile")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
+40
View File
@@ -902,6 +902,46 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
}
}
/// The reporter's network (#1951): a corner cut off by a segment drawn before the corner point was
/// deleted, so the region's loop is the open chain of the other four segments and the cutting
/// segment, joining the chain's two ends, is in no loop. Rounded, the fill and the region trace
/// the stroke's outline, cut by an arc at the chain's ends as at every other point.
#[test]
fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() {
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]);
}
/// The same network with the joining segment listed first, and with the loop walked the other way.
#[test]
fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() {
open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]);
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]);
}
fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec<usize>) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)];
let mut net = VectorNetwork::from_points(&corners, false);
net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect();
net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None });
for v in &mut net.vertices {
v.radius = 125.0;
}
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "the stroke is cut at point {i}");
assert!(keeps_off(&fill, *p) > 2.0, "the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-6, "fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
assert!((length(&region) - length(&stroke)).abs() < 1e-6, "region {} and stroke {} trace the same outline", length(&region), length(&stroke));
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
+69 -8
View File
@@ -184,6 +184,13 @@ fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usi
}
}
/// Which segments are in a loop, and which segments join each pair of points: the lookups
/// [`VectorNetwork::closing_segment`] reads, built once per pass by [`VectorNetwork::loop_index`].
pub struct LoopIndex {
in_loop: Vec<bool>,
by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>>,
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -471,16 +478,16 @@ impl VectorNetwork {
// The curves go between the two segments wherever a loop walks through the point, in the
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
let index = self.loop_index();
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
if lp.is_empty() {
continue;
}
let start_v = self.loop_start(lp).unwrap_or(0);
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1);
let mut cur = start_v;
for &x in lp.iter() {
let closing = self.closing_segment(lp, &index);
for &x in lp.iter().chain(closing.iter()) {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
@@ -497,6 +504,60 @@ impl VectorNetwork {
Some(out)
}
/// The point loop `lp` is walked from: the end of its first segment that its second does not
/// share.
fn loop_start(&self, lp: &[usize]) -> Option<usize> {
let first = self.segments.get(*lp.first()?)?;
match lp.get(1).and_then(|&i| self.segments.get(i)) {
Some(second) if first.a == second.a || first.a == second.b => Some(first.b),
_ => Some(first.a),
}
}
/// The lookups [`Self::closing_segment`] needs, built once for a pass over every loop.
pub fn loop_index(&self) -> LoopIndex {
let mut in_loop = vec![false; self.segments.len()];
for &x in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(f) = in_loop.get_mut(x) {
*f = true;
}
}
let mut by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>> = std::collections::BTreeMap::new();
for (i, s) in self.segments.iter().enumerate() {
by_ends.entry((s.a.min(s.b), s.a.max(s.b))).or_default().push(i);
}
LoopIndex { in_loop, by_ends }
}
/// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own
/// (and the only such segment): a corner cut off by a segment drawn before the corner point was
/// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that
/// walks in order as one continuous chain has one. Rounding walks it as the loop's last
/// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting
/// points or segments puts it into the loop (#1951). `index` is this network's [`Self::loop_index`],
/// shared by every loop of one pass.
pub fn closing_segment(&self, lp: &[usize], index: &LoopIndex) -> Option<usize> {
let start_v = self.loop_start(lp)?;
let mut cur = start_v;
for &x in lp {
let s = self.segments.get(x)?;
cur = if s.a == cur {
s.b
} else if s.b == cur {
s.a
} else {
return None;
};
}
if cur == start_v {
return None;
}
let mut joining =
index.by_ends.get(&(cur.min(start_v), cur.max(start_v)))?.iter().copied().filter(|&i| !index.in_loop.get(i).copied().unwrap_or(false));
let first = joining.next()?;
joining.next().is_none().then_some(first)
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
+14 -3
View File
@@ -1,7 +1,8 @@
//! Variables bound inside shared styles. A style's `bound` map names fields of its own value in
//! the keys a layer's bindings use (`fills/<i>/color`, `fills/<i>/stops/<j>/color`,
//! `effects/<i>/<field>`, `layoutGrids/<i>/<field>`, a text style's `fontSize`, `lineHeight`,
//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields
//! `letterSpacing`, `fontWeight`, `fontFamily`, `fontStyle`; a number weight and a style name set the
//! same property, the weight wins). Each layer using the style sees those fields
//! resolved in its own modes; the style itself stores them resolved in the default modes.
use std::borrow::Cow;
@@ -32,7 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
(StyleKind::Text { .. }, None) => match key {
"fontSize" | "lineHeight" | "letterSpacing" => Some(Number),
"fontSize" | "lineHeight" | "letterSpacing" | "fontWeight" => Some(Number),
"fontFamily" | "fontStyle" => Some(String),
_ => None,
},
@@ -70,6 +71,7 @@ pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static st
"letterSpacing" => &["LETTER_SPACING"],
"fontFamily" => &["FONT_FAMILY"],
"fontStyle" => &["FONT_STYLE"],
"fontWeight" => &["FONT_WEIGHT"],
_ => return None,
},
_ => return None,
@@ -111,6 +113,8 @@ pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap<String, V
/// `st`'s value with its bound fields resolved in `modes`.
fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
let mut kind = st.kind.clone();
// `bound` is a BTreeMap, so `fontStyle` applies before `fontWeight` and a weight wins when a file carries both
// (pinned by `font_weight_wins_over_font_style_when_a_file_carries_both`).
for (key, var) in &st.bound {
let Some(val) = vars.resolve(var, modes) else { continue };
match (&mut kind, list_binding(key), &val) {
@@ -134,7 +138,14 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) {
("lineHeight", VariableValue::Number(x)) if x.is_finite() => style.line_height = LineHeight::Pixels(x.clamp(0.0, 100_000.0)),
("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)),
("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.set_font_style(f.clone()),
("fontWeight", VariableValue::Number(x)) if x.is_finite() => {
// The face nearest the number (a static family) and the `wght` axis (a variable font).
let w = x.clamp(1.0, 1000.0);
let italic = style.italic();
style.set_font_style(crate::cmd::props::styled_name(crate::cmd::props::weight_name(w as u16), italic));
style.variations.insert("wght".to_string(), w);
}
_ => {}
}
}
+8 -1
View File
@@ -1,6 +1,6 @@
//! What a file's number variables measure, so exported tokens carry the right unit: a radius,
//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers.
//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target
//! A number's role comes from the layer fields bound to it (and a text style's weight), else its scopes, and an alias target
//! with no role of its own takes the role of the variables that read it (the target of an opacity
//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are.
@@ -60,6 +60,13 @@ impl Roles {
}
}
}
for st in &doc.styles {
for (field, id) in &st.bound {
if field == "fontWeight" {
roles.entry(id.clone()).or_insert(NumberRole::Unitless);
}
}
}
for v in vars.iter().filter(|v| v.kind == VariableType::Number) {
if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) {
roles.entry(v.id.clone()).or_insert(r);
+10 -1
View File
@@ -58,6 +58,7 @@ pub enum ToolId {
Text,
TextOnPath,
Comment,
Annotation,
Eyedropper,
ShapeBuilder,
// Board tools.
@@ -71,7 +72,7 @@ pub enum ToolId {
}
impl ToolId {
pub const ALL: [ToolId; 26] = [
pub const ALL: [ToolId; 27] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -89,6 +90,7 @@ impl ToolId {
ToolId::Text,
ToolId::TextOnPath,
ToolId::Comment,
ToolId::Annotation,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
ToolId::Sticky,
@@ -118,6 +120,7 @@ impl ToolId {
ToolId::Text => "text",
ToolId::TextOnPath => "textOnPath",
ToolId::Comment => "comment",
ToolId::Annotation => "annotation",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
ToolId::Sticky => "sticky",
@@ -129,6 +132,11 @@ impl ToolId {
ToolId::Stamp => "stamp",
}
}
/// Whether Developer Mode keeps this tool: it only inspects, so the tools that edit leave it.
/// The one list behind the engine's pointer routing and command allowlist and the toolbar.
pub fn works_in_dev_mode(self) -> bool {
matches!(self, ToolId::Move | ToolId::Hand | ToolId::Comment | ToolId::Annotation)
}
pub fn from_id(s: &str) -> Option<ToolId> {
ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
}
@@ -151,6 +159,7 @@ impl ToolId {
ToolId::Text => "Text",
ToolId::TextOnPath => "Text on a path",
ToolId::Comment => "Comment",
ToolId::Annotation => "Annotation",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
ToolId::Sticky => "Sticky note",
+15 -1
View File
@@ -110,7 +110,7 @@ impl Session {
/// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects.
fn acting_tool(&self) -> ToolId {
match self.tool.tool {
t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move,
t if self.tool.dev_mode && !t.works_in_dev_mode() => ToolId::Move,
t => t,
}
}
@@ -176,6 +176,7 @@ impl Session {
self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y}));
Ok(())
}
ToolId::Annotation => self.annotation_down(ev),
ToolId::Eyedropper => self.eyedrop(ev),
t if t.on_boards() => self.board_down(ev),
t if t.draws_shape() => self.draw_down(ev),
@@ -206,6 +207,19 @@ impl Session {
Ok(true)
}
/// The Annotation tool's press: the layer under the pointer is selected and the UI is asked
/// for its note (the engine writes nothing; the note is typed in the Inspect tab). A press on
/// empty canvas does nothing.
fn annotation_down(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
// The same reach as the Move tool's pick (`ev.tol() * 2.0`); never the page itself.
let Some(id) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())) else { return Ok(()) };
self.set_selection(vec![id]);
self.request_ui("annotation.new", json!({"id": id}));
Ok(())
}
pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
let tool = self.acting_tool();
let ds = self.ds()?;
+209
View File
@@ -312,6 +312,7 @@ fn remove_vertex_index(net: &mut VectorNetwork, vi: usize) {
/// Remove segments (fixing region loops); points left without any segment go too.
pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
let before = net.segments.len();
let mut touched: Vec<usize> = segs.iter().filter_map(|s| net.segments.get(*s)).flat_map(|s| [s.a, s.b]).collect();
remove_segments_only(net, segs);
touched.sort_unstable();
@@ -322,6 +323,9 @@ pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
}
}
reopen_loops(net);
if net.segments.len() != before {
close_bridged_loops(net);
}
}
fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
@@ -349,6 +353,7 @@ fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
/// Delete vertices, joining their neighbours where a vertex had exactly two segments (`heal`).
pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
let before = net.vertices.len();
let mut idx: Vec<usize> = idx.to_vec();
idx.sort_unstable();
idx.dedup();
@@ -408,6 +413,9 @@ pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
}
net.sanitize();
reopen_loops(net);
if net.vertices.len() != before {
close_bridged_loops(net);
}
}
/// Handles for the curve that heals over deleted point `vi` between `(vertex, handle)` ends `o1`
@@ -470,6 +478,24 @@ fn reopen_loops(net: &mut VectorNetwork) {
}
}
/// After points or segments were removed: an open loop whose two ends a segment in no loop already
/// joins (a bridge drawn between two points before what lay between them was deleted) closes
/// through that segment, at the loop's end so its winding is kept, as `connect` closes it (#1951).
/// Every loop takes what [`VectorNetwork::closing_segment`] gives it against one snapshot of the
/// network, which is what rounding walks: two fills that shared the removed border share the
/// bridge, as they shared the border. Only a removal can open a loop at two points a segment
/// already joins (a cut's ends are new points), so only `delete_vertices` and `remove_segments`
/// run this, and only when they removed something.
fn close_bridged_loops(net: &mut VectorNetwork) {
let index = net.loop_index();
let closings: Vec<Option<usize>> = net.regions.iter().flat_map(|r| r.loops.iter()).map(|lp| net.closing_segment(lp, &index)).collect();
for (lp, closing) in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()).zip(closings) {
if let Some(closing) = closing {
lp.push(closing);
}
}
}
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
pub fn cut_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<(usize, usize)> {
let mid = split_segment(net, seg, t)?;
@@ -1107,6 +1133,24 @@ mod tests {
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
}
/// A curved bridge between two points of a ring, then the point between them deleted (Delete,
/// no healing): the region's loop closes through the bridge, so the fill follows its curve and
/// is as long as the stroke (#1951).
#[test]
fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept");
delete_vertices(&mut n, &[2], false);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions);
let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path()));
assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline");
}
#[test]
fn lasso_polygon() {
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
@@ -1175,4 +1219,169 @@ mod tests {
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
/// An open chain of four segments over five points whose two ends a fifth segment (in no
/// loop) joins, as a bridge drawn before the point between them was deleted.
fn bridged_open_chain() -> VectorNetwork {
let corners = [
Point::new(0.0, 0.0),
Point::new(525.0, 525.0),
Point::new(0.0, 525.0),
Point::new(333.0, 0.0),
Point::new(525.0, 183.0),
Point::new(900.0, 900.0),
];
let mut n = VectorNetwork::from_points(&corners, false);
n.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect();
n.regions.clear();
for v in &mut n.vertices {
v.radius = 125.0;
}
n
}
#[test]
fn regions_over_one_open_chain_all_close_through_the_joining_segment() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let mut n = bridged_open_chain();
for _ in 0..2 {
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
delete_vertices(&mut n, &[5], false);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0], vec![3, 1, 2, 0, 4], "region {i} closes through the segment: {:?}", n.regions);
}
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = length(&n.stroke_path());
let rounded = n.rounded().unwrap();
for i in 0..2 {
let fill = length(&rounded.region_path(i).unwrap());
assert!((fill - stroke).abs() < 1e-6, "region {i}: fill {fill} and stroke {stroke} trace the same outline");
}
}
/// Many independent open chains, each with its own closing segment, all close through it.
#[test]
fn many_open_chains_each_close_through_their_own_segment() {
let one = bridged_open_chain();
let (nv, ns) = (one.vertices.len(), one.segments.len());
let mut n = VectorNetwork::default();
for (i, dx) in (0..50usize).zip((0..50u32).map(|k| f64::from(k) * 1000.0)) {
n.vertices.extend(one.vertices.iter().map(|v| Vertex { x: v.x + dx, ..*v }));
n.segments.extend(one.segments.iter().map(|s| Segment { a: s.a + i * nv, b: s.b + i * nv, ..*s }));
let base = i * ns;
n.regions.push(Region {
loops: vec![vec![base + 3, base + 1, base + 2, base]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
close_bridged_loops(&mut n);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0].last(), Some(&(i * ns + 4)), "chain {i} closes through its own segment");
}
}
#[test]
fn a_segment_joining_two_different_chains_closes_both() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0].len(), 5);
assert_eq!(n.regions[1].loops[0].len(), 5, "the same segment set is the same chain");
let mut m = bridged_open_chain();
m.segments.push(Segment::line(1, 2));
m.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
m.regions.push(Region { loops: vec![vec![3, 5, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let drawn = m.rounded().unwrap();
delete_vertices(&mut m, &[5], false);
assert_eq!(m.regions[0].loops[0], vec![3, 1, 2, 0, 4], "{:?}", m.regions);
assert_eq!(m.regions[1].loops[0], vec![3, 5, 2, 0, 4], "different chains share the segment too: {:?}", m.regions);
let after = m.rounded().unwrap();
for i in 0..2 {
assert_eq!(after.region_path(i).unwrap(), drawn.region_path(i).unwrap(), "region {i} is stored as rounding drew it");
}
}
/// Two fills that shared a border, a bridge drawn between the border's ends, then the border's
/// middle point deleted: both fills close through the bridge (#1951).
#[test]
fn two_fills_that_shared_a_deleted_border_both_close_through_the_bridge() {
use figcraft_geom::Shape;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
n.vertices.push(Vertex::new(50.0, 50.0));
n.segments.push(Segment::line(1, 6));
n.segments.push(Segment::line(6, 4));
n.regions.clear();
for lp in [vec![0, 6, 7, 4, 5], vec![1, 2, 3, 7, 6]] {
n.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
connect(&mut n, 1, 4, (30.0, 30.0), (30.0, -30.0)).unwrap();
delete_vertices(&mut n, &[6], false);
assert_eq!(n.regions[0].loops[0], vec![4, 5, 0, 6]);
assert_eq!(n.regions[1].loops[0], vec![1, 2, 3, 6], "the second fill closes through the bridge too");
let area = |i: usize| n.region_path(i).unwrap().area().abs();
assert!((area(0) + area(1) - 10_000.0).abs() < 1e-6, "the fills meet along the bridge");
}
#[test]
fn removing_nothing_leaves_an_open_loop_alone() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let before = n.clone();
remove_segments(&mut n, &[]);
assert_eq!(n, before);
remove_segments(&mut n, &[99]);
assert_eq!(n, before);
delete_vertices(&mut n, &[], false);
assert_eq!(n, before);
delete_vertices(&mut n, &[99], true);
assert_eq!(n, before);
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0], vec![3, 1, 2, 0, 4]);
}
/// The same bridge as the curved-bridge test, reached by removing segments.
#[test]
fn removing_segments_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
remove_segments(&mut n, &[1, 2]);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5));
assert_eq!(n.regions[0].loops[0], vec![1, 2, 3, 0, 4]);
}
#[test]
fn many_open_loops_with_no_closing_segment_round_as_before() {
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false);
n.segments.clear();
n.vertices.clear();
for k in 0..40 {
let x = f64::from(k) * 100.0;
let base = n.vertices.len();
for p in [(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 50.0), (x, 50.0)] {
n.vertices.push(Vertex { radius: 8.0, ..Vertex::new(p.0, p.1) });
}
let first = n.segments.len();
// Three sides of a square, an open loop with nothing to close it.
for (a, b) in [(0, 1), (1, 2), (2, 3)] {
n.segments.push(Segment::line(base + a, base + b));
}
n.regions.push(Region {
loops: vec![vec![first, first + 1, first + 2]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
let r = n.rounded().unwrap();
assert_eq!(r.regions.len(), 40);
// Each loop gained only its two inner corners' arcs (two corners, one arc each).
assert!(r.regions.iter().all(|reg| reg.loops[0].len() == 5), "{:?}", r.regions[0]);
}
}
@@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is
/// still there (both have two segments, so the region keeps its ring), then the corner deleted with
/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke.
#[test]
fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let n = net(&s, id);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions);
let corner = vertex_at(&n, 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "bridged, then the corner deleted");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
+1
View File
@@ -291,6 +291,7 @@ pub static MENU: &[MenuItem] = &[
m!("Tools", "Pencil", "tool.pencil"),
m!("Tools", "Text", "tool.text"),
m!("Tools", "Comment", "tool.comment"),
m!("Tools", "Annotation", "tool.annotation"),
m!("Tools", "Shape builder", "tool.shapeBuilder"),
m!("Tools", "Actions", "app.actions"),
];
+6 -3
View File
@@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
use export::add_member;
pub(super) use export::export_setting;
pub use export::{
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers,
fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name,
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
@@ -409,7 +409,10 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
// Layers exported on their own still read the page beneath them for a background blur
// or glass (#2213); a "contents only" off export already draws the whole page.
let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() };
let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
+6
View File
@@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own))
}
/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an
/// export of `ids` reads from (empty when `ids` are on no page).
pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default()
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
+6 -1
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing|fontWeight` (numbers; line height and letter spacing in px, as the reference tool applies them; a weight picks the family's face nearest it and the `wght` axis of a variable font) and `fontFamily|fontStyle` (strings; a weight bound as a number replaces a bound style name and the reverse). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -520,6 +520,11 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
match key.as_str() {
"fontWeight" => style.bound.remove("fontStyle"),
"fontStyle" => style.bound.remove("fontWeight"),
_ => None,
};
style.bound.insert(key, id);
}
None => {
+27 -1
View File
@@ -235,6 +235,26 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
Ok(json!({"tool": t.id()}))
}
/// Shift+T and choosing the Annotation tool: with a layer selected (or named by `id`/`ids`) the UI
/// is asked for its note at once; in the tool, a press on a layer selects it and asks (the engine
/// never writes the note). A named target is checked first: one that isn't a layer in the document
/// is an error and the tool stays as it was.
fn annotation(s: &mut Session, p: &Value) -> Result<Value> {
let named = p.get("id").is_some_and(|v| !v.is_null()) || p.get("ids").is_some_and(|v| !v.is_null());
let first = crate::params::targets(s, p).into_iter().find(|i| s.doc().is_ok_and(|d| d.node(*i).is_some_and(|n| !n.is_page())));
if named && first.is_none() {
return Err(bad("tool.annotation", "`id` must name a layer in this document (not a page)".to_string()));
}
let r = tool(s, ToolId::Annotation)?;
if let Some(id) = first {
if named {
crate::cmd::edit::select_and_page(s, vec![id])?;
}
s.request_ui("annotation.new", json!({"id": id}));
}
Ok(r)
}
/// The View menu's commands and their parameters.
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -528,7 +548,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.set",
"Choose tool",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
@@ -536,6 +556,9 @@ pub fn specs() -> Vec<CommandSpec> {
if t.on_boards() {
crate::board::require(s, &format!("tool.{}", t.id()))?;
}
if t == ToolId::Annotation {
return annotation(s, &json!({}));
}
tool(s, t)
}
),
@@ -638,6 +661,9 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, ToolId::TextOnPath)
}),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
// Shift+T: with a layer selected (or an `id`) the UI is asked for its note at once; in the
// tool, a press on a layer selects it and asks (the engine never writes the note).
spec!("tool.annotation", "Annotation", "{id?|ids?}", false, annotation),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
+1
View File
@@ -107,6 +107,7 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Annotation, NONE, ON_SQUARE, Crosshair, "the annotation tool marks the layer it is pressed on"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
];
for (tool, m, at, want, why) in rows {
+1 -1
View File
@@ -230,7 +230,7 @@ pub fn dev_mode_allows(id: &str) -> bool {
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 1] = ["data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
Some(t) => crate::tools::ToolId::from_id(t).is_some_and(crate::tools::ToolId::works_in_dev_mode),
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
@@ -99,6 +99,102 @@ fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
}
/// The `annotation.new` requests queued so far, as the layer ids they name.
fn note_requests(s: &Session) -> Vec<NodeId> {
s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect()
}
fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
}
#[test]
fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(s.tool.tool, ToolId::Annotation);
assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet");
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
// A press on empty canvas does nothing.
click(&mut s, 1000.0, 1000.0);
assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})");
// A press on the rectangle selects it and asks for its note.
click(&mut s, 40.0, 40.0);
assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}");
assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}");
assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape");
// The tool writes nothing.
assert_eq!(*s.doc().unwrap(), doc_before);
assert_eq!(s.undo_labels(1000).len(), undo_before);
}
// The keyboard flow: with a layer selected, choosing the tool asks at once.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(note_requests(&s), vec![r]);
// And with an `id`.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "tool.annotation", json!({"id": r}));
assert_eq!(note_requests(&s), vec![r]);
assert_eq!(s.selection(), vec![r]);
}
/// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it
/// was; several targets pick the first layer among them and select it.
#[test]
fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
let page = s.page().unwrap();
assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer");
assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer");
assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named");
assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change");
assert!(note_requests(&s).is_empty());
// The page first, then the rectangle: the rectangle is the target, and selected.
ex(&mut s, "tool.annotation", json!({"ids": [page, r]}));
assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r]));
}
/// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T.
#[test]
fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.set", json!({"tool": "annotation"}));
assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r]));
}
/// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it.
#[test]
fn dragging_with_the_annotation_tool_moves_nothing() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}");
assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}");
}
}
#[test]
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
for id in [
@@ -121,6 +217,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.move",
"tool.hand",
"tool.comment",
"tool.annotation",
"inspect.code",
] {
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
+161
View File
@@ -385,6 +385,167 @@ fn copy_as_png_svg_and_code() {
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
}
/// A red square with a fill-less pane over its right part, holding `effect` (#2213).
fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export");
let page = d.first_page().unwrap();
let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }];
(d, under, pane)
}
/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there.
fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) {
let setting = figcraft_doc::ExportSetting::default();
let page = d.first_page().unwrap();
let area = crate::cmd::file::export_area(d, ids, &setting).unwrap();
let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap();
let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap();
let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap();
(pixels(&bytes), canvas)
}
/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas
/// shows for that area: the blur reads what lies under it, so the export must draw the layers
/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing.
#[test]
fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() {
let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
// The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane).
assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads");
assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds");
}
/// #2213, as reported and as measured in Figma (2026-11-10): the pane inside a frame, exported on
/// its own, keeps the frame's fill and the blurred layer beneath it, opaque.
#[test]
fn a_blurred_pane_inside_a_frame_keeps_the_frame_and_the_layer_beneath() {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export-frame");
let page = d.first_page().unwrap();
let art = d.create(page, NodeKind::Frame, "Art", 0.0, 0.0, 300.0, 200.0).unwrap();
d.get_mut(art).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FFFFFF").unwrap())];
let under = d.create(art, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(art, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }];
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "the blurred square under the pane");
assert_eq!(exported.get_pixel(100, 40).0[3], 255, "the frame's white behind the pane, opaque");
}
/// #2213: the same for Glass.
#[test]
fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() {
let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default()));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]);
assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40));
}
/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop
/// is (the blurred red), and the layers over the pane stay out.
#[test]
fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let page = d.first_page().unwrap();
let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())];
let (exported, _) = export_vs_canvas(&d, &[pane]);
let left = exported.get_pixel(10, 40).0;
assert_eq!(left[3], 255, "red under the pane: opaque");
assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}");
// The far right of the pane has no square under it: nothing to read, nothing drawn.
assert_eq!(exported.get_pixel(110, 40).0[3], 0);
}
/// Guard: a layer without backdrop effects over another still exports alone ("contents only").
#[test]
fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0)));
d.get_mut(pane).unwrap().effects.clear();
let (exported, _) = export_vs_canvas(&d, &[pane]);
assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear");
}
#[test]
fn node_set_takes_back_the_export_settings_node_get_returns() {
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, PrintSettings, VdCompat};
// Every multi-word field (and `quality`) away from its default.
let expected = ExportSetting {
format: ExportFormat::Jpg,
suffix: "@3x".into(),
print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true },
contents_only: false,
include_bounding_box: true,
svg_outline_text: false,
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 55,
..Default::default()
};
let default = ExportSetting::default();
for (field, differs) in [
("print", expected.print != default.print),
("contents_only", expected.contents_only != default.contents_only),
("include_bounding_box", expected.include_bounding_box != default.include_bounding_box),
("svg_outline_text", expected.svg_outline_text != default.svg_outline_text),
("svg_id_attribute", expected.svg_id_attribute != default.svg_id_attribute),
("svg_simplify_stroke", expected.svg_simplify_stroke != default.svg_simplify_stroke),
("vd_compat", expected.vd_compat != default.vd_compat),
("color_profile", expected.color_profile != default.color_profile),
("quality", expected.quality != default.quality),
] {
assert!(differs, "{field} must be non-default in the expected setting");
}
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None);
let stored = |s: &Session| s.doc().unwrap().node(a).unwrap().exports.clone();
ex(
&mut s,
"export.add",
json!({"ids": [a], "setting": {
"format": "jpg", "suffix": "@3x", "bleed": 9, "cropMarks": true, "bleedMarks": true, "cmyk": true,
"contentsOnly": false, "includeBoundingBox": true, "svgOutlineText": false, "svgIdAttribute": true,
"svgSimplifyStroke": false, "vdCompat": "exact", "colorProfile": "displayP3", "quality": 55,
}}),
);
assert_eq!(stored(&s), vec![expected.clone()], "export.add stores every field");
// What node.get returns goes back through node.set unchanged.
let got = ex(&mut s, "node.get", json!({"id": a}))["exports"].clone();
set(&mut s, &[a], json!({"exports": []}));
assert!(stored(&s).is_empty());
set(&mut s, &[a], json!({"exports": got}));
assert_eq!(stored(&s), vec![expected.clone()], "node.set stores what node.get returned, unchanged");
// The snake_case spelling is taken too.
set(&mut s, &[a], json!({"exports": []}));
set(
&mut s,
&[a],
json!({"exports": [{
"format": "jpg", "suffix": "@3x",
"print": {"bleed": 9, "crop_marks": true, "bleed_marks": true, "cmyk": true},
"contents_only": false, "include_bounding_box": true, "svg_outline_text": false, "svg_id_attribute": true,
"svg_simplify_stroke": false, "vd_compat": "exact", "color_profile": "displayP3", "quality": 55,
}]}),
);
assert_eq!(stored(&s), vec![expected], "node.set takes the snake_case keys");
}
#[test]
fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
let mut s = Session::new();
+1
View File
@@ -192,6 +192,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.pencil", &["Shift+P"]),
k(Tools, "tool.text", &["T"]),
k(Tools, "tool.comment", &["C"]),
k(Tools, "tool.annotation", &["Shift+T"]),
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
// Board tools (on boards only), on keys nothing else uses.
k(Tools, "tool.sticky", &["Q"]),
+2
View File
@@ -206,6 +206,8 @@ fn tool_keys_come_from_the_keymap() {
assert_eq!(ToolId::Eyedropper.shortcut(), "I");
assert_eq!(ToolId::Polygon.shortcut(), "");
assert_eq!(crate::catalog::shortcut("tool.text"), Some("T"));
assert_eq!(ToolId::Annotation.shortcut(), "Shift+T");
assert_eq!(crate::catalog::shortcut("tool.annotation"), Some("Shift+T"));
}
#[test]
+2 -1
View File
@@ -110,6 +110,7 @@ static ROWS: &[Row] = &[
doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""),
doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""),
doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""),
doc(Tools, "Annotation tool", &["Shift+T"], &[], Bound("tool.annotation"), "Accessibility at Figma, Add an annotation; Developer Mode"),
doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"),
doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"),
"view.pan; nudges move the selection when there is one"),
@@ -416,7 +417,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (136, 135, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (137, 136, 1, 0, 2, 57));
}
#[test]
+117
View File
@@ -160,6 +160,7 @@ fn what_a_style_cannot_bind_is_refused() {
("fills/3/color", "bg", "no field"),
("effects/0/radius", "blur", "no field"),
("fontSize", "size", "no field"),
("fontWeight", "bg", "no field"),
("opacity", "size", "no field"),
("fill", "nope", "no such variable"),
] {
@@ -209,6 +210,7 @@ fn style_fields_offer_their_properties_scopes() {
(&body, "letterSpacing", Some(&["LETTER_SPACING"][..])),
(&body, "fontFamily", Some(&["FONT_FAMILY"][..])),
(&body, "fontStyle", Some(&["FONT_STYLE"][..])),
(&body, "fontWeight", Some(&["FONT_WEIGHT"][..])),
// Grid fields take any number variable, as on layers; fields a style lacks take none.
(&grid, "layoutGrids/0/size", None),
(&sid, "fills/3/color", None),
@@ -244,3 +246,118 @@ fn rest_and_fig_carry_style_bindings() {
assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}");
}
}
/// A text style "Body" (made from a layer, or from `value`) used by a layer in the Light frame
/// and one in the Dark one; returns (light layer, dark layer, style id).
fn body_style(s: &mut Session, value: Option<Value>) -> (NodeId, NodeId, String) {
let (_, dark, a, _, _) = two_modes(s);
let light = node(s, a).parent.unwrap();
let text = |s: &mut Session, f: NodeId| id(run(s, "text.create", json!({"text": "Hello", "x": 0, "y": 60, "parent": f})));
let (t1, t2) = (text(s, light), text(s, dark));
let from = match &value {
Some(v) => json!({"type": "text", "name": "Body", "value": v}),
None => json!({"type": "text", "name": "Body", "from": t1}),
};
let body = run(s, "style.create", from)["id"].as_str().unwrap().to_string();
run(s, "style.apply", json!({"ids": [t1, t2], "style": body}));
(t1, t2, body)
}
fn text_of(s: &Session, n: NodeId) -> figcraft_doc::TextStyle {
node(s, n).kind.text().unwrap().style.clone()
}
#[test]
fn a_number_weight_picks_the_face_and_the_wght_axis() {
let mut s = Session::new();
let (t1, t2, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session, n: NodeId| {
let t = text_of(s, n);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s, t1), ("Bold".to_string(), Some(700.0)));
assert_eq!(face(&s, t2), ("Regular".to_string(), Some(400.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Dark", "value": 650}));
assert_eq!(face(&s, t2), ("Bold".to_string(), Some(650.0)));
let e = s.execute("style.bind", &json!({"style": body, "field": "fontWeight", "variable": "bg"})).unwrap_err().to_string();
assert!(e.contains("takes a number variable"), "{e}");
// A style name bound afterwards replaces the number binding.
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"}));
let bound = s.doc().unwrap().style(&body).unwrap().bound.clone();
assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}");
// The name replaces the number's weight: no stale `wght` axis keeps drawing 700.
assert_eq!(face(&s, t1), ("Light".to_string(), None));
assert_eq!(text_of(&s, t1).weight(), 300);
}
#[test]
fn junk_weight_values_clamp_to_the_lightest_and_heaviest_face() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": -50, "Dark": 5000}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session| {
let t = text_of(s, t1);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s), ("Thin".to_string(), Some(1.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Light", "value": 5000}));
assert_eq!(face(&s), ("Black".to_string(), Some(1000.0)));
}
#[test]
fn font_weight_wins_over_font_style_when_a_file_carries_both() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
let doc = s.doc_mut().unwrap();
let (w, f) = (doc.variables.get("weight").unwrap().id.clone(), doc.variables.get("face").unwrap().id.clone());
let st = doc.styles.iter_mut().find(|x| x.id == body).unwrap();
st.bound.insert("fontStyle".into(), f);
st.bound.insert("fontWeight".into(), w);
crate::style_vars::after_load(doc);
assert_eq!(text_of(&s, t1).font_style, "Bold");
}
#[test]
fn a_number_bound_to_a_percent_line_height_applies_as_pixels() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, Some(json!({"fontSize": 24, "lineHeight": "150%"})));
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "lh", "type": "number", "values": {"Light": 150, "Dark": 36}}));
run(&mut s, "style.bind", json!({"style": body, "field": "lineHeight", "variable": "lh"}));
let lh = |s: &Session| match &s.doc().unwrap().style(&body).unwrap().kind {
StyleKind::Text { style, .. } => style.line_height,
k => panic!("{k:?}"),
};
assert_eq!(lh(&s), figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(text_of(&s, t1).line_height, figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(node(&s, t1).height, 150.0);
run(&mut s, "variable.set", json!({"variable": "lh", "mode": "Light", "value": 36}));
assert_eq!((lh(&s), node(&s, t1).height), (figcraft_doc::LineHeight::Pixels(36.0), 36.0));
}
#[test]
fn a_text_style_binding_round_trips_through_fig() {
let mut s = Session::new();
let (_, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
for (field, var) in [("fontSize", "size"), ("lineHeight", "size"), ("fontWeight", "weight")] {
run(&mut s, "style.bind", json!({"style": body, "field": field, "variable": var}));
}
let doc = s.doc().unwrap().clone();
let (bytes, warnings) = figcraft_format::fig::export(&doc, None).unwrap();
let (mut back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
crate::style_vars::after_load(&mut back);
let st = back.styles.iter().find(|x| x.name == "Body").unwrap();
assert!(st.bound.contains_key("fontSize") && st.bound.contains_key("lineHeight"), "{st:?}");
// The format has no weight field yet: the settled name comes back, not the binding (the file has no `wght` axis field yet).
assert!(warnings.iter().any(|w| w.contains("no field for")), "{warnings:?}");
let StyleKind::Text { style, .. } = &st.kind else { panic!("{st:?}") };
assert_eq!(style.font_style, "Bold");
}
+13
View File
@@ -562,6 +562,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
}
#[test]
fn css_export_keeps_a_style_weight_bare() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "weight", "type": "number", "value": 700}));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0})));
let st = ex(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t}))["id"].as_str().unwrap_or_default().to_string();
ex(&mut s, "style.bind", json!({"style": st, "field": "fontWeight", "variable": "weight"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
// A weight bound only to a text style is still a bare number, not a length.
assert!(css.contains("--weight: 700;") && !css.contains("700px"), "{css}");
}
#[test]
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
let mut s = Session::new();
+1 -1
View File
@@ -148,7 +148,7 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
let max_nodes = layer_budget(packed);
drop(message);
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
let (mut json, mut warnings, networks) = map::to_rest_for(&msg, file_name, max_nodes, board);
// The message goes before the layers are built, so the images it carries are copied out first.
let embedded = embedded_copies(&msg);
drop(msg);
+97 -1
View File
@@ -446,10 +446,106 @@ fn a_jam_file_imports_like_a_fig() {
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
let note = named(&doc, "Board object");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 2, "the sticky is native: its note and author");
assert_eq!(note.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
}
}
/// Import `layer` through the public importer as a FigJam whiteboard (`fig-jam.` header).
fn jam(layer: Kv<'static>) -> (Document, Vec<String>) {
let mut bytes = canvas(&board_schema(), &message(layer));
bytes.splice(..8, *b"fig-jam.");
import(&bytes, "Synthetic board").unwrap()
}
/// On a whiteboard a sticky is a native sticky: its colour, size, text and author come along.
#[test]
fn a_jam_sticky_is_a_native_sticky_with_its_colour_text_and_author() {
let (doc, warnings) = jam(board_node("STICKY"));
assert_eq!(doc.kind, figcraft_doc::FileKind::Board);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert_eq!(frame.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert_eq!((frame.width, frame.height), (240.0, 240.0));
assert_eq!(frame.opacity, 0.5);
let layout = frame.layout.as_ref().expect("sticky auto layout");
assert_eq!(layout.padding, [20.0; 4]);
assert_eq!(frame.child_layout.min_h, Some(240.0));
assert_eq!(frame.children.len(), 2, "the note and its author, no background vector");
let texts: Vec<_> = frame.children.iter().map(|id| doc.node(*id).unwrap()).collect();
assert!(texts.iter().all(|n| n.kind.text().is_some()));
let note = texts[0].kind.text().unwrap();
assert_eq!(note.characters, "Olá\nIdea");
assert_eq!(note.style.font_size, 24.0);
assert!(note.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive");
let author = texts[1].kind.text().unwrap();
assert_eq!(author.characters, "Test Author");
assert_eq!(author.style.font_size, 12.0);
assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}");
}
/// A sticky whose author line is not saved has an empty author and just its note.
#[test]
fn a_jam_sticky_without_an_author_line_has_no_author() {
let mut layer = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá\nIdea", 24.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 1);
let Some(figcraft_doc::BoardItem::Sticky { author }) = &frame.board else { panic!("{:?}", frame.board) };
assert!(author.is_empty());
}
/// A sticky record that isn't complete keeps today's generated layers and no board item.
#[test]
fn a_damaged_jam_sticky_keeps_the_generated_layers() {
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("visible", Kv::Bool(true))]);
let cases: Vec<Kv<'static>> = {
let mut v = Vec::new();
// Layout version 2.
let mut a = board_node("STICKY");
set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))]));
v.push(a);
// Background record missing.
let mut b = board_node("STICKY");
set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 24.0))]))]));
v.push(b);
// A stroke on the background.
let mut c = board_node("STICKY");
let stroked = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))]);
set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![stroked, record(1, text("Olá", 24.0))]))]));
v.push(c);
// A gradient on the background.
let mut d = board_node("STICKY");
let graded = record(0, vec![("fillPaints", arr(vec![gradient]))]);
set(&mut d, "nodeGenerationData", o(vec![("overrides", arr(vec![graded, record(1, text("Olá", 24.0))]))]));
v.push(d);
// Blank note.
let mut f = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut f, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))]));
v.push(f);
v
};
for (i, layer) in cases.into_iter().enumerate() {
let (as_fig, _) = imported(layer.clone());
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert!(frame.board.is_none(), "case {i}");
assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig");
}
}
/// The same sticky in a `.fig` stays the design frame it always was.
#[test]
fn a_fig_sticky_stays_a_design_frame() {
let (doc, _) = imported(board_node("STICKY"));
let frame = named(&doc, "Board object");
assert!(frame.board.is_none());
assert_eq!(frame.children.len(), 3);
}
+12
View File
@@ -17,6 +17,7 @@ use serde_json::{Map, Value, json};
use super::kiwi::Kv;
use super::network;
mod board;
mod component;
mod connector;
mod document;
@@ -50,8 +51,16 @@ const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
#[cfg(test)]
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
to_rest_for(msg, name, max_nodes, false)
}
/// The REST JSON (as `to_rest`) for a FigJam whiteboard (`board`) or a design file: a
/// whiteboard's stickies come out as native board items.
pub(super) fn to_rest_for(msg: &Kv, name: &str, max_nodes: usize, board: bool) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
let mut m = Mapper::new(msg, max_nodes);
m.board = board;
let _canon = VarCanon::install(&m);
let doc = m.document();
// Large values are moved in, never passed through `json!`, which serialises its arguments:
@@ -362,6 +371,8 @@ struct Mapper<'a> {
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
/// file, since layers with region paints don't share their network.
region_paint_elements: usize,
/// The file is a FigJam whiteboard: its objects are built as native board items.
board: bool,
}
impl<'a> Mapper<'a> {
@@ -439,6 +450,7 @@ impl<'a> Mapper<'a> {
max_nodes,
reserved: 0,
networks: Vec::new(),
board: false,
network_ids: HashMap::new(),
region_paint_elements: 0,
}
+88
View File
@@ -0,0 +1,88 @@
//! FigJam whiteboard objects as native board items: what a `.jam` import builds in place of the
//! generated frames a `.fig` keeps. The REST JSON carries them as a `board` field
//! ([`crate::rest`] reads it).
use super::layer::{own_text, valid_size};
use super::paint::paint;
use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num};
impl<'a> Mapper<'a> {
/// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved,
/// the saved colour) holding the note and, when saved, the author line. `None` (nothing
/// changed) unless every generated record is complete: then the caller keeps the generated
/// layers.
pub(super) fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
return None;
}
let record = |id: u32| {
let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?);
let view = View::of(style).with([(bounds, 1.0)]);
(valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view)
};
let background = record(0)?;
let fills = background.list("fillPaints");
let solid = matches!(fills, [p] if p.get("type").and_then(Kv::str) == Some("SOLID") && p.get("visible").and_then(Kv::bool) != Some(false));
if !solid || !background.list("strokePaints").is_empty() {
return None;
}
let note = record(1).filter(|n| own_text(n).is_some())?;
let author = record(2).filter(|a| a.flag("visible") != Some(false) && own_text(a).is_some());
let wanted = 1 + usize::from(author.is_some());
if depth >= MAX_DEPTH || self.count.saturating_add(wanted) > self.budget() {
return None;
}
let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?;
let at = note.transform().unwrap_or_default().as_coeffs();
let pad = |x: f64| if x.is_finite() { x.clamp(0.0, (w.min(h) / 2.0).max(0.0)) } else { 0.0 };
let (pad_x, pad_y) = (pad(at[4]), pad(at[5]));
// Everything is valid: build.
let paints: Vec<Value> = fills.iter().filter_map(|p| paint(p, &mut |m| self.warn(m))).collect();
o.insert("fills".into(), Value::Array(paints));
o.insert("strokes".into(), json!([]));
o.insert("clipsContent".into(), json!(false));
match background.len("cornerRadius") {
Some(r) => o.insert("cornerRadius".into(), json!(r)),
None => o.remove("cornerRadius"),
};
for (k, val) in [
("layoutMode", json!("VERTICAL")),
("primaryAxisAlignItems", json!("SPACE_BETWEEN")),
("counterAxisAlignItems", json!("MIN")),
("primaryAxisSizingMode", json!("AUTO")),
("counterAxisSizingMode", json!("FIXED")),
("minHeight", json!(h)),
("itemSpacing", json!(8.0)),
("paddingTop", json!(pad_y)),
("paddingBottom", json!(pad_y)),
("paddingLeft", json!(pad_x)),
("paddingRight", json!(pad_x)),
] {
o.insert(k.into(), val);
}
let mut kids = Vec::new();
let mut written = String::new();
for (local, view) in [(1, Some(note)), (2, author)] {
let Some(view) = view else { continue };
let chars = own_text(&view).unwrap_or_default();
let mut layer = Obj::new();
layer.insert("id".into(), json!(format!("{rest_id};generated{local}")));
layer.insert("type".into(), json!("TEXT"));
layer.insert("name".into(), json!(if local == 1 { chars.chars().take(40).collect::<String>() } else { "Author".to_string() }));
self.common(&mut layer, &view, "TEXT", Affine::IDENTITY, None);
self.text(&mut layer, &view);
if let Some(Value::Object(style)) = layer.get_mut("style") {
style.insert("textAutoResize".into(), json!("HEIGHT"));
style.insert("textAlignHorizontal".into(), json!("LEFT"));
}
layer.insert("layoutSizingHorizontal".into(), json!("FILL"));
if local == 2 {
written = chars.trim().to_string();
}
self.count += 1;
kids.push(Value::Object(layer));
}
o.insert("board".into(), json!({"type": "sticky", "author": written}));
Some(kids)
}
}
+10 -2
View File
@@ -174,7 +174,9 @@ impl<'a> Mapper<'a> {
// A label without a snapshot: the connector becomes a frame holding its line and its label.
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
// A whiteboard's sticky says it imports as a frame only when it can't be a native one.
let native_sticky = self.board && own == "STICKY";
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
if connector_snapshot {
@@ -262,12 +264,18 @@ impl<'a> Mapper<'a> {
});
kids.splice(0..0, connector_kids);
if stand.is_some_and(|s| s.0 == "FRAME") {
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None };
if let Some(native) = native {
kids.splice(0..0, native);
} else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
kids.splice(0..0, snapshot);
} else {
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
}
if native_sticky && !o.contains_key("board") {
self.warn("sticky layers import as frames with their text");
}
}
if !kids.is_empty() {
o.insert("children".into(), Value::Array(kids));
+2 -1
View File
@@ -1583,7 +1583,8 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
// A whiteboard's dump shows its native board items as the import makes them.
let (rest, ..) = map::to_rest_for(&msg, &stem, super::layer_budget(message.len()), a.canvas.starts_with(super::JAM_HEADER));
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
+37
View File
@@ -442,6 +442,8 @@ pub(crate) struct Ctx<'a> {
set_defs: BTreeMap<NodeId, BTreeMap<String, PropDef>>,
/// Prototype starting points: (page, name, REST node id).
flows: Vec<(NodeId, String, String)>,
/// Board connectors and labels, resolved once every layer is imported (their ends name layers).
pending_boards: Vec<(NodeId, &'a Value)>,
/// Layers with prototype interactions, resolved once every layer is imported.
protos: Vec<(NodeId, &'a Value)>,
/// Layers that carry overlay settings (position, background, outside clicks), by REST id.
@@ -478,6 +480,7 @@ impl<'a> Ctx<'a> {
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
pending_boards: Vec::new(),
protos: Vec::new(),
overlay_meta: BTreeMap::new(),
var_kinds: BTreeMap::new(),
@@ -781,6 +784,9 @@ impl<'a> Ctx<'a> {
self.overlay_meta.entry(rid).or_insert(j);
}
self.bindings(n, j);
if let Some(b) = j.get("board") {
self.board_item(n, b);
}
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
// Styles and bindings were read against the stroke: they follow its paints to the fill.
if outlined && !restored {
@@ -788,6 +794,36 @@ impl<'a> Ctx<'a> {
}
}
/// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape
/// is set at once; anything else, or junk, is ignored with a warning.
fn board_item(&mut self, n: &mut Node, b: &'a Value) {
// A sticky or shape is a frame holding its layers: on anything else it is ignored.
let frame = n.kind.is_frame_like();
let item = match text(b, "type") {
Some("sticky") if frame => {
let author = text(b, "author").unwrap_or("").trim();
Some(figcraft_doc::BoardItem::Sticky { author: author.chars().filter(|c| !c.is_control()).take(64).collect() })
}
Some("shape") if frame => {
text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape })
}
_ => None,
};
match item {
Some(item) => n.board = Some(item),
None => self.warn(format!("unknown board item on {} was ignored", n.name)),
}
}
/// Board items that name other layers (connector ends, a label's connector), once every
/// layer is imported and `ids` can resolve them.
fn resolve_boards(&mut self) {
for (id, _) in std::mem::take(&mut self.pending_boards) {
let name = self.doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
self.warn(format!("unknown board item on {name} was ignored"));
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
@@ -1096,6 +1132,7 @@ impl<'a> Ctx<'a> {
}
}
self.resolve_instances();
self.resolve_boards();
if let Some(sources) = self.fig_sources.take() {
let missing = sources.apply(&mut self.doc, &self.ids);
if missing > 0 {
@@ -93,3 +93,29 @@ fn thousands_of_distinct_style_references_import_in_linear_time() {
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(doc.styles.len(), n);
}
#[test]
fn a_board_field_becomes_the_nodes_board_item() {
let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200},
"board": {"type": "sticky", "author": "A\u{7}"}});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() }));
assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}");
}
#[test]
fn junk_board_fields_are_ignored_with_a_warning() {
for junk in [json!(5), json!({"type": "nope"}), json!({"type": "shape", "shape": "blob"}), json!({"type": "connector"})] {
let frame =
json!({"id": "1:1", "name": "Junk", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, "board": junk});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert!(named(&doc, "Junk").board.is_none(), "{junk}");
assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}");
}
// A sticky is a frame holding its layers: on a rectangle the item is ignored.
let rect = json!({"id": "1:2", "name": "Rect", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20},
"board": {"type": "sticky", "author": "A"}});
let (doc, warnings) = import(&file(json!([rect])), &no_images).unwrap();
assert!(named(&doc, "Rect").board.is_none());
assert!(warnings.iter().any(|w| w.contains("unknown board item on Rect was ignored")), "{warnings:?}");
}
+1 -1
View File
@@ -336,7 +336,7 @@ fn tools() -> Vec<Value> {
"y": num("Page y"),
"to": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Drag end [x, y] (page)"},
"steps": int("Intermediate move events for drag (default 8)"),
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment"},
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation"},
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
+1 -1
View File
@@ -33,7 +33,7 @@ const MAX_CACHED_IMAGES: usize = 256;
/// The largest sides of the copies the canvas draws larger images from while they show smaller
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
/// together, and 42 MB each at 4096 (168 MB at 8192).
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
pub const DISPLAY_SIDES: [u32; 6] = [256, 512, 1024, 2048, 4096, 8192];
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
+7 -6
View File
@@ -8,7 +8,7 @@ use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use tiny_skia::Pixmap;
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
use crate::images::{self, MAX_CACHED_BYTES};
use crate::{Options, View, render_view};
const W: u32 = 6000;
@@ -63,12 +63,11 @@ fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
#[test]
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
let (d, page, keys) = doc("zoomed-out");
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
for (k, p) in keys.iter().zip(&first) {
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
assert_eq!((p.width(), p.height()), (2048, 3), "{k}");
assert_eq!(images::cached_sides(k), vec![2048], "{k}: only its copy is decoded");
}
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
@@ -93,13 +92,15 @@ fn exports_and_close_ups_draw_from_the_image_itself() {
#[test]
fn the_copy_is_the_smallest_that_holds_what_shows() {
let side = |long, shown| images::display_side(long, shown);
assert_eq!(side(6000, 1500.0), 4096);
assert_eq!(side(6000, 1500.0), 2048);
assert_eq!(side(16_000, 4096.0), 4096);
assert_eq!(side(16_000, 4097.0), 8192);
assert_eq!(side(4096, 100.0), 256);
assert_eq!(side(3000, 20.0), 256);
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
assert_eq!(side(16_000, 9000.0), 16_384);
assert_eq!(side(6000, 5000.0), 16_384);
assert_eq!(side(4096, 100.0), 16_384);
assert_eq!(side(200, 100.0), 16_384);
// An unknown size or a meaningless scale: the image itself.
assert_eq!(side(0, 100.0), 16_384);
assert_eq!(side(16_000, f64::NAN), 16_384);
+39 -7
View File
@@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
nodes_into(doc, ids, view, pix, 0.0);
nodes_into(doc, ids, view, pix, 0.0, None);
}
/// [`render_nodes_into`] `time` seconds into animated GIFs.
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) {
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
@@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
under,
time,
bounds: &bounds,
};
@@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time)
}
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, b, scale, 0.0)
area_at(doc, ids, None, b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level
/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of
/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay
/// out. Without such an effect it is [`render_area`].
pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, Some(page_layers), b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
@@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
nodes_into(doc, ids, &view, &mut pix, time);
// What the backdrop effects read: the page under the first layer, with the margin they reach.
let reach = page_layers.and_then(|_| {
ids.iter()
.flat_map(|i| doc.subtree(*i))
.filter_map(|i| doc.node(i))
.filter(|n| n.visible && backdrop::has_effects(n))
.map(|n| backdrop::pad(n, scale))
.max()
});
let beneath = match (page_layers, ids.first(), reach) {
(Some(layers), Some(first), Some(m)) => {
let m = i32::try_from(m).unwrap_or(0);
let p = f64::from(m) / scale;
let around = View {
origin: (b.x0 - p, b.y0 - p),
scale,
width: view.width.saturating_add(m.unsigned_abs() * 2),
height: view.height.saturating_add(m.unsigned_abs() * 2),
};
backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m))
}
_ => None,
};
let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None));
nodes_into(doc, ids, &view, &mut pix, time, under.as_ref());
Some(pix)
}
+2
View File
@@ -122,6 +122,7 @@ name = "English"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotation" = "Annotation"
"Annotations" = "Annotations"
"Annotations shown on the canvas" = "Annotations shown on the canvas"
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
@@ -1885,6 +1886,7 @@ name = "English"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
"sticky layers import as frames with their text" = "sticky layers import as frames with their text"
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke"
"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered"
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
+2
View File
@@ -22,6 +22,7 @@ status = "draft — needs native review"
"Along the top" = "A lo largo del borde superior"
"An earlier vote" = "Una votación anterior"
"Android Vector Drawable" = "Android Vector Drawable"
"Annotation" = "Anotación"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
@@ -2366,6 +2367,7 @@ status = "draft — needs native review"
"text.underline" = "Subrayado"
"text.weightDown" = "Reducir peso de fuente"
"text.weightUp" = "Aumentar peso de fuente"
"tool.annotation" = "Anotación"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
+12
View File
@@ -266,6 +266,18 @@ impl FigApp {
self.ui.comments.show(id);
}
}
"annotation.new" => {
// The Annotation tool: show the Inspect tab (never toggle it away) and put the
// caret in the layer's new-note field.
// Only a selected layer's field can show; a request for anything else would wait
// and take the keyboard later.
let shown =
|id| self.session.selection().contains(&id) && self.session.doc().is_ok_and(|d| d.node(id).is_some_and(|n| !n.is_page()));
if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of).filter(|id| shown(*id)) {
self.ui.right_tab = RightTab::Inspect;
self.ui.dev.focus_new_note = Some(id);
}
}
"comment.new" => {
let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
+44
View File
@@ -196,3 +196,47 @@ fn the_playground_swaps_an_instance_in_the_preview_only() {
let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
}
#[test]
fn the_annotation_tool_opens_the_inspect_tab_with_the_note_field_focused() {
let (mut u, _, r) = setup();
u.app.ui.right_tab = crate::RightTab::Design;
u.frames(2, Vec::new);
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect, "the request shows the Inspect tab");
let field = egui::Id::unique(("annot_new", r)).with("te");
assert!(u.ctx.memory(|m| m.has_focus(field)), "the layer's new-note field has the caret");
assert_eq!(u.app.ui.dev.focus_new_note, None, "the focus request is spent");
// Asking again while already on the Inspect tab does not toggle it away.
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(field)), "the field has the caret again");
assert_eq!(u.app.ui.dev.focus_new_note, None, "and the request is spent");
}
/// A request for a layer that isn't selected (or is gone) does not wait to take the keyboard later.
#[test]
fn a_note_request_for_a_layer_that_is_not_selected_is_dropped() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
u.frames(2, Vec::new);
u.app.session.request_ui("annotation.new", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.dev.focus_new_note, None);
}
/// Choosing the Annotation tool from the toolbar on the Inspect tab keeps the tab (an editing tool
/// would leave it for Design) and asks for the selected layer's note.
#[test]
fn the_toolbar_keeps_the_inspect_tab_for_the_annotation_tool() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Annotation);
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(egui::Id::unique(("annot_new", r)).with("te"))), "the note field has the caret");
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Rectangle);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "a drawing tool leaves it");
}
@@ -0,0 +1,52 @@
//! A section header's icon buttons (Appearance, Fill, Stroke, Effects...) draw their glyphs at the
//! same size as the panel's other 24 pt icon buttons (#2227).
use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
}
});
out.textures_delta.clear();
out.shapes
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
/// reference) are drawn at one size.
#[test]
fn header_eye_draws_as_large_as_a_fill_rows_eye() {
let header = painted_box(|ui| {
section_header(ui, "Appearance", &[(Icon::Eye, "Hide")], false);
});
let at = painted_box(|ui| {
let r = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button_at(ui, ui.scope_id().with("b"), r, Icon::Eye, "Hide");
});
let row = painted_box(|ui| {
icons::button(ui, Icon::Eye, 24.0, false, "Toggle visibility");
});
for (name, b) in [("icon_button_at", at), ("icons::button", row)] {
assert!(header.is_finite() && b.is_finite(), "{header:?} {b:?}");
assert!((header.width() - b.width()).abs() < 0.5, "{name}: widths {} vs {}", header.width(), b.width());
assert!((header.height() - b.height()).abs() < 0.5, "{name}: heights {} vs {}", header.height(), b.height());
}
}
#[test]
fn glyph_rect_is_the_shared_inset_of_the_button() {
let b = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0));
assert_eq!(icon_glyph_rect(b).size(), vec2(16.0, 16.0));
}
+2
View File
@@ -124,6 +124,8 @@ mod guide_release_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod header_icon_size_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
+2 -2
View File
@@ -1157,7 +1157,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon);
if xr.clicked() {
remove = Some(l.id.clone());
}
@@ -1169,7 +1169,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
if sr.hovered() {
ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sw.shrink(6.0), Icon::Swap, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sw), Icon::Swap, t.icon);
crate::right::menu_popup(&sr).show(|ui| {
for other in doc.libraries.enabled.iter().filter(|o| o.id != l.id) {
if crate::right::menu_item(ui, &trf("Swap to {name}", &[("name", &other.name)]), "") {
+8
View File
@@ -92,6 +92,9 @@ impl CategoryFilter {
/// revision), the page or the choice change.
#[derive(Default)]
pub struct DevUi {
/// The layer whose new-note field takes the keyboard focus once its section draws (the
/// Annotation tool asked for that layer's note).
pub focus_new_note: Option<NodeId>,
pub filter: usize,
pub categories: CategoryFilter,
pub compare: Option<crate::dev_compare::CompareView>,
@@ -618,6 +621,11 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
}
ui.add_space(6.0);
}
// The Annotation tool asked for this layer's note: put the caret in the new-note field, once.
if app.ui.dev.focus_new_note == Some(id) {
app.ui.dev.focus_new_note = None;
ui.memory_mut(|m| m.request_focus(Id::unique(("annot_new", id)).with("te")));
}
// A new annotation starts as a note; properties are pinned to it afterwards.
if n.annotations.len() < annotations::MAX_PER_LAYER
&& let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
+1 -1
View File
@@ -684,7 +684,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPop
if xr.hovered() {
ui.painter().rect_filled(x, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon);
if xr.clicked() {
close = true;
}
+2 -3
View File
@@ -456,7 +456,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if sresp.hovered() {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Rotate, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Rotate, t.icon);
if sresp.clicked() {
app.run("frame.swapOrientation", json!({"ids": sel}));
}
@@ -511,8 +511,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
("bool.union", Some(op)) => crate::layers::op_icon(op),
_ => icon,
};
let icon_rect = Rect::from_min_size(br.min, vec2(24.0, 24.0)).shrink(5.0);
icons::paint(ui.painter(), icon_rect, icon, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), icon, t.icon);
if dropdown {
icons::paint(ui.painter(), Rect::from_center_size(pos2(br.max.x - 6.0, br.center().y), vec2(8.0, 8.0)), Icon::Chevron, t.icon_secondary);
}
+24 -2
View File
@@ -30,6 +30,9 @@ enum Target {
Proto(crate::proto_editor::VarSlot),
/// Bind it to `field` of the shared style `style`.
Style { style: String, field: String },
/// Bind it to the weight of the shared text style `style`: a string variable names the face
/// (`fontStyle`), a number one sets the weight (`fontWeight`).
StyleWeight { style: String },
}
/// The open variable picker: what it does, the variable type (None: any), where it opens.
@@ -197,6 +200,13 @@ pub fn open_style(ui: &Ui, style: &str, field: &str, kind: VariableType, at: Pos
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at }));
}
/// Open the picker at `at` to bind a string (face name) or number (weight) variable to the weight of
/// the shared text style `style`.
pub fn open_style_weight(ui: &Ui, style: &str, at: Pos2) {
let target = Target::StyleWeight { style: style.to_string() };
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: None, at }));
}
/// Where the open picker is on screen (clicks in it don't close what opened it).
pub fn picker_rect(ctx: &egui::Context) -> Option<Rect> {
picker_open(ctx).then(|| ctx.memory(|m| m.area_rect(Id::unique("variable_picker_area")))).flatten()
@@ -398,7 +408,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
let (scope, own) = match &p.target {
Target::Bind { ids, field } => (ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)), None),
Target::Alias { variable, .. } => (None, doc.variables.get_by_id(variable)),
Target::Proto(_) | Target::Style { .. } => (None, None),
Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. } => (None, None),
};
// A style's fields offer variables scoped to one of the properties the field sets.
let style_scopes = match &p.target {
@@ -406,7 +416,8 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
_ => None,
};
// Prototypes and styles use this file's variables.
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. });
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. });
let weight_target = matches!(p.target, Target::StyleWeight { .. });
let field = match &p.target {
Target::Bind { field, .. } => Some(field.as_str()),
_ => None,
@@ -417,6 +428,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
&& (q.is_empty() || v.name.to_lowercase().contains(&q))
&& scope.is_none_or(|s| v.in_scope(s))
&& style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s)))
&& (!weight_target
|| match v.kind {
VariableType::String => v.in_scope("FONT_STYLE"),
VariableType::Number => v.in_scope("FONT_WEIGHT"),
_ => false,
})
&& (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok()))
};
let groups = groups(&doc, &libs, keep);
@@ -495,6 +512,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
(Target::Style { style, field }, _) => {
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
}
(Target::StyleWeight { style }, _) => {
let number = app.session.ds().is_ok_and(|d| d.doc.variables.get_by_id(&pick.id).is_some_and(|v| v.kind == VariableType::Number));
let field = if number { "fontWeight" } else { "fontStyle" };
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
}
}
close = true;
}
+2 -2
View File
@@ -626,8 +626,8 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the
/// design.
fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) {
pub(crate) fn choose(app: &mut FigApp, tool: ToolId) {
if app.ui.right_tab == crate::RightTab::Inspect && !tool.works_in_dev_mode() {
app.ui.right_tab = crate::RightTab::Design;
}
app.run("tool.set", json!({"tool": tool.id()}));
+12 -2
View File
@@ -1024,7 +1024,7 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), r.shrink(6.0), *icon, t.icon);
icons::paint(ui.painter(), icon_glyph_rect(r), *icon, t.icon);
if resp.clicked() {
out = Some(i);
}
@@ -1056,6 +1056,16 @@ pub fn tight_end_n<R>(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R)
add(&mut child)
}
/// Inset of the glyph inside a 24 pt icon button: a 16 pt glyph, the icons' native size and what
/// `icons::button` draws (a fill row's eye), so every 24 pt icon button in the panels matches it
/// (section header buttons included).
pub const ICON_BUTTON_INSET: f32 = 4.0;
/// The rect a 24 pt icon button paints its glyph in.
pub fn icon_glyph_rect(button: Rect) -> Rect {
button.shrink(ICON_BUTTON_INSET)
}
/// An icon button at `rect` (hover highlight, tooltip); true when clicked.
pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
@@ -1063,7 +1073,7 @@ pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) ->
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
icons::paint(ui.painter(), icon_glyph_rect(rect), icon, t.icon);
resp.clicked()
}
+7 -4
View File
@@ -726,6 +726,8 @@ struct TextBinding<'a> {
fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) {
let TextBinding { style, field, kind, rect, name, lead } = binding;
match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) {
// The weight slot takes a face name or a number, so its chip opens the picker that offers both.
Some(widgets::BindEvent::Open(at)) if matches!(field, "fontWeight" | "fontStyle") => crate::variable_ui::open_style_weight(ui, style, at),
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
None => {}
@@ -787,15 +789,16 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty
let id = style.id.as_str();
let mut style_resp = None;
ui.horizontal(|ui| {
match bound_name(doc, style, "fontStyle") {
let weight_field = if style.bound.contains_key("fontWeight") { "fontWeight" } else { "fontStyle" };
match bound_name(doc, style, weight_field) {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
text_binding(
ui,
TextBinding {
style: id,
field: "fontStyle",
kind: VariableType::String,
field: weight_field,
kind: if weight_field == "fontWeight" { VariableType::Number } else { VariableType::String },
rect: r,
name: &name,
lead: widgets::Lead::Named(tr("Font style")),
@@ -944,7 +947,7 @@ fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response,
fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) {
crate::right::menu_popup(style_resp).width(200.0).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom());
crate::variable_ui::open_style_weight(ui, id, style_resp.rect.left_bottom());
ui.close();
return;
}
@@ -362,9 +362,19 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
("Body tracking", "number", json!(1)),
("Body font", "string", json!("Inter")),
("Body weight", "string", json!("Bold")),
("Body weight number", "number", json!(700)),
] {
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
}
for (name, scope) in [
("Body size", "FONT_SIZE"),
("Body leading", "LINE_HEIGHT"),
("Body tracking", "LETTER_SPACING"),
("Body font", "FONT_FAMILY"),
("Body weight number", "FONT_WEIGHT"),
] {
s.execute("variable.update", &json!({"variable": name, "scopes": [scope]})).unwrap();
}
if bind {
for (field, var) in [
("fontSize", "Body size"),
@@ -454,6 +464,54 @@ fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() {
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn the_weight_menu_binds_a_number_variable_as_font_weight() {
let mut u = text_variables_session(false);
let left = editor_left(&mut u);
let in_editor = |u: &mut crate::layers_drag_tests::Ui| -> Vec<(String, Pos2)> {
drawn_texts(u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect()
};
let texts = in_editor(&mut u);
click(&mut u, text_at(&texts, "Regular"));
let texts = drawn_texts(&mut u);
click(&mut u, text_at(&texts, "Apply variable"));
assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opens");
let texts = drawn_texts(&mut u);
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "string variables are offered");
assert!(!texts.iter().any(|(t, _)| t == "Body font" || t == "Body leading"), "other fields' variables are not");
click(&mut u, text_at(&texts, "Body weight number"));
assert_eq!(bound_to(&u, "fontWeight").as_deref(), Some("Body weight number"));
assert_eq!(bound_to(&u, "fontStyle"), None);
let font_style = || {
let doc = u.app.session.doc().unwrap();
let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| s.kind.clone()) else { panic!("a text style") };
def.font_style
};
assert_eq!(font_style(), "Bold");
// The chip shows it in the style slot; clicking it lists the string variable too, and picking that
// replaces the number: fontStyle is bound and fontWeight is not.
for _ in 0..2 {
u.frame(vec![]);
}
let texts = in_editor(&mut u);
click(&mut u, text_at(&texts, "Body weight number"));
assert!(crate::variable_ui::picker_open(&u.ctx), "the chip opens the weight picker");
let texts = drawn_texts(&mut u);
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "the string variable is offered beside the number one");
click(&mut u, text_at(&texts, "Body weight"));
assert_eq!(bound_to(&u, "fontStyle").as_deref(), Some("Body weight"));
assert_eq!(bound_to(&u, "fontWeight"), None, "the name replaced the number");
// The chip's detach button clears the binding.
for _ in 0..2 {
u.frame(vec![]);
}
let texts = in_editor(&mut u);
let chip = text_at(&texts, "Body weight");
click(&mut u, Pos2::new(left + (W - 8.0) / 2.0 - 11.0, chip.y));
assert_eq!(bound_to(&u, "fontStyle"), None, "the face name is detached");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn text_style_editor_binds_variables_from_its_fields() {
let mut u = text_variables_session(false);
@@ -477,7 +535,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
click(&mut u, Pos2::new(x, y));
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
let texts = drawn_texts(&mut u);
assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only");
assert!(!texts.iter().any(|(t, _)| t == "Body weight"), "{field} offers number variables only");
click(&mut u, text_at(&texts, name));
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
}
@@ -491,7 +549,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
let texts = drawn_texts(&mut u);
// "Body size" shows once, on the size field's chip: the picker doesn't list it.
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only");
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} does not offer the size variable");
click(&mut u, text_at(&texts, name));
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
@@ -66,7 +66,7 @@ all end with the same geometry.
| Audio and huddles | missing | |
| AI features (summaries, sorting stickies, generating boards) | missing | |
| Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as frames with their generated layers and its connectors as vectors, not yet as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) |
## Revision history
@@ -75,6 +75,7 @@ all end with the same geometry.
| 2026-11-09 | patch | M12, #2157: a sticky's text is 18 and stays 18 (measured in FigJam's web app: ten lines, then a long paragraph, never changed it; the note kept its width and grew taller). The normalize pass no longer resets a sticky's text to a size by its length (24 down to 11 before), so stickies in saved files keep their size. Size presets (Small to Huge) are still missing. Tests: `a_new_sticky_starts_at_18`, `a_sticky_keeps_its_text_size_as_text_is_added`, `a_sticky_saved_with_another_text_size_keeps_it` |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. A mind map node is a board object of its own (`BoardItem::MindNode`: text with no box) and a branch is a connector marked `branch`; the map's shape is read from its branches. `board.mindMap` places a first node with three children and `board.mindMapAdd` adds a child or a sibling, each one undo step. The normalize pass lays a touched map out again from its first node (`board::settle_mind_maps`, `figcraft_doc::mind_layout`): 88 between columns, 48 between siblings, each stack centred on its parent, numbers from a FigJam file saved and decoded. Tests: `board_mindmap_tests` in doc (five) and engine (eight). Numbers unchanged (no toolbar entry yet). |
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
| 2026-11-10 | minor | M9, #1808: a `.jam` sticky imports as a native board sticky (`BoardItem::Sticky`): its frame keeps the saved size (240 pt, not FigCraft's 200), place, opacity and shadows, the saved colour is kept as it is (not snapped to the board colours), the note and the author line are its text layers, and it lays out like one made here (vertical, space between, hugging the height down to the saved size, the saved inset as padding). The importer reads a `board` field on the REST JSON (a FigCraft extension) for this; a sticky whose generated records are damaged, or the same sticky in a `.fig`, keeps the generated layers as before. Shapes, connectors, stamps and tables are unchanged; the note's text keeps the file's size until it is edited. Tests: `board_import_tests.rs` (native sticky, no author, damaged records, `.fig` unchanged), `import_regression_tests.rs` (the `board` field and junk values). Numbers unchanged. |
| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. |
| 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). |
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -65,7 +65,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) |
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -15,7 +15,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
@@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (Windows: the custom title bar's windows stay borderless, so the content is no longer drawn about 30 px low with clicks landing above it (#1343); under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (unused; zoomed-out images draw from small copies that stay decoded, so a page of photos zooms out without decoding them again; under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -12,7 +12,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|---|---|---|---|
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, large images drawn from a copy no larger than they show (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, images drawn from the smallest copy that holds the size they show, down to 256 px (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Zoomed-out images: the canvas's copies of an image shown smaller than its pixels now also come at 256, 512, 1024 and 2048 px, the smallest that holds what the screen shows, and images of 4096 px or less get them too (they were drawn from the image itself at any size). On a page of 237 photos, about 4.6 GB decoded against the 512 MB cache, every zoomed-out render decoded them again: 3.4 s a frame at 1% before, 0.9 s the first time and 4 ms after; peak memory 3.9 → 2.3 GB. Exports, and images shown at their own size or larger, still draw from the image itself. |
| 2026-11-10 | patch | M10, #1343 (also #1422, #2233): Windows: the window no longer draws below a hidden caption. FigCraft draws its own title bar on Windows and opens undecorated windows, but winit 0.30 still gives them `WS_CAPTION` and `WS_BORDER`; on some systems the desktop compositor reserves that caption, so the content was drawn about 30 px low (a black band above it) while clicks kept the client coordinates and landed above what was drawn. A per-frame step (`apps/figcraft/src/window_frame.rs`, safe `winsafe` calls, Windows only) clears `WS_CAPTION` and `WS_BORDER` from every FigCraft window, the main one and the others, keeping the sizing border and window buttons so edge resizing and snap work, and writes only when a style changed (winit puts them back on maximize and restore). The same fix as DesignCraft #2139 and PhotoCraft #2401; the maximize-to-work-area trim from DesignCraft is left for a later change. Not run on a Windows machine that shows the offset: the style math and the per-frame hook are tested, the window itself needs a live check. Tests: `window_frame_tests`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` |
| 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. |
+3 -1
View File
@@ -1886,7 +1886,9 @@ properties with their values, its code and, with `path`, a PNG of it. The docume
unbinds): `fill` or `fills/<i>/color` and `fills/<i>/stops/<j>/color` on a paint style,
`effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` on an effect style,
`layoutGrids/<i>/<size|count|gutter|offset>` on a grid style, `fontSize`, `lineHeight`,
`letterSpacing`, `fontFamily`, `fontStyle` on a text style. Each layer using the style shows the
`letterSpacing`, `fontWeight` (numbers; a weight picks the family's face nearest it and sets the
`wght` axis of a variable font), `fontFamily`, `fontStyle` (strings; a bound weight and a bound style
name replace each other) on a text style. Each layer using the style shows the
field in its own modes and stays linked; `style.list` gives a style's `bound` map.
`effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`,
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -4
View File
File diff suppressed because one or more lines are too long