Merge main into #2292

This commit is contained in:
bflatastic
2026-11-13 22:47:47 +07:00
19 changed files with 316 additions and 37 deletions
+78
View File
@@ -276,6 +276,31 @@ pub enum TextDirection {
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// For each paragraph, whether it is an item where a list starts counting. Numbers count the items
/// of one list at one level and restart after a plain paragraph, a shallower item or the other
/// kind of list; the first item of such a run starts counting (and takes its own start number).
/// The layout numbers by this rule.
pub fn list_item_starts_counting(lists: &[ParaList]) -> Vec<bool> {
let mut run = [ListStyle::None; MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
if pl.list == ListStyle::None {
run.fill(ListStyle::None);
return false;
}
let d = usize::from(pl.indent).min(run.len() - 1);
for c in run.iter_mut().skip(d + 1) {
*c = ListStyle::None;
}
let Some(c) = run.get_mut(d) else { return false };
let starts = *c != pl.list;
*c = pl.list;
starts
})
.collect()
}
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -937,6 +962,24 @@ impl TextData {
self.fold_paragraph_lists();
}
/// Keep a start number only on the item where a numbered list starts counting, and give every
/// item that goes on counting 0 (the rule the layout numbers by, [`list_item_starts_counting`]).
/// An imported file may carry the list's start on every line; once the list is split, the
/// items after the split must restart at 1, not at the old start.
pub fn keep_list_starts_where_counting_starts(&mut self) {
if self.paragraphs.iter().all(|p| p.start_offset == 0) {
return;
}
self.fill_paragraphs();
let starts = list_item_starts_counting(&self.paragraphs);
for (pl, starts) in self.paragraphs.iter_mut().zip(starts) {
if !starts {
pl.start_offset = 0;
}
}
self.fold_paragraph_lists();
}
/// One style for all paragraphs with no nesting or start folds back into `list`.
fn fold_paragraph_lists(&mut self) {
if let Some(first) = self.paragraphs.first().copied()
@@ -1251,6 +1294,41 @@ mod case_tests {
t.clear_list_starts(0, 0);
assert_eq!(t.para_list(0).start_offset, 0);
}
#[test]
fn list_starts_stay_only_where_counting_starts() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let pl = |list, indent, start_offset| ParaList { list, indent, start_offset, ..ParaList::default() };
// (paragraphs, the starts kept, derivation)
let cases: Vec<(Vec<ParaList>, Vec<u32>, &str)> = vec![
(vec![pl(N, 0, 4), pl(N, 0, 4), pl(N, 0, 4)], vec![4, 0, 0], "items that go on counting lose theirs"),
(vec![pl(N, 0, 4), pl(P, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "a list after a plain paragraph keeps its own"),
(vec![pl(N, 0, 4), pl(B, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "and one after a bullet item"),
(
vec![pl(N, 0, 4), pl(N, 1, 2), pl(N, 1, 2), pl(N, 0, 4)],
vec![4, 2, 0, 0],
"a nested level keeps its first item's; the outer count goes on",
),
(vec![pl(N, 0, 1), pl(N, 1, 2), pl(N, 0, 9), pl(N, 1, 3)], vec![1, 2, 0, 3], "a shallower item starts the deeper count again"),
(vec![pl(N, 255, 4), pl(N, 9, 4), pl(N, 8, 4)], vec![4, 0, 0], "levels past the deepest count as the deepest"),
];
for (paragraphs, want, why) in cases {
let characters = vec!["x"; paragraphs.len()].join("\n");
let mut t = TextData { characters, paragraphs, ..TextData::new("") };
t.keep_list_starts_where_counting_starts();
let got: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(got, want, "{why}");
}
// No start anywhere: nothing is spelled out.
let mut t = TextData { list: N, ..TextData::new("a\nb") };
t.keep_list_starts_where_counting_starts();
assert!(t.paragraphs.is_empty());
// More entries than paragraphs, and an empty last paragraph.
let mut t = TextData { paragraphs: vec![pl(N, 0, 4); 5], ..TextData::new("a\n") };
t.keep_list_starts_where_counting_starts();
assert_eq!((t.para_list(0).start_offset, t.para_list(1).start_offset), (4, 0));
assert!(list_item_starts_counting(&[]).is_empty());
}
}
#[cfg(test)]
+2 -2
View File
@@ -8,7 +8,7 @@ use figcraft_doc::{Document, NodeId};
use figcraft_geom::Point;
use crate::cmd::edit::roots;
use crate::tools::{SNAP_PX, selection_box};
use crate::tools::{UNMEASURED_SNAP_PX, selection_box};
use crate::{DocState, EngineError, Result, Session};
/// How near (screen pixels) a press must be to grab the origin handle.
@@ -115,7 +115,7 @@ impl Session {
let snapped = box_points(&ds.doc, &ds.selection).and_then(|b| {
b.iter()
.enumerate()
.filter(|(_, q)| (**q - p).hypot() * zoom.max(1e-6) <= SNAP_PX)
.filter(|(_, q)| (**q - p).hypot() * zoom.max(1e-6) <= UNMEASURED_SNAP_PX)
.min_by(|x, y| (*x.1 - p).hypot().total_cmp(&(*y.1 - p).hypot()))
.map(|(i, q)| (i, *q))
});
+3 -3
View File
@@ -9,7 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
use serde::Serialize;
use crate::guides;
use crate::tools::SNAP_PX;
use crate::tools::UNMEASURED_SNAP_PX;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle; `dashed` marks a measurement's extension line.
@@ -438,14 +438,14 @@ impl Targets {
}
/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along
/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s
/// its own axis, within `UNMEASURED_SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s
/// coordinates, or `None` when nothing is in reach.
pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
// The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one),
// which the owner's transform may scale: convert the screen-pixel reach into owner units.
let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs();
let axis = if vertical { a.hypot(b) } else { c.hypot(d) };
let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6));
let threshold = UNMEASURED_SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6));
Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite())
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub use handles::{
PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point,
selection_box, selection_frame, shared_rotation_degrees,
};
pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box};
pub(crate) use handles::{SNAP_PX, UNMEASURED_SNAP_PX, handle_cursor, readable_text_fills, resized_box};
#[cfg(test)]
pub(crate) use pen::pressure_profile;
+7 -2
View File
@@ -71,8 +71,13 @@ const HANDLE_PX: f64 = 5.0;
const ROTATE_PX: f64 = 18.0;
/// Snap distance in screen pixels.
pub(crate) const SNAP_PX: f64 = 5.0;
/// Object snap distance in screen pixels: measured in Figma, where an edge snaps at a gap of 4.4
/// screen px and not at 4.6, at 100% and at 200% zoom.
pub(crate) const SNAP_PX: f64 = 4.5;
/// Snap distance in screen pixels for ruler guides and the rotation origin, not measured against
/// Figma.
pub(crate) const UNMEASURED_SNAP_PX: f64 = 5.0;
/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a
/// double-click made with an unsteady hand doesn't drag what it lands on.
+38
View File
@@ -506,6 +506,44 @@ mod tests {
assert!(!css.contains("#000000 0%"), "{css}");
}
/// The measured frame: white at the bottom, a 20% gradient above it, 50% black on top.
fn layered_frame(s: &mut Session) -> figcraft_doc::NodeId {
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"]).unwrap();
let mut g = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK));
g.opacity = 0.2;
let mut top = figcraft_color::Paint::solid(Color::BLACK);
top.opacity = 0.5;
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(Color::WHITE), g, top];
f
}
#[test]
fn several_fills_are_one_layered_background_top_first() {
let mut s = Session::new();
let f = layered_frame(&mut s);
let css = code_for(s.doc().unwrap(), f, CodeLang::Css);
let bg: Vec<&str> = css.lines().filter(|l| l.starts_with("background:")).collect();
assert_eq!(bg.len(), 1, "{css}");
let v = bg[0];
assert!(
v.starts_with(
"background: linear-gradient(0deg, rgba(0, 0, 0, 0.50), rgba(0, 0, 0, 0.50)), linear-gradient(180deg, rgba(0, 0, 0, 0.20) 0%, "
),
"{v}"
);
assert!(v.ends_with(", #FFFFFF;"), "{v}");
}
#[test]
fn one_solid_fill_stays_a_plain_colour() {
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(Color::WHITE)];
let css = code_for(s.doc().unwrap(), f, CodeLang::Css);
assert!(css.contains("background: #FFFFFF;\n"), "{css}");
assert!(!css.contains("gradient"), "{css}");
}
#[test]
fn swiftui_and_compose_for_an_auto_layout_card() {
let mut s = Session::new();
+41 -10
View File
@@ -160,17 +160,48 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
put("transform", format!("rotate({}deg)", num(-rot)));
put("transform-origin", "top left".into());
}
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
let c = color.with_alpha(color.a * p.opacity).to_css();
// A variable binds the first paint's colour.
put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
if matches!(n.kind, NodeKind::Text(_)) {
// Text: a solid is the ink (`color`), one per fill, so the last (top) one wins.
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
match &p.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * p.opacity).to_css();
// A variable binds the first paint's colour.
put("color", if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
}
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)),
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)),
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
} else {
// One `background` with every visible fill as a layer, top first (a document's fills run
// bottom to top, as the renderer paints them). CSS allows a plain colour only as the last
// layer, so a solid above another fill becomes a one-colour gradient.
let visible: Vec<(usize, &figcraft_color::Paint)> = n.fills.iter().enumerate().filter(|(_, p)| p.visible).collect();
let layers: Vec<String> = visible
.iter()
.enumerate()
.rev()
.map(|(pos, (i, p))| match &p.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * p.opacity).to_css();
if pos > 0 {
format!("linear-gradient(0deg, {c}, {c})")
} else if *i == 0 {
// A variable binds the first paint's colour.
tok(doc, n, &["fill"], c, t)
} else {
c
}
}
PaintKind::Gradient(g) => css_gradient(g, n.width, n.height, p.opacity),
PaintKind::Image(_) => "url(image.png) center / cover".into(),
PaintKind::Pattern(_) => "url(pattern.png) repeat".into(),
})
.collect();
if !layers.is_empty() {
put("background", layers.join(", "));
}
}
for (key, value) in css_borders(doc, n, t) {
+19
View File
@@ -105,6 +105,23 @@ fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// Whether `v` has a comma outside any parentheses (a multi-layer `background`).
fn layered(v: &str) -> bool {
let mut depth = 0usize;
v.chars().any(|c| match c {
'(' => {
depth += 1;
false
}
')' => {
depth = depth.saturating_sub(1);
false
}
',' => depth == 0,
_ => false,
})
}
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
fn var_name(v: &str) -> Option<&str> {
@@ -270,6 +287,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
_ => one(util("opacity", "", v, "")),
},
// Several fills are one layered `background`: commas between the layers, outside any call.
("background", v) if layered(v) => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
+3 -3
View File
@@ -196,9 +196,9 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
e
};
s.pointer(&ev(PointerKind::Down, 30.0)).unwrap();
// Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original
// Alt duplicates: the copy, dragged to 109..159, snaps to equal spacing between the original
// and the other layer (105..155).
s.pointer(&ev(PointerKind::Move, 130.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 129.0)).unwrap();
let g = s.tool.overlay.guides.clone();
assert_eq!(
labelled(&g),
@@ -209,7 +209,7 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
("45".into(), [155, 50, 200, 50])
]
);
s.pointer(&ev(PointerKind::Up, 130.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 129.0)).unwrap();
assert!(s.tool.overlay.guides.is_empty());
}
+16
View File
@@ -358,6 +358,22 @@ fn font_family_with_quote_and_bracket_stays_a_valid_class_token() {
assert!(!inner.is_empty() && !inner.contains(['\\', '"', ']']), "{out}");
}
#[test]
fn several_fills_are_one_layered_background_class() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 120})));
let mut g =
figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK));
g.opacity = 0.2;
let mut top = figcraft_color::Paint::solid(figcraft_color::Color::BLACK);
top.opacity = 0.5;
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE), g, top];
let out = code(&mut s, f, json!({}));
let class = out.split(['"', ' ']).find(|c| c.starts_with("[background:")).unwrap_or_default();
assert!(class.starts_with("[background:linear-gradient(0deg,_rgba(0,_0,_0,_0.50),_rgba(0,_0,_0,_0.50)),_linear-gradient(180deg"), "{out}");
assert!(class.ends_with(",_#FFFFFF]"), "{out}");
}
/// Issue #2272: a desktop `page` 1440 wide and a mobile one 390 wide, each holding a fixed 48 x 48
/// `badge` and a `banner` that fills the page.
fn badge_frames(s: &mut Session) -> (NodeId, NodeId) {
+26
View File
@@ -482,3 +482,29 @@ fn a_many_line_paste_clears_every_created_start() {
assert_eq!(d.para_list(0).start_offset, 4, "the first item keeps its start");
assert!((1..d.paragraph_count()).all(|p| d.para_list(p).start_offset == 0 && d.para_list(p).list == N));
}
#[test]
fn a_list_back_from_a_fig_split_in_the_middle_restarts_at_one() {
// #2273's steps: type "5. a", Enter, "b", Enter, "c"; save a copy as .fig and open it;
// Backspace at the start of "b". The export writes the start on every line, as Figma does,
// and "c" must then read 1., not 5.
let mut s = Session::new();
three_typed_items(&mut s, "5. a");
crate::textedit::end(&mut s).unwrap();
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
let t = back.nodes().find(|n| n.kind.text().is_some()).map(|n| n.id).unwrap();
let mut s = Session::with_document(back);
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0); 3]);
assert_eq!(
(d.para_list(0).start_offset, d.para_list(1).start_offset, d.para_list(2).start_offset),
(4, 0, 0),
"only the first item carries the start"
);
begin(&mut s, t, Some(2), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0), (P, 0), (N, 0)]);
assert_eq!((d.para_list(0).start_offset, d.para_list(2).start_offset), (4, 0), "c reads 1.");
}
@@ -747,6 +747,27 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
}
#[test]
fn a_layer_edge_snaps_within_4_5_screen_pixels_at_any_zoom() {
// Measured in Figma (2026-11-08): a dragged edge snaps at a gap of 4.4 screen px and not at 4.6,
// at 100% and at 200% zoom.
for (zoom, gap_px, snaps) in [(1.0, 4.4, true), (1.0, 4.8, false), (2.0, 4.4, true), (2.0, 4.8, false)] {
let mut s = Session::new();
ex(&mut s, "view.snapPixel", json!({"on": false}));
rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
let b = rect(&mut s, 300.0, 300.0, 100.0, 100.0, None);
ex(&mut s, "select.set", json!({"ids": [b]}));
let gap = gap_px / zoom;
let to = Point::new(350.0 - (200.0 - gap), 350.0);
for (kind, p) in [(PointerKind::Down, Point::new(350.0, 350.0)), (PointerKind::Move, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::at(kind, p.x, p.y, zoom)).unwrap();
}
let want = if snaps { 100.0 } else { 100.0 + gap };
let got = bounds(&s, b).x0;
assert!(near(got, want), "zoom {zoom}, gap {gap_px} screen px: left edge at {got}, wanted {want}");
}
}
#[test]
fn snapping_to_layers_wins_over_the_pixel_grid() {
// Both on (the defaults): a layer's centre at x = 5.5 is still a target.
+20 -2
View File
@@ -64,7 +64,7 @@ fn numbered_lists_keep_their_start() {
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 0), (N, 0)], "Figma writes the start on every line; only the first counts");
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
@@ -89,5 +89,23 @@ fn the_export_writes_where_a_list_starts() {
// line or per list counts the same); bullets and plain lines carry none.
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
assert_eq!(starts(&back, "List"), [(N, 1), (N, 0), (P, 0), (N, 5), (B, 0)], "reads back as it went in");
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
use ListStyle::{None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "Three", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("ORDERED_LIST", 4, false), line("ORDERED_LIST", 4, false)]),
text_node(3, "Split", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 0, true)]),
text_node(4, "Two lists", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 6, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "Three"), [(N, 4), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Split"), [(N, 4), (P, 0), (N, 0)]);
assert_eq!(starts(&doc, "Two lists"), [(N, 4), (P, 0), (N, 6)], "a second list keeps its own start");
}
+20 -1
View File
@@ -675,7 +675,11 @@ fn kitchen_sink() -> Value {
]},
{"id": "k9", "name": "Sec", "type": "SECTION", "sectionContentsHidden": true, "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 50},
"children": [{"id": "k10", "name": "Use", "type": "INSTANCE", "componentId": "3:1", "relativeTransform": at(0.0, 0.0), "size": {"x": 24, "y": 24},
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]}
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]},
{"id": "k11", "name": "Listed", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc\nd\ne\n",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED", "UNORDERED", "ORDERED", "ORDERED"], "lineIndentations": [0, 0, 1, 0, 300, 0],
"listStartOffsets": [4, 4, 3, 2, 7, 9]}
]);
if let (Some(kids), Some(more)) = (v.pointer_mut("/document/children/0/children").and_then(Value::as_array_mut), extra.as_array()) {
kids.extend(more.iter().cloned());
@@ -692,6 +696,9 @@ fn kitchen_sink_imports_cleanly() {
assert!(matches!(named(&doc, "Sec").kind, NodeKind::Section { contents_hidden: true }));
assert!(matches!(&named(&doc, "Use").kind, NodeKind::Instance { props, .. } if props.get("Show") == Some(&json!(false))));
assert!(matches!(named(&doc, "Txt").kind, NodeKind::Text(ref t) if t.list == ListStyle::Numbered && t.runs.len() == 3));
let NodeKind::Text(l) = &named(&doc, "Listed").kind else { panic!("not text") };
let got: Vec<(u8, u32)> = (0..l.paragraph_count()).map(|p| (l.para_list(p).indent, l.para_list(p).start_offset)).collect();
assert_eq!(got, [(0, 4), (0, 0), (1, 3), (0, 0), (8, 7), (0, 9)], "starts stay where a count starts; the level is capped");
assert!(warnings.iter().all(|w| !w.contains("panick")), "{warnings:?}");
}
@@ -801,3 +808,15 @@ fn an_arc_without_an_end_is_whole_wherever_it_starts() {
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
}
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
let (doc, _) = one(json!([
{"id": "t1", "name": "Every", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED"], "listStartOffsets": [4, 4, 4]}
]));
let figcraft_doc::NodeKind::Text(t) = &named(&doc, "Every").kind else { panic!("not text") };
let offsets: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(offsets, [4, 0, 0]);
}
+3
View File
@@ -283,6 +283,9 @@ fn lists(v: &Value, td: &mut TextData) {
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
td.set_para_lists(0, td.char_count(), |_| {});
// Only the item where a numbered list starts counting keeps its start; Figma writes the start on
// every line of the list, and a list split later must restart at 1.
td.keep_list_starts_where_counting_starts();
}
/// The decoration's style, thickness, offset, skip ink and colour (`{value, unit}` lengths with
+10 -8
View File
@@ -1469,25 +1469,27 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
// "Figma bullets will keep the same styling at all levels of indentation." (Create bulleted
// and numbered lists)
const BULLET: &str = "\u{2022}";
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
let starts = figcraft_doc::list_item_starts_counting(lists);
let mut count = [0usize; figcraft_doc::MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
.zip(starts)
.map(|(pl, starts)| {
if pl.list == ListStyle::None {
count.fill((ListStyle::None, 0));
count.fill(0);
return String::new();
}
let d = usize::from(pl.indent).min(count.len() - 1);
for c in count.iter_mut().skip(d + 1) {
*c = (ListStyle::None, 0);
*c = 0;
}
let Some(c) = count.get_mut(d) else { return String::new() };
let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1);
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
*c = if starts { first } else { c.saturating_add(1) };
match (pl.list, d % 3) {
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
(ListStyle::Numbered, _) => format!("{}.", c.1),
(ListStyle::Numbered, 1) => format!("{}.", alpha(*c)),
(ListStyle::Numbered, 2) => format!("{}.", roman(*c)),
(ListStyle::Numbered, _) => format!("{}.", *c),
_ => BULLET.to_string(),
}
})
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **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)
**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),
@@ -13,7 +13,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| 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, 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, 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) |
| 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) |
@@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | patch | CSS code export (#2271): a layer with several fills wrote one `background` per fill, so only the last survived. It now writes one `background` with every visible fill as a layer, top fill first, comma-separated: a solid above another fill is `linear-gradient(0deg, c, c)` (CSS allows a plain colour only as the last layer), the bottom solid stays a plain colour (with its variable), images and patterns keep their `url(...)` layers. Tailwind output carries the layered value as one `[background:...]` class. Text keeps one `color` per fill. Native targets unchanged. Percentages and hours unchanged |
| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -74,7 +74,7 @@ interaction feel only partly compared: the interactions measured so far, case by
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it 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), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `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`. |
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; the object snap threshold is 4.5 screen pixels, measured against Figma (snapped at a gap of 4.4 screen px, not at 4.6, at 100% and 200% zoom, 2026-11-08; the equal-spacing snap shares it), but the ruler-guide and rotation-origin thresholds (5 px), the red guide's look and snapping with Snap to pixel grid off are not yet compared with Figma's (`guide_snap_tests`, `transform_behaviour_tests`) |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) |
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) |
@@ -215,6 +215,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | patch | Smart guides: the object snap threshold is 4.5 screen pixels, measured against Figma (a dragged edge snapped at a gap of 4.4 screen px and not at 4.6, at 100% and at 200% zoom, Snap to pixel grid on); FigCraft used 5. Equal spacing shares the move's threshold so it follows; ruler-guide and rotation-origin snapping keep 5, unmeasured (`transform_behaviour_tests`) |
| 2026-11-11 | patch | M4, #2110: in vector edit mode, choosing Mirror angle or Mirror angle and length for a sharp point (two segments, no handles) makes it smooth, as measured in Figma: both handles on one line through the point, along `(B - A) / \|BA\|^2 + (C - B) / \|BC\|^2` (A and C the far ends of its segments), each 0.3905 x its own segment for angle, both 0.3905 x the shorter segment for angle and length (the factor is known to about three digits; the length rule is medium confidence); several selected points each become smooth. Open-path ends, points that already have a handle, points with a coincident neighbour, and points whose two far ends coincide (A = C, so the direction is zero) keep the old behaviour. Evidence: tests in `crates/engine/src/canvas/vectoredit/ops.rs` and `tests.rs` |
| 2026-11-12 | patch | M8: Tab while renaming the last layer in the Layers panel commits and opens the first row's rename, as measured in Figma's web app on 2026-11-12 (it ended the rename before); Shift+Tab on the first row is unchanged, not measured; Tab and Shift+Tab now move only the rename and leave the selection on the layer first renamed, as measured in Figma's web app on 2026-11-12 (it followed the rename before) (`tab_on_the_last_row_commits_and_wraps_to_the_first`, `tab_while_renaming_leaves_the_selection_on_the_first_layer`), Rename row |
| 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` |