mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge main into #2310
This commit is contained in:
commit
85c17dd552
15 files changed
+664
-71
No files matched your search
@@ -16,8 +16,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Pin the coverage setup that checks out this repo's pinned craft-i18n data.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@ea24c9c24e1997238334182160f60e602e5bce32
|
||||
# Pin the coverage setup that provisions test data and fetches locked dependencies before offline metadata tests.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@5008f8cd1b011b35eed223a7d058d7a8e9075ef6
|
||||
with:
|
||||
# The shared coverage archive's shards omit the CLI executable used by integration tests.
|
||||
# Run this repository's suite in one instrumented job and provide its pinned test data.
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
//! [`INSET_PX`] in, so it can be grabbed at radius 0). Dragging it along the bisector sets the
|
||||
//! radius of every corner; with Alt, only that corner of a rectangle or frame (the documentation
|
||||
//! is silent on Alt).
|
||||
//! - **Count**: a polygon's or star's sits on its first point clockwise from the top; dragging it
|
||||
//! sets the count from the pointer's angle about the centre, measured clockwise from the top
|
||||
//! point: `round(360 / angle)`, clamped to 3 to 60 (measured in Figma's web app, 2026-11-12; a
|
||||
//! polygon's maximum wasn't read).
|
||||
//! - **Ratio**: a star's sits on its first inner point; dragging it towards or away from the
|
||||
//! centre sets the inner radius as a share of the outer one.
|
||||
//!
|
||||
//! Positions are in page coordinates and work for rotated and flipped layers at any zoom; the
|
||||
//! handles hide on layers smaller than [`MIN_PX`] on screen. The Move tool's gesture (`tools.rs`)
|
||||
//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`), one
|
||||
//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`, `pointCount`), one
|
||||
//! undo step per drag.
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
@@ -20,18 +24,22 @@ use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// One shape handle: a corner's radius (corner index: tl, tr, br, bl; 0 for a polygon's or
|
||||
/// star's top point) or a star's ratio.
|
||||
/// star's top point), a star's ratio or a polygon's or star's point count.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ShapeHandle {
|
||||
Radius { corner: usize },
|
||||
Ratio,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// Layers smaller than this on screen (either side, in pixels) show no shape handles.
|
||||
pub const MIN_PX: f64 = 48.0;
|
||||
/// How far in from its corner a handle sits at least (screen pixels).
|
||||
pub const INSET_PX: f64 = 10.0;
|
||||
/// The fewest and most points the Count handle sets (a star's range in the help center).
|
||||
const COUNT_MIN: f64 = 3.0;
|
||||
const COUNT_MAX: f64 = 60.0;
|
||||
/// How near the pointer must be to pick a handle (screen pixels).
|
||||
const HIT_PX: f64 = 6.0;
|
||||
|
||||
@@ -113,6 +121,34 @@ fn ratio_point(n: &Node, count: u32, ratio: f64) -> Option<Point> {
|
||||
figcraft_geom::star_points(n.width, n.height, count, ratio).get(1).copied()
|
||||
}
|
||||
|
||||
/// The Count handle (local): a polygon's vertex 1 or a star's outer point 1, the first clockwise
|
||||
/// from the top.
|
||||
fn count_point(n: &Node) -> Option<Point> {
|
||||
match n.kind {
|
||||
NodeKind::Polygon { count } => figcraft_geom::polygon_points(n.width, n.height, count).get(1).copied(),
|
||||
NodeKind::Star { count, inner_ratio } => figcraft_geom::star_points(n.width, n.height, count, inner_ratio).get(2).copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The count a pointer at local point `q` sets: `round(360 / angle)` for its clockwise angle from
|
||||
/// the top about the centre, on the unit circle the shape's box maps from; finite for any `q`.
|
||||
fn count_at(n: &Node, q: Point) -> Option<f64> {
|
||||
let (rx, ry) = (n.width / 2.0, n.height / 2.0);
|
||||
if rx.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) || ry.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (q.x / rx - 1.0, q.y / ry - 1.0);
|
||||
// On the centre there is no direction (atan2 of two zeros would read as 180 degrees): treat it
|
||||
// as no angle, the most points.
|
||||
if x.hypot(y) < 1e-9 {
|
||||
return Some(COUNT_MAX);
|
||||
}
|
||||
let angle = x.atan2(-y).rem_euclid(std::f64::consts::TAU).to_degrees();
|
||||
let count = (360.0 / angle.max(1e-9)).round();
|
||||
count.is_finite().then(|| count.clamp(COUNT_MIN, COUNT_MAX))
|
||||
}
|
||||
|
||||
/// The shape handles of the selection at `zoom` (screen pixels per page unit): each handle and
|
||||
/// where it is (page coordinates).
|
||||
pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [f64; 2])> {
|
||||
@@ -128,6 +164,9 @@ pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [
|
||||
{
|
||||
out.push((ShapeHandle::Ratio, pt(p)));
|
||||
}
|
||||
if let Some(p) = count_point(n) {
|
||||
out.push((ShapeHandle::Count, pt(p)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -160,6 +199,10 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) ->
|
||||
};
|
||||
Some(Map::from_iter([(key.to_string(), json!(r))]))
|
||||
}
|
||||
ShapeHandle::Count => {
|
||||
count_point(n)?;
|
||||
Some(Map::from_iter([("pointCount".to_string(), json!(count_at(n, q)?))]))
|
||||
}
|
||||
ShapeHandle::Ratio => {
|
||||
let NodeKind::Star { count, .. } = n.kind else { return None };
|
||||
// Along the inner point's ray, measured on the unit circle the star's box maps from.
|
||||
@@ -172,11 +215,12 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The live readout while dragging `h` of `id`: the corner's radius, or the star's ratio in percent.
|
||||
/// The live readout while dragging `h` of `id`: the corner's radius, the star's ratio in percent or the point count.
|
||||
pub fn value(doc: &Document, id: NodeId, h: ShapeHandle) -> Option<f64> {
|
||||
let n = doc.node(id)?;
|
||||
let v = match (h, &n.kind) {
|
||||
(ShapeHandle::Ratio, NodeKind::Star { inner_ratio, .. }) => inner_ratio * 100.0,
|
||||
(ShapeHandle::Count, NodeKind::Polygon { count }) | (ShapeHandle::Count, NodeKind::Star { count, .. }) => f64::from(*count),
|
||||
(ShapeHandle::Radius { corner }, _) => corners(n).into_iter().nth(corner).map(|(c, r)| r.clamp(0.0, c.max_r))?,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! their iOS and Android code syntax in SwiftUI and Compose. The output is a starting point, not a
|
||||
//! pixel-exact translation.
|
||||
|
||||
use figcraft_color::{Color, PaintKind};
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint, PaintKind};
|
||||
use figcraft_doc::{
|
||||
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing,
|
||||
TextAlignH, TextCase, TextDecoration, Variable,
|
||||
@@ -345,6 +345,35 @@ fn first_fill(n: &Node) -> Option<Color> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A layer's visible fills with their place in the document. Document order is bottom first, as
|
||||
/// the renderer paints them; the index tells a target which paint is the bottom one.
|
||||
fn visible_fills(n: &Node) -> impl DoubleEndedIterator<Item = (usize, &Paint)> {
|
||||
n.fills.iter().enumerate().filter(|(_, p)| p.visible)
|
||||
}
|
||||
|
||||
/// A gradient's sorted stops as (position, colour), the paint's opacity folded into each colour.
|
||||
/// A lone stop is written twice (at 1 as well), since Android and Flutter need two colours.
|
||||
fn gradient_stops(g: &Gradient, opacity: f32) -> Vec<(f64, Color)> {
|
||||
let mut stops: Vec<(f64, Color)> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color.with_alpha(s.color.a * opacity))).collect();
|
||||
if let [(_, only)] = stops[..] {
|
||||
stops.push((1.0, only));
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
/// A gradient's main axis in pixels on a `w` x `h` box.
|
||||
fn gradient_axis(g: &Gradient, w: f64, h: f64) -> (f64, f64) {
|
||||
((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h)
|
||||
}
|
||||
|
||||
/// The kind a native target writes: a diamond reads as a radial gradient.
|
||||
fn native_gradient_kind(g: &Gradient) -> GradientKind {
|
||||
match g.kind {
|
||||
GradientKind::Diamond => GradientKind::Radial,
|
||||
k => k,
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_color(n: &Node) -> Option<Color> {
|
||||
if !n.has_visible_stroke() {
|
||||
return None;
|
||||
@@ -824,4 +853,188 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// White, a 20% default linear gradient of black, then 50% black: bottom to top.
|
||||
fn three_fills() -> Vec<figcraft_color::Paint> {
|
||||
let mut gradient = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK));
|
||||
gradient.opacity = 0.2;
|
||||
let mut top = figcraft_color::Paint::solid(Color::BLACK);
|
||||
top.opacity = 0.5;
|
||||
vec![figcraft_color::Paint::solid(Color::WHITE), gradient, top]
|
||||
}
|
||||
|
||||
/// A 200 x `h` layer of `kind` (`frame`, `rectangle` or `ellipse`, a rectangle with corner
|
||||
/// radius 8) with exactly `fills`.
|
||||
fn fill_layer(s: &mut Session, kind: &str, h: f64, fills: Vec<figcraft_color::Paint>) -> NodeId {
|
||||
let id = match kind {
|
||||
"frame" => id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": h})).unwrap()["id"]).unwrap(),
|
||||
_ => id_of(&s.execute(&format!("shape.{kind}"), &json!({"x": 0, "y": 0, "width": 200, "height": h})).unwrap()["id"]).unwrap(),
|
||||
};
|
||||
if kind == "rectangle" {
|
||||
s.execute("node.set", &json!({"id": id, "props": {"cornerRadius": 8}})).unwrap();
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().fills = fills;
|
||||
id
|
||||
}
|
||||
|
||||
fn one_gradient(s: &mut Session, g: figcraft_color::Gradient) -> NodeId {
|
||||
fill_layer(s, "frame", 120.0, vec![figcraft_color::Paint::gradient(g)])
|
||||
}
|
||||
|
||||
const SWIFT_WHITE: &str = "Color(red: 1, green: 1, blue: 1, opacity: 1)";
|
||||
const SWIFT_HALF: &str = "Color(red: 0, green: 0, blue: 0, opacity: 0.5)";
|
||||
const SWIFT_GRADIENT: &str = "LinearGradient(stops: [Gradient.Stop(color: Color(red: 0, green: 0, blue: 0, opacity: 0.2), location: 0), Gradient.Stop(color: Color(red: 0, green: 0, blue: 0, opacity: 0), location: 1)], startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 1))";
|
||||
|
||||
#[test]
|
||||
fn swiftui_writes_every_fill_top_first() {
|
||||
let mut s = Session::new();
|
||||
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), f, CodeLang::SwiftUi);
|
||||
let backgrounds: Vec<&str> = code.lines().map(str::trim).filter(|l| l.starts_with(".background(")).collect();
|
||||
let want = [format!(".background({SWIFT_HALF})"), format!(".background({SWIFT_GRADIENT})"), format!(".background({SWIFT_WHITE})")];
|
||||
assert_eq!(backgrounds, want.iter().map(String::as_str).collect::<Vec<_>>(), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swiftui_shapes_fill_with_the_bottom_paint_and_overlay_the_rest() {
|
||||
let mut s = Session::new();
|
||||
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), r, CodeLang::SwiftUi);
|
||||
let mods: Vec<&str> = code.lines().map(str::trim).filter(|l| l.starts_with('.')).collect();
|
||||
let want = [
|
||||
format!(".fill({SWIFT_WHITE})"),
|
||||
format!(".overlay(RoundedRectangle(cornerRadius: 8).fill({SWIFT_GRADIENT}))"),
|
||||
format!(".overlay(RoundedRectangle(cornerRadius: 8).fill({SWIFT_HALF}))"),
|
||||
".frame(width: 200, height: 120)".to_string(),
|
||||
];
|
||||
assert_eq!(mods, want.iter().map(String::as_str).collect::<Vec<_>>(), "{code}");
|
||||
assert!(!code.contains(".background("), "{code}");
|
||||
let mut half = figcraft_color::Paint::solid(Color::BLACK);
|
||||
half.opacity = 0.5;
|
||||
let e = fill_layer(&mut s, "ellipse", 120.0, vec![figcraft_color::Paint::solid(Color::WHITE), half]);
|
||||
let code = code_for(s.doc().unwrap(), e, CodeLang::SwiftUi);
|
||||
assert!(code.contains(&format!(".overlay(Ellipse().fill({SWIFT_HALF}))")), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_writes_every_fill_bottom_first() {
|
||||
let mut s = Session::new();
|
||||
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), f, CodeLang::Compose);
|
||||
let want = "Modifier.size(200.dp, 120.dp).background(Color(0xFFFFFFFF), RectangleShape).background(Brush.linearGradient(0f to Color(0x33000000), 1f to Color(0x00000000), start = Offset(0f, 0f), end = Offset(0f, Float.POSITIVE_INFINITY)), RectangleShape).background(Color(0x80000000), RectangleShape)";
|
||||
assert!(code.contains(want), "{code}");
|
||||
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), r, CodeLang::Compose);
|
||||
assert_eq!(code.matches(".background(").count(), 3, "{code}");
|
||||
assert_eq!(code.matches(", RoundedCornerShape(8.dp))").count(), 3, "{code}");
|
||||
assert!(code.contains(".background(Color(0xFFFFFFFF), RoundedCornerShape(8.dp)).background(Brush."), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_nests_a_box_per_fill_bottom_outermost() {
|
||||
let mut s = Session::new();
|
||||
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), f, CodeLang::Flutter);
|
||||
let want = "DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFFFFF),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(begin: Alignment(0, -1), end: Alignment(0, 1), colors: [Color(0x33000000), Color(0x00000000)], stops: [0, 1]),
|
||||
),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
),
|
||||
)
|
||||
)
|
||||
)";
|
||||
assert_eq!(code, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_keeps_the_shadow_under_every_fill() {
|
||||
let mut s = Session::new();
|
||||
let f = fill_layer(&mut s, "frame", 120.0, three_fills());
|
||||
s.execute("effect.add", &json!({"ids": [f], "type": "dropShadow"})).unwrap();
|
||||
let code = code_for(s.doc().unwrap(), f, CodeLang::Flutter);
|
||||
assert_eq!(code.matches("boxShadow:").count(), 1, "{code}");
|
||||
let (shadow, gradient) = (code.find("boxShadow:").unwrap(), code.find("gradient:").unwrap());
|
||||
assert!(shadow < gradient, "the shadow is on the outermost box: {code}");
|
||||
let r = fill_layer(&mut s, "rectangle", 120.0, three_fills());
|
||||
let code = code_for(s.doc().unwrap(), r, CodeLang::Flutter);
|
||||
assert_eq!(code.matches("borderRadius: BorderRadius.circular(8)").count(), 3, "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_gradients_are_written_as_gradients() {
|
||||
use figcraft_color::{Gradient, GradientKind};
|
||||
let mut s = Session::new();
|
||||
let radial = one_gradient(&mut s, Gradient::default_for(GradientKind::Radial, Color::BLACK));
|
||||
let doc = s.doc().unwrap();
|
||||
let sw = code_for(doc, radial, CodeLang::SwiftUi);
|
||||
assert!(sw.contains("RadialGradient(stops: ["), "{sw}");
|
||||
assert!(sw.contains("center: UnitPoint(x: 0.5, y: 0.5), startRadius: 0, endRadius: 60)"), "{sw}");
|
||||
let kt = code_for(doc, radial, CodeLang::Compose);
|
||||
assert!(kt.contains(".background(Brush.radialGradient(0f to Color(0xFF000000), 1f to Color(0x00000000)), RectangleShape)"), "{kt}");
|
||||
let dart = code_for(doc, radial, CodeLang::Flutter);
|
||||
assert!(
|
||||
dart.contains(
|
||||
"gradient: RadialGradient(center: Alignment(0, 0), radius: 0.5, colors: [Color(0xFF000000), Color(0x00000000)], stops: [0, 1])"
|
||||
),
|
||||
"{dart}"
|
||||
);
|
||||
let angular = one_gradient(&mut s, Gradient::default_for(GradientKind::Angular, Color::BLACK));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(code_for(doc, angular, CodeLang::SwiftUi).contains("angle: .degrees(90))"));
|
||||
assert!(code_for(doc, angular, CodeLang::Compose).contains("Brush.sweepGradient("));
|
||||
assert!(code_for(doc, angular, CodeLang::Flutter).contains("transform: GradientRotation(1.57))"));
|
||||
// Compose snaps a linear gradient to one of eight directions.
|
||||
let mut directed = |from: (f64, f64), to: (f64, f64)| {
|
||||
let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK);
|
||||
(g.from, g.to) = (from, to);
|
||||
let id = one_gradient(&mut s, g);
|
||||
code_for(s.doc().unwrap(), id, CodeLang::Compose)
|
||||
};
|
||||
let up = directed((0.5, 1.0), (0.5, 0.0));
|
||||
assert!(up.contains("start = Offset(0f, Float.POSITIVE_INFINITY), end = Offset(0f, 0f)"), "{up}");
|
||||
let right = directed((0.0, 0.5), (1.0, 0.5));
|
||||
assert!(right.contains("end = Offset(Float.POSITIVE_INFINITY, 0f)"), "{right}");
|
||||
let corner = directed((0.0, 0.0), (1.0, 1.0));
|
||||
assert!(corner.contains("end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)"), "{corner}");
|
||||
// A single stop is written twice, since a brush needs two colours.
|
||||
let mut one = Gradient::default_for(GradientKind::Linear, Color::BLACK);
|
||||
one.stops.truncate(1);
|
||||
let id = one_gradient(&mut s, one);
|
||||
let kt = code_for(s.doc().unwrap(), id, CodeLang::Compose);
|
||||
assert_eq!(kt.matches(" to Color(").count(), 2, "{kt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_solid_fill_is_unchanged_in_native_code() {
|
||||
let mut hidden = figcraft_color::Paint::solid(Color::BLACK);
|
||||
hidden.visible = false;
|
||||
let mut s = Session::new();
|
||||
for fills in [vec![figcraft_color::Paint::solid(Color::WHITE)], vec![figcraft_color::Paint::solid(Color::WHITE), hidden]] {
|
||||
let f = fill_layer(&mut s, "frame", 100.0, fills);
|
||||
let doc = s.doc().unwrap();
|
||||
let sw = code_for(doc, f, CodeLang::SwiftUi);
|
||||
let backgrounds: Vec<&str> = sw.lines().map(str::trim).filter(|l| l.starts_with(".background(")).collect();
|
||||
assert_eq!(backgrounds, [format!(".background({SWIFT_WHITE})")], "{sw}");
|
||||
assert!(!sw.contains("Gradient"), "{sw}");
|
||||
let kt = code_for(doc, f, CodeLang::Compose);
|
||||
assert!(kt.contains("Modifier.size(200.dp, 100.dp).background(Color(0xFFFFFFFF), RectangleShape))"), "{kt}");
|
||||
assert!(!kt.contains("Brush"), "{kt}");
|
||||
let dart = code_for(doc, f, CodeLang::Flutter);
|
||||
assert!(
|
||||
dart.starts_with("Container(\n width: 200,\n height: 100,\n decoration: BoxDecoration(\n color: Color(0xFFFFFFFF),\n ),\n)"),
|
||||
"{dart}"
|
||||
);
|
||||
assert!(!dart.contains("DecoratedBox"), "{dart}");
|
||||
assert!(code_for(doc, f, CodeLang::ArkUi).contains(".backgroundColor('#FFFFFF')"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::swift::one_line;
|
||||
use super::{
|
||||
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
|
||||
CodeLang, Color, Ctx, GradientKind, LayoutMode, Node, NodeId, NodeKind, PADDING, Paint, PaintKind, drop_shadow, first_fill, gradient_axis,
|
||||
gradient_stops, indent, literal, named, native_gradient_kind, num, radius, stroke_color, visible_fills,
|
||||
};
|
||||
use figcraft_doc::Document;
|
||||
|
||||
@@ -22,14 +23,66 @@ fn bound_dp(doc: &Document, n: &Node, fields: &[&str], value: f64) -> String {
|
||||
bound(doc, n, fields, format!("{}.dp", num(value)))
|
||||
}
|
||||
|
||||
/// One end of a linear brush's axis along one direction: `sign` is 0 for none, 1 for with the
|
||||
/// axis and -1 against it. `Float.POSITIVE_INFINITY` stands for the far edge, which a modifier
|
||||
/// can't know in pixels.
|
||||
fn compose_edge(sign: i8) -> (&'static str, &'static str) {
|
||||
match sign {
|
||||
0 => ("0f", "0f"),
|
||||
s if s > 0 => ("0f", "Float.POSITIVE_INFINITY"),
|
||||
_ => ("Float.POSITIVE_INFINITY", "0f"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A direction's sign: its component counts as none when it is at most tan 22.5 degrees of the other.
|
||||
fn compose_sign(v: f64, other: f64) -> i8 {
|
||||
if v.abs() <= 0.4142 * other.abs() {
|
||||
0
|
||||
} else if v > 0.0 {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient fill as a Compose `Brush` for a `w` x `h` layer. A linear gradient snaps to the
|
||||
/// nearest of eight directions; radial and angular ones sit at the box's centre.
|
||||
fn compose_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
|
||||
let stops = gradient_stops(g, opacity).into_iter().map(|(at, c)| format!("{}f to {}", num(at), kotlin_color(c))).collect::<Vec<_>>().join(", ");
|
||||
match native_gradient_kind(g) {
|
||||
GradientKind::Linear => {
|
||||
let (dx, dy) = gradient_axis(g, w, h);
|
||||
let (sx, sy) = if dx == 0.0 && dy == 0.0 { (0, 1) } else { (compose_sign(dx, dy), compose_sign(dy, dx)) };
|
||||
let ((x0, x1), (y0, y1)) = (compose_edge(sx), compose_edge(sy));
|
||||
format!("Brush.linearGradient({stops}, start = Offset({x0}, {y0}), end = Offset({x1}, {y1}))")
|
||||
}
|
||||
GradientKind::Angular => format!("Brush.sweepGradient({stops})"),
|
||||
_ => format!("Brush.radialGradient({stops})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One paint (the one at document index `i`) as the first argument of `background`; `None` for an
|
||||
/// image or pattern. Only a solid at index 0 takes the `fill` variable's name.
|
||||
fn compose_paint(doc: &Document, n: &Node, i: usize, p: &Paint) -> Option<String> {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } => {
|
||||
let c = kotlin_color(p.solid_color()?);
|
||||
Some(if i == 0 { bound(doc, n, &["fill"], c) } else { c })
|
||||
}
|
||||
PaintKind::Gradient(g) => Some(compose_gradient(g, n.width, n.height, p.opacity)),
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
|
||||
fn modifier(doc: &Document, n: &Node, shape: &str, padding: bool) -> String {
|
||||
let mut m = format!("Modifier.size({}, {})", bound_dp(doc, n, &["width"], n.width), bound_dp(doc, n, &["height"], n.height));
|
||||
if let Some(s) = drop_shadow(n) {
|
||||
m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
|
||||
}
|
||||
if let Some(c) = first_fill(n) {
|
||||
m.push_str(&format!(".background({}, {shape})", bound(doc, n, &["fill"], kotlin_color(c))));
|
||||
// Each background draws and lets the rest of the chain draw over it: bottom fill first.
|
||||
for b in visible_fills(n).filter_map(|(i, p)| compose_paint(doc, n, i, p)) {
|
||||
m.push_str(&format!(".background({b}, {shape})"));
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
m.push_str(&format!(
|
||||
|
||||
@@ -2,8 +2,9 @@ use figcraft_doc::AutoLayout;
|
||||
|
||||
use super::swift::one_line;
|
||||
use super::{
|
||||
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
|
||||
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
|
||||
CodeLang, CrossAlign, Ctx, GradientKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Paint, PaintKind, Param,
|
||||
Sizing, TextAlignH, TextDecoration, drop_shadow, first_fill, gradient_axis, gradient_stops, kotlin_color, literal, native_gradient_kind, num,
|
||||
radius, stroke_color, visible_fills,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Flutter (Dart)
|
||||
@@ -74,22 +75,72 @@ fn flutter_padding(n: &Node) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A gradient's colours and stops, the tail of every Flutter gradient constructor.
|
||||
fn flutter_colors(g: &figcraft_color::Gradient, opacity: f32) -> String {
|
||||
let stops = gradient_stops(g, opacity);
|
||||
let colors = stops.iter().map(|(_, c)| kotlin_color(*c)).collect::<Vec<_>>().join(", ");
|
||||
let at = stops.iter().map(|(at, _)| num(*at)).collect::<Vec<_>>().join(", ");
|
||||
format!("colors: [{colors}], stops: [{at}]")
|
||||
}
|
||||
|
||||
/// A point of the unit box as Flutter's `Alignment`, which runs from -1 to 1.
|
||||
fn flutter_alignment(p: (f64, f64)) -> String {
|
||||
format!("Alignment({}, {})", num(2.0 * p.0 - 1.0), num(2.0 * p.1 - 1.0))
|
||||
}
|
||||
|
||||
/// A gradient fill on a layer of `w` x `h` as a Flutter gradient.
|
||||
fn flutter_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
|
||||
let (dx, dy) = gradient_axis(g, w, h);
|
||||
let (tail, from) = (flutter_colors(g, opacity), flutter_alignment(g.from));
|
||||
match native_gradient_kind(g) {
|
||||
GradientKind::Linear => format!("LinearGradient(begin: {from}, end: {}, {tail})", flutter_alignment(g.to)),
|
||||
GradientKind::Angular => format!("SweepGradient(center: {from}, {tail}, transform: GradientRotation({}))", num(dy.atan2(dx))),
|
||||
_ => {
|
||||
// Flutter's radius is a fraction of the box's shorter side.
|
||||
let (reach, side) = (dx.hypot(dy), w.min(h));
|
||||
let radius = if reach > 0.0 && side > 0.0 { reach / side } else { 0.5 };
|
||||
format!("RadialGradient(center: {from}, radius: {}, {tail})", num(radius))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One paint as a `BoxDecoration` part (`color: ...` or `gradient: ...`); `None` for an image or
|
||||
/// pattern, which no native target writes yet.
|
||||
fn flutter_paint(n: &Node, p: &Paint) -> Option<String> {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } => Some(format!("color: {}", kotlin_color(p.solid_color()?))),
|
||||
PaintKind::Gradient(g) => Some(format!("gradient: {}", flutter_gradient(g, n.width, n.height, p.opacity))),
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a layer is written as a `Container`: the layers with a box to paint.
|
||||
fn paints_a_box(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Rectangle | NodeKind::Group) || n.kind.is_frame_like()
|
||||
}
|
||||
|
||||
/// A layer's fills as `BoxDecoration` parts, bottom first.
|
||||
fn flutter_fill_parts(n: &Node) -> Vec<String> {
|
||||
visible_fills(n).filter_map(|(_, p)| flutter_paint(n, p)).collect()
|
||||
}
|
||||
|
||||
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
|
||||
/// none of them. `pad` is the indentation of the constructor itself.
|
||||
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
|
||||
/// none of them. `pad` is the indentation of the constructor itself; `fill` is the one fill part
|
||||
/// this decoration paints, and `stroke` and `shadow` say whether it carries those.
|
||||
fn box_decoration(n: &Node, pad: &str, fill: Option<&str>, stroke: bool, shadow: bool) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(c) = first_fill(n) {
|
||||
parts.push(format!("color: {}", kotlin_color(c)));
|
||||
if let Some(f) = fill {
|
||||
parts.push(f.to_string());
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Ellipse { .. }) {
|
||||
parts.push("shape: BoxShape.circle".into());
|
||||
} else if radius(n) > 0.0 {
|
||||
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
if let Some(c) = stroke_color(n).filter(|_| stroke) {
|
||||
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
|
||||
}
|
||||
if let Some(s) = drop_shadow(n) {
|
||||
if let Some(s) = drop_shadow(n).filter(|_| shadow) {
|
||||
parts.push(format!(
|
||||
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
|
||||
kotlin_color(s.color),
|
||||
@@ -105,6 +156,42 @@ fn box_decoration(n: &Node, pad: &str) -> Option<String> {
|
||||
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
|
||||
}
|
||||
|
||||
/// What a `Container` paints itself: its own fill (the top one), and whether it carries the shadow
|
||||
/// (only when no `DecoratedBox` does).
|
||||
struct Painted {
|
||||
fill: Option<String>,
|
||||
shadow: bool,
|
||||
}
|
||||
|
||||
/// Opens a `DecoratedBox` for each fill under the top one, the bottom fill outermost, and returns
|
||||
/// what is left for the `Container`. The outermost box carries the drop shadow: a decoration paints
|
||||
/// its shadow first, so on the `Container` it would lie over the lower fills.
|
||||
fn flutter_fill_boxes(n: &Node, depth: &mut usize, close: &mut Vec<String>, out: &mut String) -> Painted {
|
||||
let mut parts = flutter_fill_parts(n);
|
||||
let top = parts.pop();
|
||||
let shadow = parts.is_empty();
|
||||
for (at, part) in parts.iter().enumerate() {
|
||||
let (pad, inner) = (flutter_indent(*depth), flutter_indent(*depth + 1));
|
||||
if close.is_empty() {
|
||||
out.push_str(&pad);
|
||||
}
|
||||
let deco = box_decoration(n, &inner, Some(part), false, at == 0).unwrap_or_default();
|
||||
out.push_str(&format!("DecoratedBox(\n{inner}decoration: {deco},\n{inner}child: "));
|
||||
close.push(format!("\n{pad})"));
|
||||
*depth += 1;
|
||||
}
|
||||
Painted { fill: top, shadow }
|
||||
}
|
||||
|
||||
/// The call an instance below the root is (an instance swap parameter is a widget); `None` for
|
||||
/// every other layer.
|
||||
fn flutter_call(ctx: &Ctx, n: &Node, depth: usize) -> Option<String> {
|
||||
match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
|
||||
Some(p) => Some(p.ident.clone()),
|
||||
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
|
||||
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
|
||||
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
|
||||
@@ -150,9 +237,13 @@ fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Pl
|
||||
close.push(format!("\n{pad})"));
|
||||
depth += 1;
|
||||
}
|
||||
// An instance is a call, made once: it gets no fill boxes, so this is its final depth.
|
||||
let call = flutter_call(ctx, n, depth);
|
||||
let painted =
|
||||
if call.is_none() && paints_a_box(n) { flutter_fill_boxes(n, &mut depth, &mut close, out) } else { Painted { fill: None, shadow: true } };
|
||||
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
|
||||
let mut body = String::new();
|
||||
flutter_widget(ctx, n, depth, &mut body);
|
||||
flutter_widget(ctx, n, depth, call, &painted, &mut body);
|
||||
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
|
||||
for c in close.iter().rev() {
|
||||
out.push_str(c);
|
||||
@@ -161,13 +252,8 @@ fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Pl
|
||||
|
||||
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
|
||||
/// closing parenthesis sits at `depth`.
|
||||
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, call: Option<String>, painted: &Painted, out: &mut String) {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
// Nested instances are calls; an instance swap parameter is a widget.
|
||||
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
|
||||
Some(p) => Some(p.ident.clone()),
|
||||
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
out.push_str(call.trim_end_matches('\n'));
|
||||
return;
|
||||
@@ -216,7 +302,7 @@ fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
}
|
||||
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
|
||||
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
|
||||
if let Some(d) = box_decoration(n, &inner) {
|
||||
if let Some(d) = box_decoration(n, &inner, painted.fill.as_deref(), true, painted.shadow) {
|
||||
out.push_str(&format!("{inner}decoration: {d},\n"));
|
||||
}
|
||||
out.push_str(&format!("{pad})"));
|
||||
@@ -232,7 +318,7 @@ fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
if !padded_inside && let Some(p) = flutter_padding(n) {
|
||||
out.push_str(&format!("{inner}padding: {p},\n"));
|
||||
}
|
||||
if let Some(d) = box_decoration(n, &inner) {
|
||||
if let Some(d) = box_decoration(n, &inner, painted.fill.as_deref(), true, painted.shadow) {
|
||||
out.push_str(&format!("{inner}decoration: {d},\n"));
|
||||
}
|
||||
if children.is_empty() && slot.is_none() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::{
|
||||
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
|
||||
CodeLang, Color, Ctx, Document, GradientKind, LayoutMode, Node, NodeId, NodeKind, PADDING, Paint, PaintKind, drop_shadow, first_fill,
|
||||
gradient_axis, gradient_stops, indent, literal, named, native_gradient_kind, num, radius, stroke_color, visible_fills,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ SwiftUI
|
||||
@@ -8,6 +9,47 @@ fn swift_color(c: Color) -> String {
|
||||
format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a)))
|
||||
}
|
||||
|
||||
fn swift_point(p: (f64, f64)) -> String {
|
||||
format!("UnitPoint(x: {}, y: {})", num(p.0), num(p.1))
|
||||
}
|
||||
|
||||
/// A gradient fill on a layer of `w` x `h` as a SwiftUI gradient.
|
||||
fn swift_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
|
||||
let stops = gradient_stops(g, opacity)
|
||||
.into_iter()
|
||||
.map(|(at, c)| format!("Gradient.Stop(color: {}, location: {})", swift_color(c), num(at)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let (dx, dy) = gradient_axis(g, w, h);
|
||||
match native_gradient_kind(g) {
|
||||
GradientKind::Linear => format!("LinearGradient(stops: [{stops}], startPoint: {}, endPoint: {})", swift_point(g.from), swift_point(g.to)),
|
||||
GradientKind::Angular => {
|
||||
format!("AngularGradient(stops: [{stops}], center: {}, angle: .degrees({}))", swift_point(g.from), num(dy.atan2(dx).to_degrees()))
|
||||
}
|
||||
_ => format!("RadialGradient(stops: [{stops}], center: {}, startRadius: 0, endRadius: {})", swift_point(g.from), num(dx.hypot(dy))),
|
||||
}
|
||||
}
|
||||
|
||||
/// One paint (the one at document index `i`) as SwiftUI; `None` for an image or pattern, which no
|
||||
/// native target writes yet. Only a solid at index 0 takes the `fill` variable's name.
|
||||
fn swift_paint(doc: &Document, n: &Node, i: usize, p: &Paint) -> Option<String> {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } => {
|
||||
let c = swift_color(p.solid_color()?);
|
||||
Some(if i == 0 { named(doc, n, "iOS", &["fill"], c) } else { c })
|
||||
}
|
||||
PaintKind::Gradient(g) => Some(swift_gradient(g, n.width, n.height, p.opacity)),
|
||||
PaintKind::Image(_) | PaintKind::Pattern(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The modifiers filling a rectangle or ellipse: the bottom paint fills the `shape`, and each paint
|
||||
/// above it is a copy of the shape laid over it.
|
||||
fn swift_shape_fills(doc: &Document, n: &Node, shape: &str) -> Vec<String> {
|
||||
let paints = visible_fills(n).filter_map(|(i, p)| swift_paint(doc, n, i, p));
|
||||
paints.enumerate().map(|(at, c)| if at == 0 { format!(".fill({c})") } else { format!(".overlay({shape}.fill({c}))") }).collect()
|
||||
}
|
||||
|
||||
fn swift_weight(w: u16) -> &'static str {
|
||||
match w {
|
||||
0..=150 => ".thin",
|
||||
@@ -84,16 +126,12 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
}
|
||||
NodeKind::Ellipse { .. } => {
|
||||
out.push_str(&format!("{pad}Ellipse()\n"));
|
||||
if let Some(c) = &fill {
|
||||
mods.push(format!(".fill({c})"));
|
||||
}
|
||||
mods.extend(swift_shape_fills(doc, n, "Ellipse()"));
|
||||
mods.push(format!(".frame(width: {w}, height: {h})"));
|
||||
}
|
||||
NodeKind::Rectangle => {
|
||||
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {corner})\n"));
|
||||
if let Some(c) = &fill {
|
||||
mods.push(format!(".fill({c})"));
|
||||
}
|
||||
mods.extend(swift_shape_fills(doc, n, &format!("RoundedRectangle(cornerRadius: {corner})")));
|
||||
mods.push(format!(".frame(width: {w}, height: {h})"));
|
||||
}
|
||||
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
|
||||
@@ -142,9 +180,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
}
|
||||
}
|
||||
mods.push(format!(".frame(width: {w}, height: {h}, alignment: .topLeading)"));
|
||||
if let Some(c) = &fill {
|
||||
mods.push(format!(".background({c})"));
|
||||
}
|
||||
// Each background goes behind the ones before it, so the top fill is written first.
|
||||
mods.extend(visible_fills(n).rev().filter_map(|(i, p)| swift_paint(doc, n, i, p)).map(|c| format!(".background({c})")));
|
||||
if radius(n) > 0.0 {
|
||||
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {corner}))"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
@@ -63,7 +64,11 @@ fn polygon_and_star_handles() {
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let hs = handles(&doc, &[star], 1.0);
|
||||
let kinds: Vec<ShapeHandle> = hs.iter().map(|h| h.0).collect();
|
||||
assert_eq!(kinds, vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio], "a star has a radius and a ratio handle");
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio, ShapeHandle::Count],
|
||||
"a star has a radius, a ratio and a count handle"
|
||||
);
|
||||
// The ratio handle sits on the first inner point; dragging it to the centre's side shrinks it.
|
||||
let ratio_at = hs[1].1;
|
||||
let inner = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[1];
|
||||
@@ -78,7 +83,7 @@ fn polygon_and_star_handles() {
|
||||
assert!(radii(&s, star)[0] > 1.0, "the star's points round");
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 1, "a polygon has one radius handle");
|
||||
assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 2, "a polygon has a radius and a count handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -100,3 +105,100 @@ fn handles_show_only_where_they_can_be_used() {
|
||||
s.execute("vector.edit.begin", &json!({"id": r})).unwrap();
|
||||
assert!(s.shape_handles().is_empty(), "none while editing points");
|
||||
}
|
||||
|
||||
fn count_of(s: &Session, id: NodeId) -> u32 {
|
||||
match s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Polygon { count } | NodeKind::Star { count, .. } => count,
|
||||
_ => panic!("a polygon or star"),
|
||||
}
|
||||
}
|
||||
|
||||
fn count_handle(s: &Session, id: NodeId) -> [f64; 2] {
|
||||
handles(s.doc().unwrap(), &[id], 1.0).into_iter().find(|h| h.0 == ShapeHandle::Count).expect("a count handle").1
|
||||
}
|
||||
|
||||
/// The page point at `deg` degrees clockwise from the shape's own top, `r` from its centre (200 x 200 box).
|
||||
fn at_angle(s: &Session, id: NodeId, deg: f64, r: f64) -> (f64, f64) {
|
||||
let a = deg.to_radians();
|
||||
let p = s.doc().unwrap().world_transform(id) * Point::new(100.0 + r * a.sin(), 100.0 - r * a.cos());
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn count_drag(s: &mut Session, id: NodeId, deg: f64) {
|
||||
let h = count_handle(s, id);
|
||||
let to = at_angle(s, id, deg, 80.0);
|
||||
drag(s, (h[0], h[1]), to, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polygon_has_a_count_handle_on_its_first_vertex_clockwise_from_the_top() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
let h = count_handle(&s, poly);
|
||||
let v1 = figcraft_geom::polygon_points(200.0, 200.0, 5)[1];
|
||||
assert!((Point::new(h[0], h[1]) - v1).hypot() < 1e-6, "on vertex 1, not inset: {h:?} vs {v1:?}");
|
||||
assert!((h[0] - (100.0 + 100.0 * (TAU / 5.0).sin())).abs() < 1e-6, "72 degrees from the top");
|
||||
// Hovering it finds it, not the radius handle.
|
||||
assert_eq!(hit(s.doc().unwrap(), &[poly], Point::new(h[0], h[1]), 1.0).map(|x| x.1), Some(ShapeHandle::Count));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8), (120.0, 3), (0.5, 60), (0.0, 60), (200.0, 3)] {
|
||||
count_drag(&mut s, poly, deg);
|
||||
assert_eq!(count_of(&s, poly), want, "{deg} degrees");
|
||||
}
|
||||
// The radius from the centre doesn't matter.
|
||||
let h = count_handle(&s, poly);
|
||||
let to = at_angle(&s, poly, 90.0, 30.0);
|
||||
drag(&mut s, (h[0], h[1]), to, false);
|
||||
assert_eq!(count_of(&s, poly), 4, "90 degrees near the centre");
|
||||
// The readout is the count.
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(crate::shape_handles::value(doc, poly, ShapeHandle::Count), Some(4.0));
|
||||
// A pointer on the centre has no direction: it gives the most points.
|
||||
let h = count_handle(&s, poly);
|
||||
drag(&mut s, (h[0], h[1]), (100.0, 100.0), false);
|
||||
assert_eq!(count_of(&s, poly), 60, "a pointer on the centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_stars_count_handle_sets_its_points() {
|
||||
let mut s = Session::new();
|
||||
let star = create(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let h = count_handle(&s, star);
|
||||
let outer = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[2];
|
||||
assert!((Point::new(h[0], h[1]) - outer).hypot() < 1e-6, "on its first outer point clockwise from the top");
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8), (130.0, 3), (1.0, 60)] {
|
||||
count_drag(&mut s, star, deg);
|
||||
assert_eq!(count_of(&s, star), want, "{deg} degrees");
|
||||
}
|
||||
let NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
|
||||
assert!((inner_ratio - 0.38).abs() < 0.02, "the ratio stays through count drags: {inner_ratio}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_drag_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
count_drag(&mut s, poly, 45.0);
|
||||
assert_eq!(count_of(&s, poly), 8);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(count_of(&s, poly), 5, "one Ctrl+Z takes the drag back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_polygons_count_angle_is_measured_in_its_own_frame() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5, "rotation": 30}})).unwrap();
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8)] {
|
||||
count_drag(&mut s, poly, deg);
|
||||
assert_eq!(count_of(&s, poly), want, "{deg} degrees in the shape's frame");
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,8 @@ impl<'d> Exporter<'d> {
|
||||
/// The editable network (with per-point corner radii, handle mirroring and end caps) and,
|
||||
/// when it has filled areas, their outline.
|
||||
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
|
||||
if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) {
|
||||
self.warn("vector region paints were left out of the .fig export");
|
||||
if net.regions.iter().any(|region| region.paint_space.is_some()) {
|
||||
self.warn("vector region paint frames were left out (their gradients and images follow the layer's box)");
|
||||
}
|
||||
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
|
||||
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
|
||||
@@ -260,11 +260,19 @@ impl<'d> Exporter<'d> {
|
||||
tb: (finite(sg.tb.0), finite(sg.tb.1)),
|
||||
})
|
||||
.collect();
|
||||
let regions = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| network::Region { style: 0, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() })
|
||||
.collect();
|
||||
// A region with paints of its own (an empty list included) names a style entry that
|
||||
// holds them; one without writes style 0 and shows the layer's fills.
|
||||
let mut regions = Vec::with_capacity(net.regions.len());
|
||||
for r in &net.regions {
|
||||
let style = match &r.fills {
|
||||
Some(paints) => {
|
||||
let list = self.paints(paints, true, &Bound::default());
|
||||
style_of(vec![("fillPaints", list)])
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
regions.push(network::Region { style, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() });
|
||||
}
|
||||
let blob = network::encode(&network::Network { vertices, segments, regions });
|
||||
let table = styles.into_iter().zip(1u32..).map(|(mut st, id)| {
|
||||
st.insert(0, ("styleID", u(id)));
|
||||
|
||||
@@ -843,21 +843,38 @@ fn round_trip_keeps_the_details() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_warns_when_region_paints_are_left_out() {
|
||||
let mut doc = generate(3);
|
||||
let Some(id) = doc.nodes().find_map(|node| match &node.kind {
|
||||
NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("generated document has no filled vector");
|
||||
};
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = Some(Vec::new());
|
||||
fn vector_region_paints_round_trip() {
|
||||
let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
|
||||
let mut doc = Document::new("regions");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut two = VectorNetwork::from_points(&square, true);
|
||||
let again = two.regions[0].clone();
|
||||
two.regions.push(again);
|
||||
let mut half = Paint::solid(Color { r: 0.25, g: 0.5, b: 0.75, a: 1.0 });
|
||||
half.opacity = 0.5;
|
||||
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }));
|
||||
two.regions[0].fills = Some(vec![half, gradient]);
|
||||
two.regions[1].fills = Some(Vec::new());
|
||||
let id = doc.create(page, NodeKind::Vector { network: two }, "Painted", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
doc.node_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let inherit = VectorNetwork::from_points(&square, true);
|
||||
let id2 = doc.create(page, NodeKind::Vector { network: inherit }, "Inherits", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
doc.node_mut(id2).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
let (back, warnings) = round_trip(&doc, "region paints");
|
||||
assert!(!warnings.iter().any(|w| w.contains("region")), "{warnings:?}");
|
||||
let regions_of = |d: &Document, name: &str| match d.nodes().find(|n| n.name == name).map(|n| &n.kind) {
|
||||
Some(NodeKind::Vector { network }) => network.regions.clone(),
|
||||
k => panic!("{name}: {k:?}"),
|
||||
};
|
||||
let painted = regions_of(&back, "Painted");
|
||||
assert_eq!(painted.len(), 2);
|
||||
let fills = painted[0].fills.as_ref().expect("the first region keeps its paints");
|
||||
assert_eq!(fills.len(), 2);
|
||||
assert!(matches!(&fills[0].kind, PaintKind::Solid { .. }) && (fills[0].opacity - 0.5).abs() < 1e-6);
|
||||
assert!(matches!(&fills[1].kind, PaintKind::Gradient(_)));
|
||||
assert_eq!(painted[1].fills, Some(Vec::new()), "an unfilled region stays unfilled");
|
||||
assert_eq!(regions_of(&back, "Inherits")[0].fills, None, "a region with no paints of its own still shows the layer's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -875,16 +892,18 @@ fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
|
||||
let region = network.regions.first_mut().unwrap();
|
||||
region.fills = None;
|
||||
region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
let text = "vector region paint frames were left out (their gradients and images follow the layer's box)";
|
||||
|
||||
let (_, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|warning| warning == text), "{warnings:?}");
|
||||
|
||||
let mut plain = doc;
|
||||
let node = plain.node_mut(id).unwrap();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].paint_space = None;
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
let (_, warnings) = export(&plain, None).unwrap();
|
||||
assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|warning| warning.contains("region paint")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -359,6 +359,28 @@ fn vector_region_missing_and_empty_paints_remain_distinct() {
|
||||
assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_fills_survive_a_fig_export() {
|
||||
let solid = o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(1.0))])), ("opacity", f(0.5))]);
|
||||
let gradient = o(vec![
|
||||
("type", e("GRADIENT_LINEAR")),
|
||||
(
|
||||
"stops",
|
||||
arr(vec![
|
||||
o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]),
|
||||
o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(1.0))]))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let (doc, _) = import(®ion_canvas(Some(vec![solid, gradient]), 1), "Region fills").unwrap();
|
||||
let first = network_of(&doc, "Wave").regions[0].fills.clone();
|
||||
assert!(first.as_ref().is_some_and(|fills| fills.len() == 2), "{first:?}");
|
||||
let (bytes, warnings) = crate::fig::export::export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|warning| warning.contains("region")), "{warnings:?}");
|
||||
let (back, _) = import(&bytes, "Again").unwrap();
|
||||
assert_eq!(network_of(&back, "Wave").regions[0].fills, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
|
||||
let linked = solid(0.9, 0.1, 0.2);
|
||||
|
||||
@@ -701,6 +701,7 @@ fn shape_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
|
||||
let text = match hint.handle {
|
||||
ShapeHandle::Radius { .. } => format!("{} {}", tr("Radius"), hint.value),
|
||||
ShapeHandle::Ratio => format!("{} {}%", tr("Ratio"), hint.value),
|
||||
ShapeHandle::Count => format!("{} {}", tr("Count"), hint.value),
|
||||
};
|
||||
pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, text, col);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dev Mode parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** patch (CSS code export writes several fills as one layered `background`, top first, as the incumbent does (#2271): a solid above another fill becomes a one-colour gradient, the bottom solid stays a plain colour, Tailwind gets one `[background:...]` class; 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)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** patch (SwiftUI, Compose and Flutter code write every visible colour and gradient fill of a frame, group, rectangle or ellipse in paint order, gradients as gradients, instead of one colour per layer (chained `.background` in SwiftUI, chained `Modifier.background` in Compose, a `DecoratedBox` for each fill under the top one in Flutter); CSS code export writes several fills as one layered `background`, top first, as the incumbent does (#2271): a solid above another fill becomes a one-colour gradient, the bottom solid stays a plain colour, Tailwind gets one `[background:...]` class; 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),
|
||||
@@ -10,10 +10,12 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | patch | SwiftUI, Compose and Flutter code export kept one colour per layer (the bottom visible solid, or a gradient's first stop) and dropped every other fill (#2271). Now each writes every visible solid and gradient fill of a frame, group, rectangle or ellipse, in paint order, and a gradient as a gradient. SwiftUI chains `.background(...)` with the top fill first, since each one goes behind the ones before it (a rectangle or ellipse fills with the bottom paint and lays a copy of the shape over it for each paint above). Compose chains `Modifier.background(...)` with the bottom fill first, since each one draws over the ones before it (a linear gradient snaps to eight directions, radial and angular ones sit at the box's centre). Flutter keeps the top fill in the `Container` and wraps each fill under it in a `DecoratedBox`, the bottom fill outermost and the drop shadow on the outermost box. A layer with one solid fill gives the same code as before; a lone gradient is now a gradient, not its first stop's colour. Still not written: image and pattern fills in the native targets, ArkUI (one background and one gradient per component, so a stack needs extra nodes) and text with several fills. The incumbent's iOS and Android output wasn't measured: the order follows how each platform paints. Percentages and hours unchanged |
|
||||
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
|
||||
| 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, several fills as one layered `background` (top first), 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) |
|
||||
| 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); SwiftUI, Compose and Flutter write every colour and gradient fill of a layer in paint order, gradients as gradients (chained `.background` in SwiftUI, top fill first; chained `Modifier.background` in Compose, bottom fill first, a linear gradient snapped to eight directions and radial and angular ones at the box's centre; in Flutter a `DecoratedBox` for each fill under the top one); the native targets don't write image or pattern fills yet, and ArkUI and text still take one colour (#2271) |
|
||||
| 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 | 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) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.fig` export writes text on a path (#2323); `.jam` import: shapes with text come in as native board shapes (#1808); `.fig` export writes image adjustments (#2297); `.fig` import: a numbered list keeps its start only where it starts counting (#2273); `.fig` export writes a page's prototype background (#2326); `.fig` export writes list spacing, hanging punctuation and lists, and vertical trim (#2305); `.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where iLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.fig` export writes vector region paints (#2302); `.fig` export writes text on a path (#2323); `.jam` import: shapes with text come in as native board shapes (#1808); `.fig` export writes image adjustments (#2297); `.fig` import: a numbered list keeps its start only where it starts counting (#2273); `.fig` export writes a page's prototype background (#2326); `.fig` export writes list spacing, hanging punctuation and lists, and vertical trim (#2305); `.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the Line truncated
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -81,8 +81,9 @@ visible and opaque (a page also its background opacity): a reader takes what is
|
||||
draws nothing below a hidden page. The message
|
||||
lists one node change per layer, parents first, each with its GUID, its parent and a position
|
||||
string that orders it among its siblings; vector networks and outlines go in blobs, written once
|
||||
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
|
||||
source paint coordinates.
|
||||
each. `.fig` export writes a vector's region paint stacks (each filled area's own paints as an entry of the
|
||||
network's style table, named by the area's style id); a region's kept paint frame is left out with a
|
||||
warning; import and editing preserve their source paint coordinates.
|
||||
|
||||
Scaled instances retain an explicit override when a length is reset to the unscaled main's value,
|
||||
rather than losing it to the field diff. This covers corners, strokes, layout and grid lengths,
|
||||
@@ -93,6 +94,8 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
- **Written:** pages (name, background, prototype background as an opaque colour, for every page); frames, groups, sections, rectangles (as rounded
|
||||
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
|
||||
network blobs with per-point corner radius, handle mirroring, caps and joins, each filled area's own paints, plus the outline of
|
||||
their filled areas), text, boolean groups, slices; the fill outline of frames and shapes and the
|
||||
network blobs with per-point corner radius, handle mirroring, caps and joins, plus the outline of
|
||||
their filled areas), text, text on a path (the first part of its path, where the text starts and its side), boolean groups, slices; the fill outline of frames and shapes and the
|
||||
stroke outline of plain strokes (a ring around boxes and whole ellipses, the widened path of other
|
||||
@@ -133,7 +136,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
- **Left out, with a warning each:** colour calculations that resolve to nothing (an operand
|
||||
missing, or more than 32 variables to read), variable bindings the format has no field for (component
|
||||
properties, arc fields, font weight: written as the values they give), text styles on part of a
|
||||
text (written as values), variables' publishing, code syntax and inheritance settings, extended
|
||||
text (written as values), the paint frames of vector regions, variables' publishing, code syntax and inheritance settings, extended
|
||||
collections (written as collections of their own), comments, Developer Mode annotations, statuses,
|
||||
resources, measurements and code links, plugin data, ruler
|
||||
guides, a page's prototype device settings (device, rotation, colour, custom size, responsive), the opacity of a page's prototype background (written opaque), slot properties and slot content, brush
|
||||
@@ -195,6 +198,7 @@ scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | M6, #2302: the `.fig` export writes the paints of a vector's own regions: each region with paints of its own (an empty list means unfilled) names an entry of the network's style table (`styleOverrideTable[].fillPaints`), equal lists share one entry, and a region without paints writes style 0. The fill outline is still one path with style 0. A region's kept paint frame is still left out, with a narrower warning. The importer already read this; not checked in other software |
|
||||
| 2026-11-12 | minor | M6, #2323: the `.fig` export writes text on a path as the format's own layer kind (`TEXT_PATH`): the first part of its path as a vector network of one open or closed chain with the segment the text starts on marked, the start as that segment's own parameter (`textPathStart.tValue`, found by inverse arc length) and the side as `forward`. Vertical alignment is written as top, which is how text on a path draws. The importer already read all of it; a closed path comes back starting at the marked segment, the same curve and start point. Text with more than one line and paths the format can't hold stay plain text, the parts of a path after the first are left out, and text on a path in an instance but not in its main component (or the other way round) is written as the main has it, each with its own warning. The field numbers come from the schema of a synthetic file saved from Figma's web app. Checked through the importer only, not in other software |
|
||||
| 2026-11-12 | minor | M6, #2297: the `.fig` exporter writes an image paint's adjustments as `paintFilter` (seven fields: exposure, contrast, vibrance from saturation, temperature, tint, highlights, shadows; the tint's sign is flipped, as the importer reads it), and no longer warns about them; the importer already read them. Not checked in other software |
|
||||
| 2026-11-12 | patch | M6, #2273: `.fig` import: a numbered list keeps its start only on the item where it starts counting. Figma writes the start on every line of the list (measured in Figma's web app, 2026-11-12, from a saved .fig: a list started at 5 has `listStartOffset` 4 on all its lines, and after Backspace takes a middle item out of the list, the item after it starts a new list and is written with 0), so reading every line's value restarted the items after a split at the old start. The export is unchanged and a re-import now equals what went in; REST `listStartOffsets` read the same way |
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 13: a boolean group's corner radius (#2222); gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (gap 1: the `.fig` export writes vector region paints (#2302); gap 13: a boolean group's corner radius (#2222); gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
@@ -87,8 +87,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
layer budgets; the issue sample was checked locally only. Native
|
||||
board metadata and direct `.jam` opening (#1342) remain separate work.
|
||||
- **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path
|
||||
operations retain them with their source paint coordinates. `.fig` export still warns and drops
|
||||
region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`.
|
||||
operations retain them with their source paint coordinates. `.fig` export writes them since
|
||||
2026-11-12 (#2302); a region's kept paint frame is left out, with a warning. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`.
|
||||
- **Fixed (2026-10-29, #1236):** imported text keeps the line breaks the file saved with it
|
||||
(`derivedTextData`), so a font whose advances differ slightly from the file's (another build
|
||||
of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The
|
||||
@@ -595,6 +595,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | Gap 1: the `.fig` export writes vector region paints (#2302); the 2026-10-26 "Landed" bullet no longer says the export drops them |
|
||||
| 2026-11-10 | minor | Gap 13: a boolean group's corner radius rounds every sharp corner of its result (#2222); the boolean clause leaves the 2026-10-12 "Left" list |
|
||||
| 2026-11-10 | minor | Gap 24: mind maps, first step (`board.mindMap`, `board.mindMapAdd`; the map lays itself out again after every edit, #2226); the toolbar entry, shortcuts, drag to re-parent and `.jam` import are what is missing of them. |
|
||||
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a double-click on a layer's icon zooms to it instead of renaming, measured in Figma's web app on 2026-11-12; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); 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 marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a double-click on a layer's icon zooms to it instead of renaming, measured in Figma's web app on 2026-11-12; M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); 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 marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub sectionLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -95,7 +95,7 @@ interaction feel only partly compared: the interactions measured so far, case by
|
||||
| Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | the middle mouse button and a mouse's side buttons (4 and 5) pan as Space does (#1758) |
|
||||
| Frame (presets panel, swap orientation), section, slice | done | |
|
||||
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
|
||||
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
|
||||
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; a Count handle sits on a polygon's first vertex or a star's first outer point clockwise from the top, and the count follows the pointer's angle about the centre, `round(360 / angle)` clockwise from the top point, clamped to 3 to 60, in one undo step (measured in Figma's web app, 2026-11-12; a polygon's maximum wasn't read and is clamped at 60 like the star's, the help center's range); corner smoothing rounds rectangles and frames only |
|
||||
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a mirror mode on a sharp point turns it smooth by the measured rule (#2110); a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers |
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
@@ -332,4 +332,5 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
|
||||
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
|
||||
| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
|
||||
| 2026-11-12 | minor | M8: a selected polygon or star has a Count handle on the canvas (hover label Count N), measured in Figma's web app on 2026-11-12: it sits on the first vertex clockwise from the top (a star's first outer point, not inset), and a drag sets the count to round(360 / the pointer's clockwise angle from the top point about the centre), so 90 degrees gives 4, 72 gives 5 and 45 gives 8, with the step shrinking as the count grows; one drag is one undo step. The count is clamped to 3 to 60 (the help center's range for a star; a polygon's maximum was not read). Tests: `a_polygon_has_a_count_handle_on_its_first_vertex_clockwise_from_the_top`, `dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre`, `a_count_drag_is_one_undo_step` |
|
||||
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
|
||||
Reference in new issue
Block a user