Browse / github.com/storytold/figcraft / patch
Enforce Rust coding standards via Clippy
Enforce Rust coding standards via Clippy Denies `clippy::cognitive_complexity` (threshold 30, matches Sonar rust:S3776), `too_many_arguments` and `wildcard_imports` workspace-wide. Existing violations are fixed in follow-up commits. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Chores** * Updated development checks to flag excessive complexity, long parameter lists, and wildcard imports, with consistent enforcement across the workspace. * **Refactor** * Reorganized flow and text layout calculations while preserving existing wrapping,
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dylanmtaylor (imported from pull request #1403, open)
- Base
8b591f703d0106666abfe3642395e72006b4bd5e- Size
- +665 −417 in 28 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d0635270873fec6ec9de4145
How it went
No other operator has reported using it yet.
Diff
raw · CLAUDE.md Cargo.toml clippy.toml crates/doc/src/node.rs crates/format/src/fig/assets_tests.rs crates/format/src/fig/export/assets.rs crates/format/src/fig/export/common.rs crates/format/src/fig/export/component.rs crates/format/src/fig/export/layer.rs crates/format/src/fig/export/paint.rs crates/format/src/fig/export/prototype.rs crates/format/src/fig/export/shape.rs crates/format/src/fig/kiwi.rs crates/format/src/fig/map/component.rs crates/format/src/fig/map/connector.rs crates/format/src/fig/map/document.rs crates/format/src/fig/map/layer.rs crates/format/src/fig/map/layout.rs crates/format/src/fig/map/paint.rs crates/format/src/fig/map/proto.rs crates/format/src/fig/map/text.rs crates/format/src/fig/map/variables.rs crates/format/src/fig/source_identity_tests.rs crates/format/src/fig/tests.rs crates/format/src/rest/proto_variable_tests.rs crates/layout/src/lib.rs crates/text/src/layout.rs xtask/Cargo.toml
@@ -62,6 +62,7 @@ code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.+- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are safety nets, not substitutes for `Result`.@@ -102,6 +102,9 @@ webbrowser = "1" unsafe_code = "forbid" [workspace.lints.clippy]+cognitive_complexity = "deny"+too_many_arguments = "deny"+wildcard_imports = "deny" dbg_macro = "warn" # Style lints from newer toolchains that fight index-heavy pixel code. needless_range_loop = "allow"@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ]+cognitive-complexity-threshold = 30@@ -1,7 +1,10 @@ //! Nodes: one struct with the properties every node can have, and a kind with per-type data. use crate::NodeId;-use crate::props::*;+use crate::props::{+ AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,+ StrokeProps,+}; use crate::proto::{Interaction, Overflow, ScrollPosition}; use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId}; use crate::text::TextData;@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() { #[test] fn variables_are_written_as_nodes_on_a_hidden_page() { let nodes = written(&themed());- let hidden = named(&nodes, "Internal Only Canvas");+ assert_hidden_theme(&nodes);+ assert_variable_values(&nodes);+ assert_variable_bindings(&nodes);+}++fn assert_hidden_theme(nodes: &[Value]) {+ let hidden = named(nodes, "Internal Only Canvas"); assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));- let set = named(&nodes, "Theme");+ let set = named(nodes, "Theme"); assert_eq!(set["type"], "VARIABLE_SET"); assert_eq!(set["parentIndex"]["guid"], hidden["guid"]); let modes = set["variableSetModes"].as_array().unwrap(); assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Light", "Dark"]);- let bg = named(&nodes, "color/bg");+}++fn assert_variable_values(nodes: &[Value]) {+ let set = named(nodes, "Theme");+ let modes = set["variableSetModes"].as_array().unwrap();+ let bg = named(nodes, "color/bg"); assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR"))); assert_eq!(bg["variableSetID"]["guid"], set["guid"]); assert_eq!(bg["description"], "Behind everything"); assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));- assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));+ assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); let values = bg["variableDataValues"]["entries"].as_array().unwrap(); assert_eq!(values.len(), 2); assert_eq!(values[1]["modeID"], modes[1]["id"]);@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { (Some("COLOR"), Some(0.25)) ); // An alias names the variable's node; every value says the type it resolves to.- let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];+ let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"]; assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS"); assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");- assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);- assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");- assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);- assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);+ assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);+ assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");+ assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);+ assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);+}++fn assert_variable_bindings(nodes: &[Value]) {+ let bg = named(nodes, "color/bg");+ let set = named(nodes, "Theme");+ let modes = set["variableSetModes"].as_array().unwrap(); // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.- let card = named(&nodes, "Card");+ let card = named(nodes, "Card"); assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);- assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);+ assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]); let fields: Vec<&str> = card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect(); for f in@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { { assert!(fields.contains(&f), "{f} in {fields:?}"); }- let swatch = named(&nodes, "Swatch");+ let swatch = named(nodes, "Swatch"); assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none()); assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}"); } // The page sets a mode too.- assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);+ assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]); } #[test]@@ -1,5 +1,8 @@ use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};-use super::*;+use super::{+ AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,+ TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,+}; impl<'d> Exporter<'d> { /// GUIDs for every layer (pages first, then depth-first), component property definitions,@@ -1,4 +1,8 @@-use super::*;+use super::{+ Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,+ PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,+ s, xy,+}; /// Longest kept description of such a layer (bytes of JSON). const MAX_STASH: usize = 1 << 20;@@ -1,7 +1,7 @@ use super::common::{ bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs, };-use super::*;+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s}; impl<'d> Exporter<'d> { /// A component property definition of `owner` (a component, or a set), `index`-th in its list.@@ -1,5 +1,9 @@ use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};-use super::*;+use super::{+ AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,+ Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,+ guid, int, matrix, o, position, root_fields, s, u, xy,+}; impl<'d> Exporter<'d> { /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.@@ -1,5 +1,8 @@ use super::common::one_sided;-use super::*;+use super::{+ Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,+ StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,+}; impl<'d> Exporter<'d> { /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.@@ -1,5 +1,8 @@ use super::common::key_codes;-use super::*;+use super::{+ Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,+ b, color, e, fl, int, o, position, s,+}; impl<'d> Exporter<'d> { /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.@@ -1,5 +1,9 @@ use super::common::{style_fields, unsupported_decoration_look};-use super::*;+use super::{+ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,+ StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,+ join_name, network, o, path_blob, s, u, xy,+}; impl<'d> Exporter<'d> { /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> { /// An encoder for the same format: the `.fig` export writes its schema and message with it (and /// test fixtures are built with it). Values are checked against the schema; it never panics. pub mod encode {- use super::*;+ use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err}; #[derive(Default)] pub struct Writer(pub Vec<u8>);@@ -1,6 +1,6 @@ use super::layer::own_text; use super::text::char_styles;-use super::*;+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json}; /// An override row names a layer of the main by its current GUID or by the `overrideKey` the /// layer had when the row was written: the key becomes the current GUID, unless the layer is@@ -1,5 +1,5 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};-use super::*;+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows}; impl<'a> Mapper<'a> { /// A connector's end caps, and when it has no saved outline or network a straight line@@ -1,7 +1,7 @@ use super::component::{assigned, prop_guid}; use super::paint::color; use super::variables::variable_value;-use super::*;+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows}; impl<'a> Mapper<'a> { /// The DOCUMENT with its pages.@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, paint, paint_bound, stroke};-use super::*;+use super::{+ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,+ size,+}; /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.@@ -61,6 +64,22 @@ impl Fit { } } +struct LayerChildren<'s, 'a> {+ id: &'s str,+ inst: Option<&'s Inst<'a>>,+ base: &'s Kv<'a>,+ child_space: Affine,+ view: &'s View<'a>,+ object: &'s mut Obj,+ ty: &'s str,+ rest_id: &'s str,+ connector_snapshot: bool,+ connector_terminal: bool,+ depth: usize,+ included: Option<&'s BTreeSet<String>>,+ slot_content: Option<String>,+}+ impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> { connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot);- let ty = match own {- "ROUNDED_RECTANGLE" => "RECTANGLE",- "SYMBOL" => "COMPONENT",- "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",- "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",- "VARIABLE" | "VARIABLE_SET" => return None,- "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",- "CONNECTOR" if connector_snapshot => "FRAME",- t => stand.map_or(t, |s| s.0),- };+ let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); }@@ -225,18 +235,59 @@ impl<'a> Mapper<'a> { source.insert("slotOverride".into(), json!(true)); } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };- let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };+ let mut kids = self.map_layer_children(LayerChildren {+ id,+ inst,+ base,+ child_space,+ view: &view,+ object: &mut o,+ ty,+ rest_id: &rest_id,+ connector_snapshot,+ connector_terminal,+ depth,+ included,+ slot_content,+ });+ if stand.is_some_and(|s| s.0 == "FRAME") {+ kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));+ kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));+ }+ if !kids.is_empty() {+ o.insert("children".into(), Value::Array(kids));+ }+ Some(Value::Object(o))+ }++ fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec<Value> {+ let LayerChildren {+ id,+ inst,+ base,+ child_space,+ view,+ object: o,+ ty,+ rest_id,+ connector_snapshot,+ connector_terminal,+ depth,+ included,+ slot_content,+ } = context;+ let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() }; if connector_terminal { // The saved vector is flattened into the connector node when no child slot remains. } else if ty == "INSTANCE" {- if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {+ if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); // An instance that records no auto layout of its own has its main's. let view = match main { Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };- self.auto_layout(&mut o, &layered);+ self.auto_layout(o, &layered); layered } _ => view.clone(),@@ -252,7 +303,7 @@ impl<'a> Mapper<'a> { content_inst.slot_content = true; } for c in self.children(&content) {- kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));+ kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included)); } } else { // Inside an instance a layer's children are its main's: they follow it from the main's@@ -263,17 +314,29 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) {- kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));+ kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included)); } }- if stand.is_some_and(|s| s.0 == "FRAME") {- kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));- kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));- }- if !kids.is_empty() {- o.insert("children".into(), Value::Array(kids));+ kids+ }++ fn mapped_type<'s>(+ own: &'s str,+ view: &View<'a>,+ connector_snapshot: bool,+ connector_terminal: bool,+ stand: Option<(&'static str, &'static str)>,+ ) -> Option<&'s str> {+ match own {+ "ROUNDED_RECTANGLE" => Some("RECTANGLE"),+ "SYMBOL" => Some("COMPONENT"),+ "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),+ "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),+ "VARIABLE" | "VARIABLE_SET" => None,+ "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),+ "CONNECTOR" if connector_snapshot => Some("FRAME"),+ t => Some(stand.map_or(t, |s| s.0)), }- Some(Value::Object(o)) } /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its@@ -1,5 +1,5 @@ use super::paint::{bound, color};-use super::*;+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num}; impl<'a> Mapper<'a> { pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {@@ -1,6 +1,6 @@ use super::layer::cap_name; use super::variables::alias;-use super::*;+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2}; fn filter_number(value: &Kv) -> Option<f64> { let number = match value {@@ -1,6 +1,6 @@ use super::paint::color; use super::variables::variable_data;-use super::*;+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid}; impl<'a> Mapper<'a> { pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {@@ -1,5 +1,5 @@ use super::paint::{paint, style_link};-use super::*;+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num}; /// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. fn text_num(v: &View, key: &str) -> Option<f64> {@@ -1,5 +1,5 @@ use super::paint::color;-use super::*;+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid}; impl<'a> Mapper<'a> { pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) { let copied_frame = child(doc, copied_body, 0); assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));+ assert_external_slots(doc, frame, copied_frame, main);+ assert_external_copy_state(doc, frame, copied_frame);+}++fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) { for index in 0..2 { let slot = child(doc, frame, index); let copied_slot = child(doc, copied_frame, index);@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) { assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); }+}++fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) { let old = doc.node(child(doc, frame, 0)).unwrap(); let current = doc.node(child(doc, copied_frame, 0)).unwrap(); assert!(!old.visible && old.is_mask && old.opacity < 1.0);@@ -630,63 +630,82 @@ fn imports_the_fixture() { assert!(is_fig(&bytes)); let (doc, warnings) = import(&bytes, "fallback").unwrap(); assert_eq!(doc.name, "Fixture");+ assert_fixture_layers(&doc);+ assert_fixture_styles(&doc);+ assert_fixture_variables(&doc);+ assert_fixture_components(&doc);+ assert_fixture_prototype(&doc, &warnings);+}++fn assert_fixture_layers(doc: &Document) { // The internal page is skipped. assert_eq!(doc.pages.len(), 1); let page = doc.node(doc.pages[0]).unwrap(); assert_eq!(page.name, "Page A"); // Siblings by position: the frame comes first even though it's listed after the component. assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");- let screen = named(&doc, "Screen");+ let screen = named(doc, "Screen"); let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); // Image fill from the archive.- let photo = named(&doc, "Photo");+ let photo = named(doc, "Photo"); let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); // Text with a bold run over "the".- let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };+ let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100).- assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));- let tri = named(&doc, "Tri");+ assert!(matches!(named(doc, "Group").kind, NodeKind::Group));+ let tri = named(doc, "Tri"); assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); let abs = doc.world_transform(tri.id).translation(); assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");- assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));- // The linked style supplies the paint.- let styled = named(&doc, "Styled");+ assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));+}++fn assert_fixture_styles(doc: &Document) {+ let styled = named(doc, "Styled"); assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); assert!(doc.styles.iter().any(|s| s.name == "Brand")); let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));- // Variables, the fill binding and the explicit mode.+}++fn assert_fixture_variables(doc: &Document) {+ let screen = named(doc, "Screen"); assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables[0].kind, VariableType::Color);- assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));+ assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));- // The component keeps its property; the instance is expanded with the value applied.- let button = named(&doc, "Button");+}++fn assert_fixture_components(doc: &Document) {+ let button = named(doc, "Button"); assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));- let inst = named(&doc, "Button copy");+ let inst = named(doc, "Button copy"); assert!( matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) ); let icon = doc.node(inst.children[0]).unwrap(); assert_eq!(icon.name, "Renamed icon"); assert!(!icon.visible);- // Prototype: the click navigates to the component; the flow starts at the screen.- let it = &photo.interactions[0];+}++fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {+ let button = named(doc, "Button");+ let screen = named(doc, "Screen");+ let it = &named(doc, "Photo").interactions[0]; assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); assert_eq!(it.duration_ms, 250); let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); // The sticky note comes in as a frame, with a warning.- assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));+ assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); } @@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() { #[test] fn interactions_triggers_actions_and_transitions() { let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();- let id = |n: &str| named(&doc, n).id;- let button = named(&doc, "Button");+ assert_interactions(&doc, &warnings);+ assert_legacy_transition(&doc);+ assert_interactions_without_variables();+}++fn assert_interactions(doc: &Document, warnings: &[String]) {+ let id = |n: &str| named(doc, n).id;+ let button = named(doc, "Button"); let its = &button.interactions; assert_eq!(its.len(), 8, "{its:#?}"); @@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[7].trigger, Trigger::MouseUp); assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped"); - // The legacy single transition.- let details = named(&doc, "Details");+ assert!(!has(warnings, "modifier keys"), "{warnings:?}");+ for w in ["video triggers", "without a destination"] {+ assert!(has(warnings, w), "{w}: {warnings:?}");+ }+}++fn assert_legacy_transition(doc: &Document) {+ let id = |n: &str| named(doc, n).id;+ let details = named(doc, "Details"); assert_eq!(details.interactions.len(), 1); let legacy = &details.interactions[0]; assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));+} - assert!(!has(&warnings, "modifier keys"), "{warnings:?}");- for w in ["video triggers", "without a destination"] {- assert!(has(&warnings, w), "{w}: {warnings:?}");- }- // Without variables, set-variable actions have nothing to set.+fn assert_interactions_without_variables() { let (doc, warnings) = import(&file_json(), &no_images).unwrap(); assert_eq!(named(&doc, "Button").interactions.len(), 6); assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");@@ -333,6 +333,154 @@ struct Item { base_off: f64, } +/// Split flow items into lines, preserving a single empty line when wrapping is off.+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec<Vec<usize>> {+ if !wraps {+ return vec![(0..items.len()).collect()];+ }+ let mut lines = Vec::new();+ let mut current = Vec::new();+ let mut used = 0.0;+ for (i, item) in items.iter().enumerate() {+ let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };+ let added = if current.is_empty() { size } else { gap.max(0.0) + size };+ if !current.is_empty() && used + added > inner_main + 1e-6 {+ lines.push(std::mem::take(&mut current));+ used = size;+ } else {+ used += added;+ }+ current.push(i);+ }+ if !current.is_empty() {+ lines.push(current);+ }+ lines+}++/// Resolve each line's main extent and baseline-adjusted cross extent.+fn flow_line_extents(items: &mut [Item], lines: &[Vec<usize>], gap: f64) -> Vec<(f64, f64)> {+ let mut extents = Vec::with_capacity(lines.len());+ for line in lines {+ let gaps = gap * line.len().saturating_sub(1) as f64;+ let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);+ for i in line {+ if let Some(item) = items.get_mut(*i) {+ item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);+ }+ }+ let in_line = || line.iter().filter_map(|i| items.get(*i));+ let main_used = in_line().map(|item| item.main).sum::<f64>() + gaps;+ let all_fill = in_line().all(|item| item.fill_cross);+ let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);+ extents.push((main_used, cross));+ }+ extents+}++/// Resolve cross-axis line spacing and alignment before positioning the flow items.+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {+ if extents.len() <= 1 {+ return (0.0, cross_gap);+ }+ let lines_cross: f64 = extents.iter().map(|line| line.1).sum();+ let between = (extents.len() - 1) as f64;+ let spread = (inner_cross - lines_cross) / between;+ if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {+ return (0.0, spread);+ }+ let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };+ let free = inner_cross - lines_cross - cross_gap * between;+ if free > 0.0 && items.iter().all(|item| item.fill_cross) {+ let extra = free / extents.len() as f64;+ extents.iter_mut().for_each(|line| line.1 += extra);+ return (0.0, cross_gap);+ }+ let offset = match al.cross_align {+ CrossAlign::Min | CrossAlign::Baseline => 0.0,+ CrossAlign::Center => free / 2.0,+ CrossAlign::Max => free,+ };+ (offset, cross_gap)+}++/// Collect the size and sizing constraints of the children in flow order.+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec<Item> {+ let mut items = Vec::with_capacity(kids.len());+ for child in kids {+ let Some(node) = doc.node(*child) else { continue };+ let (w, h) = outer_size(doc, *child);+ let cl = &node.child_layout;+ let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };+ let (min_main, max_main, min_cross, max_cross) =+ if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };+ items.push(Item {+ id: *child,+ main: if horizontal { w } else { h },+ cross: if horizontal { h } else { w },+ // Fill needs room to fill: on an axis the frame hugs it reads as fixed.+ fill_main: sm == Sizing::Fill && !hug_main,+ fill_cross: sc == Sizing::Fill && !hug_cross,+ min_main,+ max_main,+ min_cross,+ max_cross,+ baseline: None,+ base_off: 0.0,+ });+ }+ items+}++/// Position one resolved flow line, resizing wrapped fill children across the line.+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {+ let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;+ let total = used - gap * line.len().saturating_sub(1) as f64;+ let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);+ pos += main_start;+ for i in line {+ let Some(it) = items.get(*i) else { continue };+ let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };+ let off = match al.cross_align {+ CrossAlign::Min => 0.0,+ // Baseline applies to horizontal layouts; elsewhere it reads as top/left.+ CrossAlign::Baseline => it.base_off,+ CrossAlign::Center => (line_cross - cross_size) / 2.0,+ CrossAlign::Max => line_cross - cross_size,+ };+ if it.fill_cross && wraps {+ let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };+ resize_child(doc, it.id, w, h, m);+ }+ let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };+ place(doc, it.id, x, y);+ pos += it.main + step_gap;+ }+}++struct FlowLineGeometry {+ horizontal: bool,+ wraps: bool,+ main_start: f64,+ inner_main: f64,+ cross_pos: f64,+ line_cross: f64,+ used: f64,+ gap: f64,+}++/// Distribute a line's remaining main-axis space among its fill children.+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec<usize>], inner_main: f64, gap: f64, auto_gap: bool) {+ for line in lines {+ let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum();+ let gaps = gap * line.len().saturating_sub(1) as f64;+ let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect();+ if !fills.is_empty() {+ distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });+ }+ }+}+ /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let Some(_nested) = Nested::enter() else { return };@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let hug_cross = if horizontal { hug_h } else { hug_w }; let baseline = horizontal && al.cross_align == CrossAlign::Baseline; - let mut items: Vec<Item> = Vec::with_capacity(kids.len());- for c in &kids {- let Some(n) = doc.node(*c) else { continue };- let (w, h) = outer_size(doc, *c);- let cl = &n.child_layout;- let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };- let (min_main, max_main, min_cross, max_cross) =- if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };- items.push(Item {- id: *c,- main: if horizontal { w } else { h },- cross: if horizontal { h } else { w },- // Fill needs room to fill: on an axis the frame hugs it reads as fixed.- fill_main: sm == Sizing::Fill && !hug_main,- fill_cross: sc == Sizing::Fill && !hug_cross,- min_main,- max_main,- min_cross,- max_cross,- baseline: None,- base_off: 0.0,- });- }+ let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross); let gap = finite_or_zero(al.gap); let auto_gap = al.main_align.is_auto_gap();@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).- let mut lines: Vec<Vec<usize>> = Vec::new();- if wraps {- let mut cur: Vec<usize> = Vec::new();- let mut used = 0.0;- for (i, it) in items.iter().enumerate() {- let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };- let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };- if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {- lines.push(std::mem::take(&mut cur));- used = sz;- } else {- used += add;- }- cur.push(i);- }- if !cur.is_empty() {- lines.push(cur);- }- } else {- lines.push((0..items.len()).collect());- }+ let lines = flow_lines(&items, wraps, gap, inner_main_fixed); // Share each line's free space among its fill children.- for line in &lines {- let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();- let gaps = gap * line.len().saturating_sub(1) as f64;- let fills: Vec<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();- if !fills.is_empty() {- distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });- }- }+ distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap); // Fill children take their main size now, so their cross size (wrapped text, a hugging frame) // is final before the lines and the frame are sized. for it in items.iter_mut().filter(|it| it.fill_main) {@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } // Line extents: (main used, cross size).- let mut line_extents: Vec<(f64, f64)> = Vec::new();- for line in &lines {- let gaps = gap * line.len().saturating_sub(1) as f64;- // Baseline alignment: the line's baseline sits at the deepest first baseline.- let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);- for i in line {- if let Some(it) = items.get_mut(*i) {- it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);- }- }- let in_line = || line.iter().filter_map(|i| items.get(*i));- let main_used: f64 = in_line().map(|it| it.main).sum::<f64>() + gaps;- // A line of nothing but fill-across children keeps their own size.- let all_fill = in_line().all(|it| it.fill_cross);- let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);- line_extents.push((main_used, cross));- }+ let mut line_extents = flow_line_extents(&mut items, &lines, gap); // Container size (on a hugging axis fill children can't fill: they count with their own size). let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);@@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { // Position. Several lines share the free space across the flow: packed lines sit where the // cross alignment says (lines of nothing but fill children stretch over it instead), or the // space goes between them.- let mut cross_pos = if horizontal { pt } else { pl };+ let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);+ let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset; let single = lines.len() == 1;- let mut cross_gap = cross_gap;- if lines.len() > 1 {- let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();- let between = (lines.len() - 1) as f64;- let spread = (inner_cross - lines_cross) / between;- if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {- cross_gap = spread;- } else {- if al.content_align == ContentAlign::SpaceBetween {- cross_gap = 0.0;- }- let free = inner_cross - lines_cross - cross_gap * between;- if free > 0.0 && items.iter().all(|it| it.fill_cross) {- let extra = free / lines.len() as f64;- line_extents.iter_mut().for_each(|l| l.1 += extra);- } else {- cross_pos += match al.cross_align {- CrossAlign::Min | CrossAlign::Baseline => 0.0,- CrossAlign::Center => free / 2.0,- CrossAlign::Max => free,- };- }- }- } for (li, line) in lines.iter().enumerate() { let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0)); let line_cross = if single { inner_cross } else { line_cross };- let total = used - gap * line.len().saturating_sub(1) as f64;- let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);- pos += if horizontal { pl } else { pt };- for i in line {- let Some(it) = items.get(*i) else { continue };- let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };- let off = match al.cross_align {- CrossAlign::Min => 0.0,- // Baseline applies to horizontal layouts; elsewhere it reads as top/left.- CrossAlign::Baseline => it.base_off,- CrossAlign::Center => (line_cross - cross_size) / 2.0,- CrossAlign::Max => line_cross - cross_size,- };- // Fill across a wrapped line: its size is known only now.- if it.fill_cross && wraps {- let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };- resize_child(doc, it.id, w, h, m);- }- let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };- place(doc, it.id, x, y);- pos += it.main + step_gap;- }+ let geometry =+ FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };+ place_flow_line(doc, &items, line, &al, m, geometry); cross_pos += line_cross + cross_gap; } }@@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> { order } -/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width-/// pass `None`). `box_height` is used for truncation only.-pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {+struct ShapedText {+ chars: Vec<char>,+ styles: Vec<TextStyle>,+ faces: Vec<Option<Arc<Face>>>,+ fallbacks: Vec<Arc<Face>>,+ items: Vec<Item>,+ bidi: Option<Bidi>,+}++fn shape_text(text: &TextData) -> ShapedText { let chars: Vec<char> = text.characters.chars().collect(); let spans = text.spans(); let mut styles: Vec<TextStyle> = Vec::new();@@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) // Shape each span, split by paragraph, bidi run and fallback face. let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);- let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec<Item> = Vec::with_capacity(chars.len());@@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) } } - // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the- // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent- // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the- // indent are on the right, the text ends where they start.- let lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();- let markers = list_markers(&lists);- let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());- let hang_list = if text.hanging_list { step } else { 0.0 };- // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).- let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };- let indent_at = |char_idx: usize| {- let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };- if start_aligned { indent_px } else { 0.0 }- };- let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();- // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).- let line_indent_of = |p: usize, first: bool, ci: usize| {- let pl = list_of(p);- match pl.list {- ListStyle::None if first => indent_at(ci),- ListStyle::None => 0.0,- _ => step * (f64::from(pl.indent) + 1.0) - hang_list,- }- };- // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge- // of right-aligned and justified ones (left-to-right paragraphs).- let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);- let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);+ ShapedText { chars, styles, faces, fallbacks, items, bidi }+} - // Line breaking.- let wrap = match text.auto_resize {- TextAutoResize::WidthAndHeight => None,- _ => box_width.filter(|w| w.is_finite() && *w > 0.0),- };+fn line_break_after(items: &[Item], end: usize) -> bool {+ end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))+}++fn break_lines(+ items: &[Item],+ wrap: Option<f64>,+ line_indent_of: &impl Fn(usize, bool, usize) -> f64,+ rtl_para_at: &impl Fn(usize) -> bool,+ hang_start: bool,+ hang_end: bool,+) -> Vec<(usize, usize, bool, usize)> { let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0;@@ -521,11 +508,259 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) } i += 1; }- let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));- if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {+ if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) { lines.push((line_start, items.len(), true, para)); } + lines+}++#[derive(Clone, Copy)]+struct LayoutFinish<'a> {+ text: &'a TextData,+ box_width: Option<f64>,+ box_height: Option<f64>,+ wrap: Option<f64>,+ lines: &'a [(usize, usize, bool, usize)],+ items: &'a [Item],+ styles: &'a [TextStyle],+ faces: &'a [Option<Arc<Face>>],+ first_cap: f64,+ flow_height: f64,+}++fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option<Glyph> {+ let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale;+ (item.gid != 0 || !item.is_space()).then(|| Glyph {+ face: item.face.clone(),+ coords: item.coords.clone(),+ gid: item.gid,+ x: pen + item.dx,+ y: baseline - item.dy,+ size,+ advance: item.adv,+ char_start: item.char_start,+ char_end: item.char_end,+ style: item.style,+ rtl: item.level % 2 == 1,+ warp: Affine::IDENTITY,+ })+}++fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) {+ let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish;+ let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();+ let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height };+ // Truncation by height.+ if matches!(text.auto_resize, TextAutoResize::Truncate)+ && let Some(h) = box_height+ && out.height > h + 0.5+ {+ let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1);+ if keep < out.lines.len() {+ out.lines.truncate(keep);+ let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0);+ out.glyphs.truncate(gend);+ out.truncated = true;+ out.height = out.lines.last().map_or(0.0, bottom);+ }+ }+ if out.truncated {+ // The last line shown runs on into the line cut after it, so the ellipsis fills the line+ // ("Lorem consec…") instead of following the last whole word ("Lorem…").+ let last = out.lines.len().checked_sub(1);+ let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));+ if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)+ && !para_end+ && !line_break_after(items, e)+ && !line.rtl+ {+ let baseline = line.baseline;+ let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);+ for it in items.get(ns..ne).unwrap_or(&[]) {+ if pen > maxw {+ break;+ }+ if !is_line_break(it.ch) {+ out.glyphs.extend(glyph_at(styles, it, pen, baseline));+ pen += it.adv;+ }+ }+ let gend = out.glyphs.len();+ if let Some(l) = out.lines.last_mut() {+ l.glyph_end = gend;+ }+ }+ add_ellipsis(out, wrap);+ // Carets and selections reach every char the line shows, borrowed ones included.+ if let Some(l) = out.lines.last() {+ let shown =+ out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();+ if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {+ l.char_end = l.char_end.max(shown);+ }+ }+ }+}++fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) {+ let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish;+ // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.+ let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();+ if trim && let Some(first) = out.lines.first() {+ let shift = first.baseline - first_cap;+ for l in &mut out.lines {+ l.top -= shift;+ l.baseline -= shift;+ }+ for g in &mut out.glyphs {+ g.y -= shift;+ }+ }+ let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };+ out.height = out.lines.last().map_or(flow_height, bottom).max(0.0);++ truncate_layout(out, &finish);++ // Horizontal alignment within the box.+ let align_width = match text.auto_resize {+ TextAutoResize::WidthAndHeight => out.width,+ _ => box_width.unwrap_or(out.width),+ };+ for li in 0..out.lines.len() {+ let Some(line) = out.lines.get(li) else { continue };+ let dx = match text.align_h {+ TextAlignH::Left | TextAlignH::Justified => 0.0,+ TextAlignH::Center => (align_width - line.width) / 2.0,+ TextAlignH::Right => align_width - line.width,+ };+ if dx != 0.0 {+ let (gs, ge) = (line.glyph_start, line.glyph_end);+ for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {+ g.x += dx;+ }+ if let Some(l) = out.lines.get_mut(li) {+ l.x = dx;+ }+ }+ }++ // Decorations per contiguous glyph run of the same style on a line; a decoration colour+ // paints with a style of its own, added after the glyphs' styles.+ let mut decos = Vec::new();+ let mut deco_styles: Vec<TextStyle> = Vec::new();+ for line in &out.lines {+ let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);+ let mut j = 0;+ while j < gl.len() {+ let Some(g0) = gl.get(j) else { break };+ // List markers and the ellipsis are not decorated.+ if g0.char_end == g0.char_start {+ j += 1;+ continue;+ }+ let st = out.styles.get(g0.style).cloned().unwrap_or_default();+ let mut k = j + 1;+ while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {+ k += 1;+ }+ if st.decoration != TextDecoration::None+ && let Some(last) = gl.get(k - 1)+ {+ let x0 = g0.x;+ let x1 = last.x + last.advance;+ // The style's own font and size (not a fallback face or a small-cap scale), so the+ // line runs at one height and weight through mixed scripts.+ let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face);+ let size = sanitize_size(st.font_size);+ let s = size / f.upem;+ let look = st.look();+ let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);+ let underline = st.decoration == TextDecoration::Underline;+ // The line's top edge (an underline's own offset is measured from the baseline).+ let top = match look.offset {+ DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,+ m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),+ _ => line.baseline - f.strike_pos * s - th / 2.0,+ };+ let style = match look.color {+ Some(c) => {+ let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };+ let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {+ deco_styles.push(own);+ deco_styles.len() - 1+ });+ out.styles.len() + k+ }+ None => g0.style,+ };+ let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };+ // Skip ink: the underline stops a little before each glyph crossing it.+ let gap = th.max(1.0);+ let cuts = if underline && look.skip_ink {+ ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)+ } else {+ Vec::new()+ };+ let mut from = x0;+ for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {+ if c0.min(x1) > from + 0.5 {+ let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);+ decos.push(Decoration { rect, style, kind: look.style, thickness: th });+ }+ from = from.max(c1);+ }+ }+ j = k;+ }+ }+ out.decorations = decos;+ out.styles.extend(deco_styles);+ if let Some(tp) = &text.path {+ crate::onpath::place(out, tp, text.align_h);+ }+}++/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width+/// pass `None`). `box_height` is used for truncation only.+pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) -> TextLayout {+ let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text);+ let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);++ // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the+ // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent+ // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the+ // indent are on the right, the text ends where they start.+ let lists: Vec<ParaList> = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();+ let markers = list_markers(&lists);+ let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());+ let hang_list = if text.hanging_list { step } else { 0.0 };+ // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).+ let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };+ let indent_at = |char_idx: usize| {+ let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };+ if start_aligned { indent_px } else { 0.0 }+ };+ let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();+ // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).+ let line_indent_of = |p: usize, first: bool, ci: usize| {+ let pl = list_of(p);+ match pl.list {+ ListStyle::None if first => indent_at(ci),+ ListStyle::None => 0.0,+ _ => step * (f64::from(pl.indent) + 1.0) - hang_list,+ }+ };+ // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge+ // of right-aligned and justified ones (left-to-right paragraphs).+ let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);+ let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);++ let wrap = match text.auto_resize {+ TextAutoResize::WidthAndHeight => None,+ _ => box_width.filter(|w| w.is_finite() && *w > 0.0),+ };+ let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end); // Line metrics and positions. let default_style = styles.first().cloned().unwrap_or_default(); let default_face = faces.first().cloned().flatten();@@ -546,25 +781,6 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], } };- // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space- // the font has no glyph for).- let glyph_at = |it: &Item, pen: f64, baseline: f64| {- let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;- (it.gid != 0 || !it.is_space()).then(|| Glyph {- face: it.face.clone(),- coords: it.coords.clone(),- gid: it.gid,- x: pen + it.dx,- y: baseline - it.dy,- size,- advance: it.adv,- char_start: it.char_start,- char_end: it.char_end,- style: it.style,- rtl: it.level % 2 == 1,- warp: Affine::IDENTITY,- })- }; let mut first_cap = 0.0; for (li, (s, e, para_end, para)) in lines.iter().enumerate() { if li >= max_lines {@@ -613,7 +829,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); let justify_extra = match (text.align_h, wrap) {- (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {+ (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) } _ => 0.0,@@ -704,7 +920,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) if is_line_break(it.ch) { continue; }- out.glyphs.extend(glyph_at(it, pen, baseline));+ out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); pen += it.adv; if it.stretches() { pen += justify_extra;@@ -723,176 +939,18 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end,- break_after: break_after(*e),+ break_after: line_break_after(&items, *e), rtl: rtl_line, }); y += lh; para_start_line = *para_end; } out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max);- // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.- let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();- if trim && let Some(first) = out.lines.first() {- let shift = first.baseline - first_cap;- for l in &mut out.lines {- l.top -= shift;- l.baseline -= shift;- }- for g in &mut out.glyphs {- g.y -= shift;- }- }- let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };- out.height = out.lines.last().map_or(y, bottom).max(0.0);-- // Truncation by height.- if matches!(text.auto_resize, TextAutoResize::Truncate)- && let Some(h) = box_height- && out.height > h + 0.5- {- let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1);- if keep < out.lines.len() {- out.lines.truncate(keep);- let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0);- out.glyphs.truncate(gend);- out.truncated = true;- out.height = out.lines.last().map_or(0.0, bottom);- }- }- if out.truncated {- // The last line shown runs on into the line cut after it, so the ellipsis fills the line- // ("Lorem consec…") instead of following the last whole word ("Lorem…").- let last = out.lines.len().checked_sub(1);- let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));- if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)- && !para_end- && !break_after(e)- && !line.rtl- {- let baseline = line.baseline;- let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);- for it in items.get(ns..ne).unwrap_or(&[]) {- if pen > maxw {- break;- }- if !is_line_break(it.ch) {- out.glyphs.extend(glyph_at(it, pen, baseline));- pen += it.adv;- }- }- let gend = out.glyphs.len();- if let Some(l) = out.lines.last_mut() {- l.glyph_end = gend;- }- }- add_ellipsis(&mut out, wrap);- // Carets and selections reach every char the line shows, borrowed ones included.- if let Some(l) = out.lines.last() {- let shown =- out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();- if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {- l.char_end = l.char_end.max(shown);- }- }- }+ finish_layout(+ &mut out,+ LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y },+ ); - // Horizontal alignment within the box.- let align_width = match text.auto_resize {- TextAutoResize::WidthAndHeight => out.width,- _ => box_width.unwrap_or(out.width),- };- for li in 0..out.lines.len() {- let Some(line) = out.lines.get(li) else { continue };- let dx = match text.align_h {- TextAlignH::Left | TextAlignH::Justified => 0.0,- TextAlignH::Center => (align_width - line.width) / 2.0,- TextAlignH::Right => align_width - line.width,- };- if dx != 0.0 {- let (gs, ge) = (line.glyph_start, line.glyph_end);- for g in out.glyphs.get_mut(gs..ge).unwrap_or(&mut []) {- g.x += dx;- }- if let Some(l) = out.lines.get_mut(li) {- l.x = dx;- }- }- }-- // Decorations per contiguous glyph run of the same style on a line; a decoration colour- // paints with a style of its own, added after the glyphs' styles.- let mut decos = Vec::new();- let mut deco_styles: Vec<TextStyle> = Vec::new();- for line in &out.lines {- let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);- let mut j = 0;- while j < gl.len() {- let Some(g0) = gl.get(j) else { break };- // List markers and the ellipsis are not decorated.- if g0.char_end == g0.char_start {- j += 1;- continue;- }- let st = out.styles.get(g0.style).cloned().unwrap_or_default();- let mut k = j + 1;- while gl.get(k).is_some_and(|g| g.style == g0.style && g.char_end > g.char_start) {- k += 1;- }- if st.decoration != TextDecoration::None- && let Some(last) = gl.get(k - 1)- {- let x0 = g0.x;- let x1 = last.x + last.advance;- // The style's own font and size (not a fallback face or a small-cap scale), so the- // line runs at one height and weight through mixed scripts.- let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face);- let size = sanitize_size(st.font_size);- let s = size / f.upem;- let look = st.look();- let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size);- let underline = st.decoration == TextDecoration::Underline;- // The line's top edge (an underline's own offset is measured from the baseline).- let top = match look.offset {- DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0,- m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size),- _ => line.baseline - f.strike_pos * s - th / 2.0,- };- let style = match look.color {- Some(c) => {- let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() };- let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| {- deco_styles.push(own);- deco_styles.len() - 1- });- out.styles.len() + k- }- None => g0.style,- };- let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 };- // Skip ink: the underline stops a little before each glyph crossing it.- let gap = th.max(1.0);- let cuts = if underline && look.skip_ink {- ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap)- } else {- Vec::new()- };- let mut from = x0;- for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) {- if c0.min(x1) > from + 0.5 {- let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing);- decos.push(Decoration { rect, style, kind: look.style, thickness: th });- }- from = from.max(c1);- }- }- j = k;- }- }- out.decorations = decos;- out.styles.extend(deco_styles);- if let Some(tp) = &text.path {- crate::onpath::place(&mut out, tp, text.align_h);- } out } @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true publish = false +[lints]+workspace = true+ [dependencies] serde_json = { workspace = true } figcraft-engine = { workspace = true }