Merge upstream/main into feat/tidy-up-figma-rule

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor Brown
2026-11-13 10:38:49 -05:00
co-authored by Claude Sonnet 5.5
34 changed files with 600 additions and 35 deletions
+7 -2
View File
@@ -16,6 +16,11 @@ concurrency:
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
# Pin the coverage setup that checks out this repo's pinned craft-i18n data.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@ea24c9c24e1997238334182160f60e602e5bce32
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.
shards: 1
fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
secrets: inherit
Generated
+1 -1
View File
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280"
dependencies = [
"chrono",
"dunce",
+1 -1
View File
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+119
View File
@@ -124,9 +124,62 @@ pub fn move_handles(net: &mut VectorNetwork, hs: &[(usize, bool)], d: Vec2, inde
}
}
/// Each handle of a sharp point turned smooth by a mirror mode is this fraction of the length of its
/// own segment. Measured in the incumbent (0.3905 and 0.390 at two angles): known to about 3 digits only.
const MIRROR_SMOOTH_FACTOR: f64 = 0.3905;
/// Give a sharp point (two segments, no handles) the handles a mirror mode makes in the incumbent.
/// Both lie on one line through the point, along `(B - A) / |BA|^2 + (C - B) / |BC|^2` (A, C: the far
/// ends of the two segments). "Angle": each is [`MIRROR_SMOOTH_FACTOR`] x its own segment.
/// "Angle and length": both are that factor x the shorter segment (medium confidence: the
/// measurement could not separate this from other rules). Open-path ends, points with a handle
/// already and coincident neighbours are left alone. Returns whether handles were made.
fn smooth_sharp_point(net: &mut VectorNetwork, vi: usize, mode: HandleMirroring) -> bool {
if mode == HandleMirroring::None {
return false;
}
let inc = net.incident(vi);
let [(s1, a1), (s2, a2)] = inc.as_slice() else { return false };
let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2)) else { return false };
if tvec(h1).hypot() > 1e-9 || tvec(h2).hypot() > 1e-9 {
return false;
}
let far = |s: usize, st: bool| net.segments.get(s).and_then(|x| net.vertices.get(if st { x.b } else { x.a })).map(|v| v.point());
let (Some(b), Some(a), Some(c)) = (net.vertices.get(vi).map(|v| v.point()), far(*s1, *a1), far(*s2, *a2)) else { return false };
let (ab, bc) = (b - a, c - b);
let (la, lc) = (ab.hypot(), bc.hypot());
if !(la > 1e-9 && lc > 1e-9 && la.is_finite() && lc.is_finite()) {
return false;
}
let d = ab / (la * la) + bc / (lc * lc);
if !finite(d) || d.hypot() < 1e-12 {
return false;
}
let d = d.normalize();
let (l_in, l_out) = if mode == HandleMirroring::AngleAndLength {
let l = MIRROR_SMOOTH_FACTOR * la.min(lc);
(l, l)
} else {
(MIRROR_SMOOTH_FACTOR * la, MIRROR_SMOOTH_FACTOR * lc)
};
let (in_h, out_h) = (-d * l_in, d * l_out);
if !(finite(in_h) && finite(out_h)) {
return false;
}
net.set_handle(*s1, *a1, (in_h.x, in_h.y));
net.set_handle(*s2, *a2, (out_h.x, out_h.y));
true
}
/// Set the mirroring of vertices and make their handles agree with it.
pub fn set_mirroring(net: &mut VectorNetwork, verts: &[usize], mode: HandleMirroring) {
for &vi in verts {
if smooth_sharp_point(net, vi, mode) {
if let Some(v) = net.vertices.get_mut(vi) {
v.mirror = Some(mode);
}
continue;
}
let inc = net.incident(vi);
if let [(s1, a1), (s2, a2)] = inc.as_slice()
&& let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2))
@@ -949,6 +1002,72 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
/// An open path A, B, C (the measured triangle of #2110) with B sharp.
fn triangle_path() -> VectorNetwork {
VectorNetwork::from_points(&[Point::new(-250.0, -174.0), Point::new(117.0, -174.0), Point::new(-67.0, 194.0)], false)
}
fn handles_at_b(n: &VectorNetwork) -> (Vec2, Vec2) {
(tvec(n.segments[0].tb), tvec(n.segments[1].ta))
}
#[test]
fn mirror_modes_smooth_a_sharp_point_as_measured() {
// Mirror angle and length: both handles 0.3905 x the shorter segment (367), opposite.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::AngleAndLength);
let (i, o) = handles_at_b(&n);
assert!((o - Vec2::new(86.24, 114.47)).hypot() < 0.1, "{o:?}");
assert!((i + o).hypot() < 1e-9, "{i:?} {o:?}");
assert!((o.hypot() - 143.3).abs() < 0.1, "{}", o.hypot());
// Mirror angle: each handle 0.3905 x its own segment; still collinear.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
assert!((i - Vec2::new(-86.24, -114.47)).hypot() < 0.1, "{i:?}");
assert!((o - Vec2::new(96.69, 128.33)).hypot() < 0.1, "{o:?}");
assert!((i.x * o.y - i.y * o.x).abs() < 1e-6, "collinear");
assert!((i.hypot() - 143.3).abs() < 0.1 && (o.hypot() - 160.7).abs() < 0.1);
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
}
#[test]
fn mirror_modes_smooth_other_angles_and_several_points() {
// A right angle at B with legs 100 and 50: d = (1,0)/100 + (0,1)/50, so the line leans to +y.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0)], false);
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
let want = Vec2::new(0.01, 0.02).normalize();
assert!((o.normalize() - want).hypot() < 1e-9, "{o:?}");
assert!((o.hypot() - 19.525).abs() < 1e-9 && (i.hypot() - 39.05).abs() < 1e-9, "{i:?} {o:?}");
// Two sharp points of a closed square, both selected: both become smooth.
let mut sq = square();
set_mirroring(&mut sq, &[0, 2], HandleMirroring::AngleAndLength);
for v in [0, 2] {
assert!(sq.incident(v).iter().all(|(s, st)| sq.handle(*s, *st).is_some_and(|h| tvec(h).hypot() > 1.0)), "vertex {v}");
assert_eq!(sq.mirroring(v), HandleMirroring::AngleAndLength);
}
assert!(sq.segments.iter().all(|s| tvec(s.ta).x.is_finite() && tvec(s.tb).y.is_finite()));
}
#[test]
fn mirror_modes_leave_unmeasured_points_alone() {
// Open-path ends have only one segment.
let mut n = open_line();
set_mirroring(&mut n, &[0, 2], HandleMirroring::Angle);
assert!(n.segments.iter().all(|s| s.is_straight()));
// A zero-length neighbour: stays sharp, finite.
let mut z = VectorNetwork::from_points(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0), Point::new(60.0, 0.0)], false);
set_mirroring(&mut z, &[1], HandleMirroring::AngleAndLength);
assert!(z.segments.iter().all(|s| s.is_straight()));
// One handle already: the old behaviour (the lone handle stays, the other mirrors it).
let mut h = open_line();
h.segments[1].ta = (10.0, 5.0);
set_mirroring(&mut h, &[1], HandleMirroring::Angle);
assert_eq!(h.segments[1].ta, (10.0, 5.0));
assert_eq!(h.segments[0].tb, (-10.0, -5.0));
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
@@ -407,6 +407,24 @@ fn mirroring_position_and_join_commands() {
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
}
#[test]
fn mirror_mode_on_sharp_points_is_one_undo_step() {
// #2110: a mirror mode turns sharp points smooth; the whole command undoes in one step.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[-250, -174], [117, -174], [-67, 194]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
ex(&mut s, "vector.edit.mirroring", json!({"mode": "angleAndLength"}));
let n = net(&s, id);
let (i, o) = (n.segments[0].tb, n.segments[1].ta);
assert!((o.0 - 86.24).abs() < 0.1 && (o.1 - 114.47).abs() < 0.1, "{o:?}");
assert!((i.0 + o.0).abs() < 1e-9 && (i.1 + o.1).abs() < 1e-9, "{i:?}");
s.undo().unwrap();
let n = net(&s, id);
assert!(n.segments.iter().all(|x| x.is_straight()), "one undo restores the sharp point");
}
#[test]
fn a_point_dropped_on_another_merges_into_it() {
// #401: an open path's end dragged onto its start closes the path at that point.
+4 -1
View File
@@ -184,7 +184,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.duplicate";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let pos = doc.variables.variables.iter().position(|x| x.id == v || x.name == v).ok_or_else(|| bad(cmd, "no such variable"))?;
// An exact id first, then a name: an earlier variable named like another's id must not be the
// one copied.
let vars = &doc.variables.variables;
let pos = vars.iter().position(|x| x.id == v).or_else(|| vars.iter().position(|x| x.name == v)).ok_or_else(|| bad(cmd, "no such variable"))?;
let mut copy = doc.variables.variables.get(pos).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let taken = |n: &str| doc.variables.variables.iter().any(|x| x.collection == copy.collection && x.name == n);
let name = (1..1000)
@@ -47,6 +47,18 @@ fn rename_describe_scope_and_duplicate() {
assert_eq!(vars.get(&id).unwrap().scopes, brand.scopes);
}
#[test]
fn duplicate_by_id_copies_that_variable_even_when_an_earlier_one_is_named_like_its_id() {
let mut s = Session::new();
tokens(&mut s);
let brand = s.doc().unwrap().variables.get("brand").unwrap().id.clone();
// The first variable in the list takes the later one's id as its name.
ex(&mut s, "variable.update", json!({"variable": "space/sm", "name": brand.clone()}));
let id = ex(&mut s, "variable.duplicate", json!({"variable": brand.clone()}))["id"].as_str().unwrap().to_string();
let vars = &s.doc().unwrap().variables;
assert_eq!(vars.get(&id).unwrap().name, "brand copy");
}
#[test]
fn modes_and_collections_rename_and_delete() {
let mut s = Session::new();
@@ -215,9 +215,12 @@ fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl
fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
// (The earlier mirroring step already turned the selected corners smooth, #2110.)
let a_before = net(s, a).segments;
let b_before = net(s, b).segments;
drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
assert!(net(s, b).segments != b_before, "the bend lands on the layer under the pointer");
assert!(net(s, a).segments == a_before, "other layers keep their segments");
// Select all takes every edited layer's points.
let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
+2
View File
@@ -38,6 +38,8 @@ mod paste_style_tests;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod proto_background_tests;
#[cfg(test)]
mod scale_factor_tests;
#[cfg(test)]
mod slot_tests;
+1 -1
View File
@@ -190,7 +190,7 @@ message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201, Color prototypeBackgroundColor=141
struct Blob byte[] bytes
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
";
+9 -1
View File
@@ -26,7 +26,15 @@ impl<'d> Exporter<'d> {
if !n.guides.is_empty() {
self.warn("ruler guides were left out");
}
if *prototype != Default::default() {
let defaults = figcraft_doc::proto::PrototypeSettings::default();
// Every page writes its colour, so Figma shows the same one (a page without the field shows Figma's own default).
// The colour is written opaque: the record measured has alpha 1, and the import reads the colour as opaque and
// an alpha of 0 as no background.
f.push(("prototypeBackgroundColor", color(prototype.background.with_alpha(1.0))));
if prototype.background.a < 1.0 {
self.warn("a prototype background's opacity was left out");
}
if (figcraft_doc::proto::PrototypeSettings { background: defaults.background, ..prototype.clone() }) != defaults {
self.warn("prototype device settings were left out");
}
self.modes(n, &mut f);
@@ -0,0 +1,113 @@
//! A page's prototype background in `.fig` exports.
use figcraft_doc::color::Color;
use figcraft_doc::proto::{DeviceRotation, PrototypeSettings};
use figcraft_doc::{Document, NodeKind};
use super::kiwi::Kv;
use super::{Archive, decode_canvas, export, import};
fn blue() -> Color {
Color::from_rgb8_const(0x33, 0x66, 0x99)
}
/// The background of the first page, and its device, after exporting and importing `doc`.
fn back(doc: &Document) -> (Color, String, Vec<String>) {
let (bytes, warnings) = export(doc, None).unwrap();
let (imported, _) = import(&bytes, "x").unwrap();
let page = imported.first_page().and_then(|p| imported.node(p)).unwrap();
let NodeKind::Page { prototype, .. } = &page.kind else { panic!("not a page") };
(prototype.background, prototype.device.clone(), warnings)
}
/// The `prototypeBackgroundColor` (r, g, b, a) in the record the export writes for the page of `doc`.
fn written(doc: &Document) -> Option<[f32; 4]> {
let (bytes, _) = export(doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let page = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some("CANVAS")).unwrap();
page.get("prototypeBackgroundColor").map(|c| ["r", "g", "b", "a"].map(|k| c.get(k).and_then(Kv::f64).unwrap() as f32))
}
fn with_prototype(set: impl FnOnce(&mut figcraft_doc::proto::PrototypeSettings)) -> Document {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
if let Some(NodeKind::Page { prototype, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
set(prototype);
}
doc
}
#[test]
fn a_pages_prototype_background_survives_a_fig_export() {
let doc = with_prototype(|p| p.background = blue());
assert_eq!(written(&doc), Some([blue().r, blue().g, blue().b, 1.0]));
let (background, _, warnings) = back(&doc);
assert_eq!(background, blue());
assert!(warnings.iter().all(|w| !w.contains("prototype")), "{warnings:?}");
}
#[test]
fn a_page_with_a_device_keeps_its_background_and_warns_for_the_device() {
let doc = with_prototype(|p| {
p.background = blue();
p.device = "iPhone 17".into();
});
let (background, _, warnings) = back(&doc);
assert_eq!(background.to_hex(), blue().to_hex());
assert!(warnings.iter().any(|w| w.contains("prototype device settings were left out")), "{warnings:?}");
}
#[test]
fn a_default_page_writes_its_prototype_background_so_figma_shows_the_same_colour() {
let doc = with_prototype(|_| {});
let default = PrototypeSettings::default().background;
assert_eq!(default.to_hex(), "1E1E1E");
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]));
let (background, _, warnings) = back(&doc);
assert_eq!(background, default);
assert!(warnings.iter().all(|w| !w.contains("prototype")), "{warnings:?}");
}
#[test]
fn the_schema_numbers_the_prototype_background() {
let s = super::kiwi::Schema::parse(super::export::SCHEMA).unwrap();
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
let id = |f: &str| def("NodeChange").fields.iter().find(|x| x.name == f).unwrap().value;
assert_eq!(id("prototypeBackgroundColor"), 141);
}
#[test]
fn each_device_setting_alone_warns_and_keeps_the_default_background() {
let settings: [fn(&mut PrototypeSettings); 5] = [
|p| p.device = "iPhone 17".into(),
|p| p.rotation = DeviceRotation::Landscape,
|p| p.color = "White".into(),
|p| p.custom_size = [320.0, 480.0],
|p| p.responsive = true,
];
for (i, set) in settings.into_iter().enumerate() {
let doc = with_prototype(set);
let default = PrototypeSettings::default().background;
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]), "setting {i}");
let (_, _, warnings) = back(&doc);
assert!(warnings.iter().any(|w| w == "prototype device settings were left out"), "setting {i}: {warnings:?}");
}
}
#[test]
fn a_prototype_background_is_written_opaque() {
// The import reads an alpha of 0 as no background, and any other alpha as 1.
for alpha in [0.0, 0.5] {
let doc = with_prototype(|p| p.background = blue().with_alpha(alpha));
assert_eq!(written(&doc), Some([blue().r, blue().g, blue().b, 1.0]), "alpha {alpha}");
let (background, _, warnings) = back(&doc);
assert_eq!(background, blue(), "alpha {alpha}");
assert!(warnings.iter().any(|w| w == "a prototype background's opacity was left out"), "alpha {alpha}: {warnings:?}");
}
// The default colour is written opaque too.
let doc = with_prototype(|p| p.background = p.background.with_alpha(0.5));
let default = PrototypeSettings::default().background;
assert_eq!(written(&doc), Some([default.r, default.g, default.b, 1.0]));
}
+1
View File
@@ -522,6 +522,7 @@ name = "English"
"Duo" = "Duo"
"Duplicate" = "Duplicate"
"Duplicate by dragging" = "Duplicate by dragging"
"Duplicate group" = "Duplicate group"
"Duplicate mode" = "Duplicate mode"
"Duplicate page" = "Duplicate page"
"Edit" = "Edit"
@@ -574,6 +574,10 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
start_rename(st, target.clone());
ui.close();
}
if menu_item(ui, tr("Duplicate group"), "") {
duplicate_group(app, &members, &list, g);
ui.close();
}
if menu_item(ui, tr("Delete group"), "") {
let steps: Vec<serde_json::Value> = members
.iter()
@@ -614,6 +618,28 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st:
}
}
/// Copy group `g` (its variables `members`, every mode, scope and setting) as the group named
/// after it plus " 2" (the next free number among the collection's groups `list`), in one undo
/// step. Each copy is `variable.duplicate` then a rename; an alias keeps pointing at its target.
fn duplicate_group(app: &mut FigApp, members: &[&Variable], list: &[String], g: &str) {
let (parent, leaf) = g.rsplit_once('/').map_or(("", g), |(p, l)| (p, l));
let leaf = unique(leaf, |n| {
let full = if parent.is_empty() { n.to_string() } else { format!("{parent}/{n}") };
full == g || list.contains(&full) || members.iter().any(|v| v.name.starts_with(&format!("{full}/")))
});
let copy = if parent.is_empty() { leaf } else { format!("{parent}/{leaf}") };
let prefix = format!("{g}/");
let mut steps: Vec<serde_json::Value> = Vec::new();
for (k, v) in members.iter().filter(|v| v.name.starts_with(&prefix)).enumerate() {
let tag = format!("copy{k}");
steps.push(json!({"command": "variable.duplicate", "params": {"variable": v.id}, "as": tag}));
steps.push(
json!({"command": "variable.update", "params": {"variable": {"$ref": tag}, "name": format!("{copy}/{}", &v.name[prefix.len()..])}}),
);
}
app.run("command.batch", json!({"label": "Duplicate group", "commands": steps}));
}
fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) {
let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
if let Some(id) =
+26 -4
View File
@@ -6,11 +6,13 @@ use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
/// The bounding box of every glyph-like shape painted in one frame of `add`, one per shape, with
/// the pointer at `pointer` (hover-only buttons) when given.
pub(crate) fn painted_rects(pointer: Option<egui::Pos2>, add: impl FnOnce(&mut egui::Ui)) -> Vec<Rect> {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let events = pointer.map(egui::Event::PointerMoved).into_iter().collect();
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
@@ -21,7 +23,27 @@ fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
.collect()
}
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
painted_rects(None, add).into_iter().fold(Rect::NOTHING, |a, b| a.union(b))
}
/// Whether `painted` holds `icon` drawn at the size of a 24 pt `icons::button`'s glyph (16 pt): the
/// reference glyph's shapes, moved by one offset, are all among `painted` (same sizes and spacing,
/// so a three-dot glyph's spread counts too).
pub(crate) fn paints_icon_at_button_size(painted: &[Rect], icon: Icon) -> bool {
let reference = painted_rects(None, |ui| {
icons::button(ui, icon, 24.0, false, "reference");
});
let same = |a: Rect, b: Rect| (a.min - b.min).length() < 0.5 && (a.size() - b.size()).length() < 0.5;
let Some(first) = reference.first() else { return false };
painted.iter().any(|p| {
let shift = p.min - first.min;
reference.iter().all(|r| painted.iter().any(|q| same(*q, r.translate(shift))))
})
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
+28 -2
View File
@@ -180,7 +180,7 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) {
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
let ctx = ui.ctx().clone();
let at = (f.server.as_str(), f.file.as_str());
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
@@ -302,7 +302,7 @@ fn team_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str, web: &str,
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
if crate::right::menu_item(ui, tr("Open"), "") {
open_team(app, server, f);
@@ -427,3 +427,29 @@ pub fn inspect(app: &FigApp) -> Value {
"rename": app.ui.online.rename.as_ref().map(|r| &r.2),
})
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use figcraft_engine::figcraft_collab::message::Access;
/// The menu buttons of Online's file rows and team rows paint their dots at 16 pt (#2238).
#[test]
fn online_row_menus_paint_16pt() {
let t_app = || FigApp::new(figcraft_engine::Session::new(), Box::new(crate::NoServices));
let mut app = t_app();
let file = OnlineFile { server: "wss://share.example/".into(), file: "f".into(), name: "Trail".into(), ..OnlineFile::default() };
let rows = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
row(&mut app, ui, &t, &file, false);
});
assert!(paints_icon_at_button_size(&rows, Icon::Dots), "file row menu is not 16 pt");
let team = TeamFile { file: "f".into(), name: "Trail".into(), updated: 0, people: 0, access: Access::Edit };
let teams = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
team_row(&mut app, ui, &t, "wss://share.example/", "", &team);
});
assert!(paints_icon_at_button_size(&teams, Icon::Dots), "team row menu is not 16 pt");
}
}
@@ -0,0 +1,37 @@
//! Every 24 pt icon button paints its glyph at 16 pt, the size `icons::button` draws (#2238, after
//! the Design panel's in #2227). The panels' own buttons: the Layers panel's header, the Assets
//! panel's back button and the Dev Mode status button. The tests of the other panels sit beside
//! them (`present/tests.rs`, and the `icon_size_tests` modules of `versions`, `proto_panel`,
//! `online` and the style picker).
use egui::{Rect, pos2, vec2};
use figcraft_engine::Session;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
use crate::{FigApp, NoServices};
fn app() -> FigApp {
FigApp::new(Session::new(), Box::new(NoServices))
}
#[test]
fn layers_and_assets_icon_buttons_paint_16pt() {
let mut a = app();
let rail = painted_rects(None, |ui| crate::layers::left_panel(&mut a, ui));
for icon in [Icon::Panels, Icon::Search, Icon::Plus] {
assert!(paints_icon_at_button_size(&rail, icon), "Layers panel: {icon:?} is not 16 pt");
}
a.ui.libraries = true;
let back = painted_rects(None, |ui| crate::assets_panel::show(&mut a, ui));
assert!(paints_icon_at_button_size(&back, Icon::ChevronLeft), "Assets panel: the back chevron is not 16 pt");
}
#[test]
fn the_dev_mode_status_button_paints_a_16pt_glyph() {
let mut a = app();
let doc = a.session.doc().unwrap().clone();
let button = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
let painted = painted_rects(None, |ui| crate::section_panel::status_icon(&mut a, ui, &doc, &[], button));
assert!(paints_icon_at_button_size(&painted, Icon::Code), "the status button's code glyph is not 16 pt");
}
+2
View File
@@ -132,6 +132,8 @@ mod home_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod icon_button_size_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
+1 -1
View File
@@ -818,7 +818,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
if bresp.hovered() {
ui.painter().rect_filled(back, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), back.shrink(5.0), Icon::ChevronLeft, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(back), Icon::ChevronLeft, t.icon);
ui.painter().text(pos2(back.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, tr("Libraries"), semibold(12.0), t.text);
if bresp.clicked() {
app.ui.libraries = false;
+4 -4
View File
@@ -168,7 +168,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(hr), Icon::Panels, t.icon);
if hresp.clicked() {
app.run("view.minimize", json!({}));
}
@@ -204,7 +204,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.page_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("page_search_field");
if sresp.clicked() {
app.ui.page_search = !app.ui.page_search;
@@ -218,7 +218,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if aresp.hovered() {
ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(ar), Icon::Plus, t.icon);
if aresp.clicked() {
app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid);
if !crate::menus::dev_mode_refuses(app, "page.add") {
@@ -1090,7 +1090,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.layer_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("layer_search_field");
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
+19 -1
View File
@@ -374,7 +374,7 @@ fn icon_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), icon, t.icon);
resp.clicked()
}
@@ -1354,3 +1354,21 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId],
});
}
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The panel's 24 pt icon buttons (flow play, preview, remove) paint their glyphs at 16 pt (#2238).
#[test]
fn prototype_icon_buttons_paint_16pt() {
for icon in [Icon::Play, Icon::Preview, Icon::Minus] {
let painted = painted_rects(None, |ui| {
let rect = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button(ui, Id::unique("proto_icon_size"), rect, icon, "x");
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub fn status_icon(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]
Some((DevStatus::None, _)) | None => t.icon,
Some(_) => t.accent_text,
};
icons::paint(ui.painter(), rect.shrink(5.0), Icon::Code, color);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Code, color);
menu_popup(&resp).width(160.0).show(|ui| status_menu(app, ui, ids, doc));
}
+24 -2
View File
@@ -441,7 +441,7 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -496,7 +496,7 @@ fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -607,3 +607,25 @@ fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option<bool> {
})
.inner
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The version and branch rows' More buttons (shown on hover) paint their dots at 16 pt (#2238).
#[test]
fn version_and_branch_menus_paint_16pt() {
let over = Some(pos2(20.0, 20.0));
let row = Row { id: "v".into(), name: "Draft".into(), description: String::new(), time: 0, auto: false };
let version = painted_rects(over, |ui| {
version_row(ui, &row, true, false);
});
assert!(paints_icon_at_button_size(&version, Icon::Dots), "version row menu is not 16 pt");
let branch = BranchRow { id: "b".into(), name: "Try".into(), time: 0, state: BranchState::Active };
let branches = painted_rects(over, |ui| {
branch_row(ui, &branch);
});
assert!(paints_icon_at_button_size(&branches, Icon::Dots), "branch row menu is not 16 pt");
}
}
+2 -2
View File
@@ -262,13 +262,13 @@ fn header_text(t: Tokens, dark: bool) -> Color32 {
if dark { t.menu_text } else { t.text }
}
/// The X: a 24-point button, its icon inset 6 (the colour picker's header).
/// The X: a 24-point button, its cross 16 points like every 24 pt icon button (the colour picker's header).
fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {
let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });
}
icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Close, if dark { t.menu_text } else { t.icon });
resp.clicked()
}
+21
View File
@@ -194,3 +194,24 @@ fn a_caller_can_put_it_where_it_likes() {
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);
}
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::paints_icon_at_button_size;
use crate::icons::Icon;
/// The header's 24 pt X paints its cross at 16 pt, like every other 24 pt icon button (#2238).
#[test]
fn the_close_button_paints_a_16pt_cross() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_close_size", "Colour");
let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
let painted: Vec<Rect> = shapes
.iter()
.filter(|s| matches!(s, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.visual_bounding_rect())
.collect();
assert!(paints_icon_at_button_size(&painted, Icon::Close), "the popover's close cross is not 16 pt");
}
}
+1 -1
View File
@@ -173,7 +173,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography"));
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon });
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), *icon, if on { t.accent_on } else { t.icon });
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text });
}
+18
View File
@@ -436,3 +436,21 @@ fn smart_animate_frame_cost() {
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)");
}
/// The preview pane's 24 pt bar buttons paint their glyphs at 16 pt (#2238).
#[test]
fn pane_bar_buttons_paint_16pt() {
use super::layout::Chrome;
use super::player::player;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
let (d, a, ..) = device_doc();
let mut p = player_on(d, a);
let pane = Chrome { pane: true, bar_h: 32.0, pad: 12.0, round: 8, keys: false };
let painted = painted_rects(None, |ui| {
player(&mut p, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 500.0)), 0.0, pane);
});
for icon in [Icon::Close, Icon::Play, Icon::Reset] {
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
@@ -251,6 +251,23 @@ fn the_bulk_menu_scrolls_when_forty_groups_make_it_taller_than_the_window() {
assert!(screen.contains_rect(popup_rect(&u)));
}
/// A collection with a group `space` of two number variables with distinct values in two modes.
fn grouped_window() -> Ui {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
for (n, light, dark) in [("space/sm", 4, 8), ("space/md", 16, 32)] {
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "number", "value": light})).unwrap();
s.execute("variable.set", &json!({"variable": n, "mode": "Dark", "value": dark})).unwrap();
s.execute("variable.update", &json!({"variable": n, "scopes": ["GAP"], "description": n})).unwrap();
}
let mut u = Ui::new(s);
u.app.ui.variables = Some(Default::default());
for _ in 0..10 {
u.frame(vec![]);
}
u
}
/// Three colour variables: bravo and charlie also have Stroke in their scopes, delta does not.
fn colour_window() -> Ui {
let mut s = Session::new();
@@ -269,6 +286,52 @@ fn colour_window() -> Ui {
u
}
/// Right-click the group row `name` and choose Duplicate group.
fn duplicate_group(u: &mut Ui, name: &str) {
let p = row(u, name);
click(u, p, PointerButton::Secondary, Modifiers::NONE);
let menu = flat(&u.frame_output(vec![]).shapes);
click(u, text_center(&menu, "Duplicate group"), PointerButton::Primary, Modifiers::NONE);
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn duplicate_group_copies_every_variable_in_both_modes_in_one_undo_step() {
let mut u = grouped_window();
let before = u.app.session.undo_labels(100).len();
duplicate_group(&mut u, "space");
let doc = u.app.session.doc().unwrap();
let vars = &doc.variables;
for (leaf, light, dark) in [("sm", 4, 8), ("md", 16, 32)] {
let old = vars.variables.iter().find(|v| v.name == format!("space/{leaf}")).unwrap();
let new = vars.variables.iter().find(|v| v.name == format!("space 2/{leaf}")).unwrap_or_else(|| panic!("no space 2/{leaf}"));
assert_ne!(old.id, new.id);
assert_eq!(new.values, old.values, "{leaf}: values in both modes");
assert_eq!(new.scopes, old.scopes);
assert_eq!(new.description, old.description);
let _ = (light, dark);
}
assert_eq!(names(&u).len(), 4);
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
assert_eq!(u.app.session.undo_labels(1), ["Duplicate group"]);
// The new group is listed directly after the original.
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "space 2"));
assert!(text_center(&shapes, "space 2").y > text_center(&shapes, "space").y);
u.app.session.undo().unwrap();
assert_eq!(names(&u), ["space/sm", "space/md"]);
}
#[test]
fn a_second_duplicate_group_takes_the_next_free_name() {
let mut u = grouped_window();
duplicate_group(&mut u, "space");
duplicate_group(&mut u, "space");
let n = names(&u);
assert!(n.contains(&"space 2/sm".to_string()) && n.contains(&"space 3/md".to_string()), "{n:?}");
assert_eq!(n.len(), 6);
}
fn open_edit_variables(u: &mut Ui) -> Vec<egui::Shape> {
let menu = select_three_and_open_menu(u);
assert!(has_text(&menu, "Edit variables"), "the bulk menu offers Edit variables");
+6 -1
View File
@@ -303,7 +303,12 @@ fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Respon
if on || resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.menu_hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.menu_text);
let glyph = if (rect.width() - 24.0).abs() < 0.5 && (rect.height() - 24.0).abs() < 0.5 {
crate::widgets::icon_glyph_rect(rect)
} else {
rect.shrink(5.0)
};
icons::paint(ui.painter(), glyph, icon, t.menu_text);
resp
}
@@ -558,3 +558,17 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
/// The picker's 24 pt icon buttons paint their glyphs at 16 pt; a smaller button keeps a smaller one
/// (#2238).
#[test]
fn style_picker_icon_buttons_paint_16pt() {
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
for icon in [Icon::Plus, Icon::Detach, Icon::ChevronLeft] {
let painted = painted_rects(None, |ui| {
menu_icon(ui, Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(24.0, 24.0)), icon, "x", false);
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (a group's context menu in the Local variables window has Duplicate group, a copy named after it plus " 2" holding a copy of every variable, in one undo step (#2084); pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -66,7 +66,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once; a group can be duplicated from its row's menu (Duplicate group, one undo step) |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | M3, #2084: a group's context menu in the Local variables window has Duplicate group (after Rename group, before Delete group). It adds a group named after the original plus " 2" (the next free number when taken: " 3"), whose variables are copies of the originals with the same leaf names, values in every mode, description, scopes, hidden-from-publishing and code syntax, each sent as `variable.duplicate` plus a rename in one `command.batch` labelled Duplicate group, so one undo step. Measured in the incumbent with one mode; an alias to a variable of the same group keeps pointing at the original. Ungroup is not built. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-07 | minor | M3, #2082: Edit variables in the context menu of a selection of several local variables opens the reference's compact editor with two tabs: Details holds only Hide from publishing, Scope the scopes every selected variable's type has (All supported properties and one box per scope; a selection that shares none says so). Each box is checked when all selected variables have it, empty when none, and shows a dash when they differ; a click sets it on all (or clears it when checked on all), each change one `command.batch`, so one undo step. Names, descriptions and code syntax stay single-variable, as in the reference. The Scope: menu entries stay. A tri-state `widgets::checkbox_tri` draws the dash. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-12 | minor | M7, #2215: pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215). The same section the frames' Variable modes use (`variable.setMode` without layers sets the page's mode), one undo step per pick. Binding the page background to a variable is not in the reference and is not offered |
| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
File diff suppressed because one or more lines are too long
+4 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -1,6 +1,9 @@
sonar.projectKey=storytold_figcraft
sonar.organization=storytold
# Pinned integration-test fonts are data, not source code for analysis.
sonar.exclusions=craft-fonts/**
# S4790 (weak hash): a .fig file names each image by the SHA-1 of its bytes (20 bytes, the only
# length readers accept). It is an identifier the format fixes, not a security choice.
sonar.issue.ignore.multicriteria=fig_image_sha1