diff --git a/README.md b/README.md index bb43d61..3cb648e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@

Layouts that build themselves

- Auto layout in both directions, with wrap and grid, padding, gaps, alignment, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, and drag handles for padding and spacing right on the canvas. Constraints and layout grids for everything else. + Auto layout in both directions, with wrap and grid (fixed, hug and `fr` tracks, cells that span), padding, gaps, alignment including text baselines, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, and drag handles for padding and spacing right on the canvas. Constraints and layout grids for everything else.

Components that scale

diff --git a/ROADMAP.md b/ROADMAP.md index 1444dd4..ec722f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ continuous Claude Opus 5.5 agent work (including builds and the CI gate), for ** **4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity table below and updated as work lands. -_Last updated: 2026-10-10 (vector edit mode tools)._ +_Last updated: 2026-10-10 (vector edit mode tools, auto layout depth)._ ## Where we are @@ -47,8 +47,11 @@ dimension measured side by side. 2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, truncation/max lines, font picker with previews and recents, word/line selection, IME commit. *Left:* inline IME preedit, variable font axes, visual QA. 8–12 h. -3. **Auto layout depth**: *landed:* on-canvas padding/gap handles. *Left:* drag-reorder polish, min/max UI, baseline - alignment, absolute positioning UI, grid tracks (fr/fixed), wrap polish. 15–25 h. +3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid + column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text + wraps at its max), ignore auto layout (absolute position) with constraints, text baseline + alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI. *Left:* + grid/wrap-aware drag-reorder and drag-to-cell, nested hug/fill edge cases. 6–10 h. 4. **Components depth**: *landed (engine):* component properties (edit/delete/bind), add variant, reset override, restore main, instance structure protection. *Left:* properties panel UI, instance swap UI, nested overrides, slots, local libraries, Assets drag-to-canvas. 14–20 h. @@ -121,7 +124,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 | | Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 | | Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 | -| Auto layout & constraints | 9 | 45% | canvas handles, baseline, grid tracks, nested hug/fill edge cases | 15–25 | +| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 35% | in-text mouse selection, styled ranges UI, lists, OpenType, variable fonts | 20–30 | | Components & variants | 9 | 40% | properties UI, swap UI, nested overrides, libraries, slots | 20–30 | | Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 | @@ -130,7 +133,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 | | Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 | -Weighted feature depth ≈ **45%**. +Weighted feature depth ≈ **47%**. ## Milestones @@ -139,7 +142,7 @@ Weighted feature depth ≈ **45%**. | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | | M0.9 CLI + MCP, web build, release packaging, PDF export | in progress | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~60% | -| M2 Layout depth | ~45% | +| M2 Layout depth | ~60% | | M3 Components & styles depth | ~40% | | M4 Vector & text depth | ~45% | | M5 Prototype & present | ~30% | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 590baea..dd925be 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -298,9 +298,13 @@ pub struct AutoLayout { pub padding: [f64; 4], pub main_align: MainAlign, pub cross_align: CrossAlign, - /// Grid mode: column and row counts. + /// Grid mode: column and row counts (rows grow to fit the children). pub grid_columns: u32, pub grid_rows: u32, + /// Grid mode: column track sizes (missing tracks are `1fr`). + pub column_tracks: Vec, + /// Grid mode: row track sizes (missing tracks are `1fr`). + pub row_tracks: Vec, /// Include strokes in layout sizes. pub strokes_included: bool, /// Last item on top (canvas stacking). @@ -319,6 +323,8 @@ impl Default for AutoLayout { cross_align: CrossAlign::Min, grid_columns: 2, grid_rows: 2, + column_tracks: Vec::new(), + row_tracks: Vec::new(), strokes_included: false, reverse_z: false, } @@ -347,6 +353,34 @@ impl Sizing { } } +/// One grid auto layout track (a column or a row): a fixed size in px, hugging its contents, or +/// filling a share of the free space (`value` fractional units, like CSS `fr`). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct GridTrack { + pub sizing: Sizing, + /// Fixed: the size in px. Fill: the fractional units. Hug: unused. + pub value: f64, +} + +impl Default for GridTrack { + fn default() -> Self { + Self { sizing: Sizing::Fill, value: 1.0 } + } +} + +impl GridTrack { + pub fn fixed(px: f64) -> Self { + Self { sizing: Sizing::Fixed, value: px } + } + pub fn hug() -> Self { + Self { sizing: Sizing::Hug, value: 0.0 } + } + pub fn fill(fr: f64) -> Self { + Self { sizing: Sizing::Fill, value: fr } + } +} + /// Layout properties a node has as a child (of an auto layout frame) or as a resizable box. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] @@ -359,6 +393,23 @@ pub struct ChildLayout { pub max_w: Option, pub min_h: Option, pub max_h: Option, + /// Grid auto layout: anchor column / row (0-based); `None` = placed automatically. + pub grid_column: Option, + pub grid_row: Option, + /// Grid auto layout: how many columns / rows the child spans (0 is read as 1). + pub grid_column_span: u32, + pub grid_row_span: u32, +} + +impl ChildLayout { + /// Column span, at least 1. + pub fn column_span(&self) -> u32 { + self.grid_column_span.max(1) + } + /// Row span, at least 1. + pub fn row_span(&self) -> u32 { + self.grid_row_span.max(1) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f1bd7a4..bf32fe8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -264,6 +264,19 @@ fn serde_roundtrip_and_repair() { assert!(bad.validate().is_empty(), "{:?}", bad.validate()); } +#[test] +fn auto_layout_loads_files_without_grid_tracks() { + // Saved before grid tracks, cells and spans existed. + let al: AutoLayout = serde_json::from_str(r#"{"mode":"grid","gap":4,"grid_columns":3,"grid_rows":2}"#).unwrap(); + assert_eq!((al.grid_columns, al.column_tracks.len(), al.row_tracks.len()), (3, 0, 0)); + let cl: ChildLayout = serde_json::from_str(r#"{"sizing_h":"fill","absolute":true,"min_w":10}"#).unwrap(); + assert_eq!((cl.grid_column, cl.grid_row, cl.column_span(), cl.row_span()), (None, None, 1, 1)); + // Tracks round-trip; a track missing its value reads as the default kind's value. + let t: GridTrack = serde_json::from_str(r#"{"sizing":"fixed","value":120}"#).unwrap(); + assert_eq!(t, GridTrack::fixed(120.0)); + assert_eq!(serde_json::from_str::("{}").unwrap(), GridTrack::fill(1.0)); +} + #[test] fn node_id_parse() { assert_eq!("12".parse::().unwrap(), NodeId(12)); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs new file mode 100644 index 0000000..5643ac7 --- /dev/null +++ b/crates/engine/src/cmd/layout.rs @@ -0,0 +1,270 @@ +//! Auto layout commands beyond `layout.add` / `layout.remove` (in `structure`) and the layout +//! keys of `node.set` (gap, padding, alignment, sizing, min/max, absolute, grid cells): grid tracks. + +use figcraft_doc::{GridTrack, LayoutMode, Sizing}; +use serde_json::Value; + +use super::props::sizing; +use super::{CommandSpec, ok}; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +pub fn specs() -> Vec { + vec![spec!( + "layout.setTrack", + "Set grid track size", + "{ids?, axis: columns|rows, index (0-based), sizing: fixed|hug|fill, value? (fixed: px, default the track's current size; fill: fr, default 1)}", + true, + set_track + )] +} + +fn set_track(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layout.setTrack"; + let ids = req_targets(CMD, s, p)?; + let columns = match string(p, "axis").map(|a| a.to_ascii_lowercase()).as_deref() { + Some("columns" | "column" | "col" | "x") => true, + Some("rows" | "row" | "y") => false, + _ => return Err(bad(CMD, "`axis` must be columns or rows")), + }; + let index = req_num(CMD, p, "index")?; + let kind = get(p, "sizing").and_then(sizing).ok_or_else(|| bad(CMD, "`sizing` must be fixed, hug or fill"))?; + let value = num(p, "value"); + let doc = s.doc_mut()?; + for id in ids { + // Current track sizes (switching to Fixed keeps the size the track has now). + let tracks_now = figcraft_layout::grid_geometry(doc, id).map(|g| if columns { g.columns } else { g.rows }).unwrap_or_default(); + let n = doc.get_mut(id)?; + let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else { + return Err(bad(CMD, format!("node {id} is not a grid auto layout frame"))); + }; + let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize); + if !(0.0..count as f64).contains(&index) { + return Err(bad(CMD, format!("`index` must be 0..{count}"))); + } + let i = index as usize; + let track = match kind { + Sizing::Fixed => { + let current = tracks_now.get(i).map(|(a, b)| b - a).unwrap_or(100.0); + GridTrack::fixed(value.unwrap_or(current).clamp(0.0, 1e6)) + } + Sizing::Hug => GridTrack::hug(), + Sizing::Fill => GridTrack::fill(value.unwrap_or(1.0).clamp(0.0, 1e4)), + }; + let tracks = if columns { &mut l.column_tracks } else { &mut l.row_tracks }; + if tracks.len() <= i { + tracks.resize(i + 1, GridTrack::default()); + } + if let Some(t) = tracks.get_mut(i) { + *t = track; + } + } + ok() +} + +#[cfg(test)] +mod tests { + use figcraft_doc::{CrossAlign, Document, GridTrack, LayoutMode, Node, NodeId, Sizing}; + use serde_json::{Value, json}; + + use crate::{EngineError, Session, params}; + + fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() + } + + fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) + } + + fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().node(id).unwrap().clone() + } + + fn x(s: &Session, id: NodeId) -> (f64, f64) { + let c = node(s, id).transform.as_coeffs(); + (c[4], c[5]) + } + + /// A horizontal auto layout frame (padding 0, gap 10) with two 40×20 rectangles. + fn al_frame(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let f = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100, "props": {"layout": "horizontal", "gap": 10}}))); + let a = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f}))); + let b = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f}))); + (f, a, b) + } + + #[test] + fn min_max_props_undo_and_order() { + let mut s = Session::new(); + let (f, a, _) = al_frame(&mut s); + ex(&mut s, "node.set", json!({"id": f, "props": {"sizingH": "hug"}})); + assert_eq!(node(&s, f).width, 90.0); + ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": 200}})); + assert_eq!(node(&s, f).width, 200.0); + // A max below the min pulls the min down with it. + ex(&mut s, "node.set", json!({"id": f, "props": {"maxWidth": 150}})); + let n = node(&s, f); + assert_eq!((n.child_layout.min_w, n.child_layout.max_w, n.width), (Some(150.0), Some(150.0), 150.0)); + s.undo().unwrap(); + assert_eq!(node(&s, f).width, 200.0); + // null removes the limit. + ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": null, "maxWidth": null}})); + assert_eq!(node(&s, f).width, 90.0); + // Child limits clamp a fixed child; negatives become 0; junk is refused and rolled back. + ex(&mut s, "node.set", json!({"id": a, "props": {"minWidth": 70, "maxHeight": -5}})); + let n = node(&s, a); + assert_eq!((n.width, n.height, n.child_layout.max_h), (70.0, 0.0, Some(0.0))); + let before = s.ds().unwrap().history.undo.len(); + assert!(s.execute("node.set", &json!({"id": a, "props": {"minWidth": "wide"}})).is_err()); + assert!(s.execute("node.set", &json!({"id": a, "props": {"maxWidth": [1]}})).is_err()); + assert_eq!(s.ds().unwrap().history.undo.len(), before); + assert_eq!(node(&s, a).child_layout.min_w, Some(70.0)); + } + + #[test] + fn absolute_child_keeps_place_and_follows_constraints() { + let mut s = Session::new(); + let (f, a, b) = al_frame(&mut s); + ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": "fill"}})); + let at = x(&s, b); + ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": true, "constraints": {"horizontal": "right"}}})); + let n = node(&s, b); + assert!(n.child_layout.absolute); + assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "an absolute child can't fill"); + assert_eq!(x(&s, b), at, "toggling absolute keeps the child where it is"); + // The flow ignores it: changing the gap moves nothing else. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingLeft": 30}})); + assert_eq!(x(&s, a).0, 30.0); + assert_eq!(x(&s, b), at); + // Resizing the frame applies its constraints (right: keeps its right margin). + let right_margin = 300.0 - at.0 - node(&s, b).width; + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 400}})); + assert!((400.0 - x(&s, b).0 - node(&s, b).width - right_margin).abs() < 1e-9); + // Undo the resize, then back into the flow. + s.undo().unwrap(); + assert_eq!(x(&s, b), at); + ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": false}})); + assert_eq!(x(&s, b).0, 30.0 + 40.0 + 10.0); + assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); + } + + #[test] + fn baseline_alignment_lines_up_text() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal"}}))); + let small = id(&ex(&mut s, "text.create", json!({"text": "Small", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 12}}))); + let big = id(&ex(&mut s, "text.create", json!({"text": "Big", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}})); + assert_eq!(node(&s, f).layout.unwrap().cross_align, CrossAlign::Baseline); + let base = + |s: &Session, t: NodeId| figcraft_text::layout_node(&node(s, t)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy)); + // Only meaningful when the text was measured (fonts available). + if let (Some(bs), Some(bb)) = (base(&s, small), base(&s, big)) + && (bb - bs).abs() > 1.0 + { + assert!((x(&s, small).1 + bs - (x(&s, big).1 + bb)).abs() < 1e-6); + } + // Vertical layouts treat baseline as left. + ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "vertical"}})); + assert_eq!(x(&s, small).0, x(&s, big).0); + } + + #[test] + fn grid_tracks_cells_and_undo() { + let mut s = Session::new(); + let f = id(&ex( + &mut s, + "frame.create", + json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid", "gridColumns": 3, "columnGap": 10, "rowGap": 10}}), + )); + let kids: Vec = + (0..4).map(|_| id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f})))).collect(); + ex(&mut s, "node.set", json!({"id": f, "props": {"columnTracks": [100, "1fr", "auto"]}})); + let l = node(&s, f).layout.unwrap(); + assert_eq!(l.column_tracks, vec![GridTrack::fixed(100.0), GridTrack::fill(1.0), GridTrack::hug()]); + // 320 - 20 gaps - 100 - 20 (hug) = 180 for the 1fr column. + assert_eq!(x(&s, kids[1]).0, 110.0); + assert_eq!(x(&s, kids[2]).0, 300.0); + // layout.setTrack: switching to fixed keeps the current size. + ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "columns", "index": 1, "sizing": "fixed"})); + assert_eq!(node(&s, f).layout.unwrap().column_tracks.get(1).copied(), Some(GridTrack::fixed(180.0))); + ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "rows", "index": 0, "sizing": "fixed", "value": 50})); + assert_eq!(node(&s, f).layout.unwrap().row_tracks, vec![GridTrack::fixed(50.0)]); + s.undo().unwrap(); + assert!(node(&s, f).layout.unwrap().row_tracks.is_empty()); + // Cells: anchor the last child at column 2, row 1, span the first over two columns. + ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": 2, "gridRow": 1}})); + ex(&mut s, "node.set", json!({"id": kids[0], "props": {"gridColumnSpan": 2, "sizingH": "fill"}})); + assert_eq!(node(&s, kids[0]).width, 100.0 + 10.0 + 180.0); + let info = crate::inspect::node_json(s.doc().unwrap(), kids[3], 0); + assert_eq!(info["gridCell"]["column"], json!(2)); + assert_eq!(info["gridCell"]["row"], json!(1)); + assert_eq!(info["gridCell"]["anchored"], json!(true)); + ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}})); + assert_eq!(node(&s, kids[3]).child_layout.grid_column, None); + // Fewer columns drop the extra tracks. + ex(&mut s, "node.set", json!({"id": f, "props": {"gridColumns": 2}})); + assert_eq!(node(&s, f).layout.unwrap().column_tracks.len(), 2); + // Errors roll back. + for bad in [ + json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}), + json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}), + json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}), + json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}), + json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}), + ] { + assert!(s.execute("layout.setTrack", &bad).is_err(), "{bad}"); + } + assert!(s.execute("node.set", &json!({"id": f, "props": {"columnTracks": ["1fr", "wide"]}})).is_err()); + assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err()); + } + + #[test] + fn layout_props_survive_hostile_values() { + let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")]; + let keys = [ + "minWidth", + "maxWidth", + "minHeight", + "maxHeight", + "absolute", + "columnTracks", + "rowTracks", + "gridColumn", + "gridRow", + "gridColumnSpan", + "gridRowSpan", + "gridColumns", + "gridRows", + "columnGap", + "rowGap", + "crossAlign", + ]; + for k in keys { + for j in &junk { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"layout": "grid"}}))); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": f}))); + for target in [f, r] { + if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": target, "props": {k: j}})) { + panic!("{cmd} panicked on {k}={j}: {msg}"); + } + } + let doc: &Document = s.doc().unwrap(); + for n in [f, r] { + let n = doc.node(n).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{k}={j}"); + } + assert!(doc.node(f).unwrap().layout.as_ref().is_some_and(|l| l.mode == LayoutMode::Grid)); + } + } + // Tracks as track objects and extreme strings. + let t: Vec> = + [json!({"sizing": "fill", "value": 2}), json!({"sizing": "fixed"}), json!("120px"), json!("1e400fr"), json!("hug")] + .iter() + .map(super::super::props::track_of) + .collect(); + assert_eq!(t, vec![Some(GridTrack::fill(2.0)), None, Some(GridTrack::fixed(120.0)), None, Some(GridTrack::hug())]); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4f93a73..dcdb4ca 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod component; pub mod create; pub mod edit; pub mod file; +pub mod layout; pub mod page; pub mod props; pub mod proto; @@ -64,6 +65,7 @@ fn registry() -> &'static Registry { list.extend(edit::specs()); list.extend(arrange::specs()); list.extend(structure::specs()); + list.extend(layout::specs()); list.extend(file::specs()); list.extend(page::specs()); list.extend(view::specs()); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 0974064..b5f8d84 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,8 +13,10 @@ pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (paren fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \ strokeCap / startCap / endCap, strokeJoin, dashes [..], cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ -mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \ -absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ +mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap, \ +columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \ +gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, sizingH / sizingV (fixed|hug|fill), \ +absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \ (widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), pointCount, innerRadius, arcStart, arcSweep, \ @@ -73,7 +75,7 @@ pub(crate) fn enum_of(v: &Value) -> Option { serde_json::from_value(Value::String(norm)).ok() } -fn sizing(v: &Value) -> Option { +pub(crate) fn sizing(v: &Value) -> Option { match v.as_str()?.to_ascii_lowercase().as_str() { "fixed" => Some(Sizing::Fixed), "hug" | "auto" | "hug contents" => Some(Sizing::Hug), @@ -114,6 +116,53 @@ fn four(v: &Value) -> Option<[f64; 4]> { } } +const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]"; + +/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`, +/// or `{sizing, value}`. +pub fn track_of(v: &Value) -> Option { + match v { + Value::Number(_) => f64_of(v).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))), + Value::String(s) => { + let s = s.trim().to_ascii_lowercase(); + match s.as_str() { + "auto" | "hug" => Some(GridTrack::hug()), + "fill" => Some(GridTrack::fill(1.0)), + _ => match s.strip_suffix("fr") { + Some(fr) => fr.trim().parse::().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))), + None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))), + }, + } + } + Value::Object(o) => { + let s = o.get("sizing").or_else(|| o.get("type")).and_then(sizing)?; + let val = o.get("value").or_else(|| o.get("size")).and_then(f64_of); + Some(match s { + Sizing::Fixed => GridTrack::fixed(val?.clamp(0.0, 1e6)), + Sizing::Hug => GridTrack::hug(), + Sizing::Fill => GridTrack::fill(val.unwrap_or(1.0).clamp(0.0, 1e4)), + }) + } + _ => None, + } +} + +/// Set one min/max limit (`None` removes it), keeping min ≤ max by moving the other limit. +pub fn set_limit(cl: &mut ChildLayout, key: &str, val: Option) { + let (target, other, is_min) = match key { + "minWidth" => (&mut cl.min_w, &mut cl.max_w, true), + "maxWidth" => (&mut cl.max_w, &mut cl.min_w, false), + "minHeight" => (&mut cl.min_h, &mut cl.max_h, true), + _ => (&mut cl.max_h, &mut cl.min_h, false), + }; + *target = val; + if let (Some(v), Some(o)) = (val, *other) + && (is_min && o < v || !is_min && o > v) + { + *other = Some(v); + } +} + fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> { let id = n.id; n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text"))) @@ -260,9 +309,11 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), } } - "gap" | "itemSpacing" => ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?, - "crossGap" | "counterAxisSpacing" => { - ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))? + "gap" | "itemSpacing" | "columnGap" => { + ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "crossGap" | "counterAxisSpacing" | "rowGap" => { + ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? } "padding" => { let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; @@ -294,8 +345,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false), - "gridColumns" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32, - "gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32, + "gridColumns" | "gridRows" => { + let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let l = ensure_layout(doc.get_mut(id)?); + // Tracks past the new count go away (new tracks are 1fr). + let (n, tracks) = + if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; + *n = count; + tracks.truncate(count as usize); + } "strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false), "reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false), "sizingH" | "layoutSizingHorizontal" => { @@ -306,11 +364,63 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.child_layout.absolute = v.as_bool().unwrap_or(false), - "minWidth" => doc.get_mut(id)?.child_layout.min_w = f64_of(v), - "maxWidth" => doc.get_mut(id)?.child_layout.max_w = f64_of(v), - "minHeight" => doc.get_mut(id)?.child_layout.min_h = f64_of(v), - "maxHeight" => doc.get_mut(id)?.child_layout.max_h = f64_of(v), + "absolute" | "ignoreAutoLayout" => { + let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; + let cl = &mut doc.get_mut(id)?.child_layout; + cl.absolute = on; + // An absolute child can't fill: it keeps its current size. + if on { + for s in [&mut cl.sizing_h, &mut cl.sizing_v] { + if *s == Sizing::Fill { + *s = Sizing::Fixed; + } + } + } + } + "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { + let val = match v { + Value::Null => None, + other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), + }; + set_limit(&mut doc.get_mut(id)?.child_layout, k, val); + } + "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { + let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; + let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; + let l = ensure_layout(doc.get_mut(id)?); + if k.contains("olumn") { + l.grid_columns = (tracks.len() as u32).clamp(1, 1000); + l.column_tracks = tracks; + } else { + l.grid_rows = (tracks.len() as u32).clamp(1, 1000); + l.row_tracks = tracks; + } + } + "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => { + Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) + as u32) + } + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.starts_with("gridColumn") { + cl.grid_column = val; + } else { + cl.grid_row = val; + } + } + "gridColumnSpan" | "gridRowSpan" => { + let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let cl = &mut doc.get_mut(id)?.child_layout; + if k == "gridColumnSpan" { + cl.grid_column_span = span; + } else { + cl.grid_row_span = span; + } + } "constraints" => { let n = doc.get_mut(id)?; if let Some(h) = v.get("horizontal") { @@ -649,7 +759,8 @@ pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> let kx = if ow > 1e-9 { w / ow } else { 1.0 }; let ky = if oh > 1e-9 { h / oh } else { 1.0 }; figcraft_layout::scale_group_children(doc, id, kx, ky); - } else if doc.get(id)?.layout.is_none() { + } else { + // In an auto layout frame this moves only the children that ignore auto layout. figcraft_layout::apply_constraints(doc, id, ow, oh); } Ok(()) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 2891835..02976c5 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -75,10 +75,27 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); } - if n.child_layout.absolute { + let cl = &n.child_layout; + if cl.absolute { o.insert("absolute".into(), json!(true)); } - if !parent_al && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { + for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { + if let Some(v) = v { + o.insert(k.into(), json!(v)); + } + } + if let Some(cell) = parent_al + .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) + .flatten() + .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) + { + o.insert( + "gridCell".into(), + json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some()}), + ); + } + if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); } if n.kind.is_frame_like() { diff --git a/crates/engine/src/measure.rs b/crates/engine/src/measure.rs index fd51df6..69dabce 100644 --- a/crates/engine/src/measure.rs +++ b/crates/engine/src/measure.rs @@ -16,6 +16,19 @@ impl figcraft_layout::Measure for TextMeasure { } fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> { let n = doc.node(id)?; - figcraft_text::auto_size(n) + let NodeKind::Text(t) = &n.kind else { return None }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + return figcraft_text::auto_size(n); + } + // Natural (unwrapped) size, whatever the resize mode. + let mut t = t.clone(); + t.auto_resize = TextAutoResize::WidthAndHeight; + let l = figcraft_text::layout_cached(&t, None, None); + Some((l.width.ceil().max(1.0), l.height.max(1.0))) + } + fn text_baseline(&self, doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + let (l, dy) = figcraft_text::layout_node(n)?; + l.lines.first().map(|line| line.baseline + dy).filter(|b| b.is_finite()) } } diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 7024515..c0f46f6 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -46,7 +46,12 @@ pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result { } pub fn boolean(p: &Value, key: &str) -> Option { - match get(p, key)? { + bool_of(get(p, key)?) +} + +/// A lenient boolean: `true`, `1`, `"yes"`, `"on"`… +pub fn bool_of(v: &Value) -> Option { + match v { Value::Bool(b) => Some(*b), Value::Number(n) => n.as_f64().map(|f| f != 0.0), Value::String(s) => match s.to_ascii_lowercase().as_str() { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 73cab13..0ddb0dd 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1,9 +1,11 @@ //! FigCraft layout. //! //! - [`layout_page`] / [`layout_frame`]: Figma-style auto layout. Children of an auto layout frame -//! are stacked horizontally or vertically (optionally wrapping) or placed on a grid, with gap, -//! padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the free -//! space). A Hug container sizes to its children. Absolute children are left alone. +//! are stacked horizontally or vertically (optionally wrapping, optionally aligned on their first +//! text baseline) or placed on a grid with fixed / hug / `fr` tracks and cells that span, with +//! gap, padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the +//! free space), within its min/max size. A Hug container sizes to its children. Absolute +//! children (ignoring auto layout) are not placed; they follow their constraints instead. //! - [`apply_constraints`]: children of a regular frame follow their constraints when it resizes. //! - [`grid_lines`]: the column/row rectangles of a layout grid, for drawing and snapping. //! @@ -20,8 +22,13 @@ use figcraft_geom::{Affine, Point, Rect}; pub trait Measure { /// The height a text node needs at `width`. fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option; - /// Natural (auto width) size of a text node. + /// Natural (auto width, unwrapped) size of a text node, whatever its resize mode. fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)>; + /// Distance from the top of a text node's box to its first line's baseline (for baseline + /// alignment). `None` when unknown (non-text, no fonts); the item then aligns to the top. + fn text_baseline(&self, _doc: &Document, _id: NodeId) -> Option { + None + } } /// A measurer that never changes text sizes. @@ -36,7 +43,8 @@ impl Measure for NoMeasure { } } -fn clamp_size(v: f64, min: Option, max: Option) -> f64 { +/// Clamp a size to `[min, max]` (non-finite or negative sizes become 0; when min > max, min wins). +pub fn clamp_size(v: f64, min: Option, max: Option) -> f64 { let mut v = if v.is_finite() { v.max(0.0) } else { 0.0 }; if let Some(m) = max.filter(|m| m.is_finite()) { v = v.min(m); @@ -47,6 +55,10 @@ fn clamp_size(v: f64, min: Option, max: Option) -> f64 { v } +fn finite_or_zero(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + /// The child's box size as seen by the parent (rotation → bounding box). fn outer_size(doc: &Document, id: NodeId) -> (f64, f64) { let Some(n) = doc.node(id) else { return (0.0, 0.0) }; @@ -108,6 +120,88 @@ pub fn layout_up(doc: &mut Document, id: NodeId, m: &dyn Measure) { } } +/// The children an auto layout frame places: visible and not ignoring auto layout. +pub fn flow_children(doc: &Document, frame: NodeId) -> Vec { + let Some(f) = doc.node(frame) else { return Vec::new() }; + f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect() +} + +/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the stroke weight +/// when strokes are included in the layout. +fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) { + let stroke_pad = if al.strokes_included && f.has_visible_stroke() { finite_or_zero(f.stroke.weight).max(0.0) } else { 0.0 }; + let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 }); + (pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad) +} + +/// Text that hugs its width: refresh its natural size, honouring min/max width by wrapping (a +/// hugging text wider than its max width wraps at the max; narrower than its min width gets the min). +fn fit_hug_text(doc: &mut Document, id: NodeId, m: &dyn Measure) { + let Some(n) = doc.node(id) else { return }; + let NodeKind::Text(t) = &n.kind else { return }; + let cl = &n.child_layout; + let auto_width = t.auto_resize == TextAutoResize::WidthAndHeight; + // A hugging text that wrapped at its max width earlier is auto height now. + let wrapped_hug = cl.sizing_h == Sizing::Hug && t.auto_resize == TextAutoResize::Height; + if !auto_width && !wrapped_hug { + return; + } + let (min_w, max_w) = (cl.min_w, cl.max_w); + let Some((nw, nh)) = m.text_size(doc, id) else { return }; + let w = clamp_size(nw, min_w, max_w); + let limited = (w - nw).abs() > 1e-6; + let h = if limited { m.text_height(doc, id, w).unwrap_or(nh) } else { nh }; + if let Some(n) = doc.node_mut(id) + && let NodeKind::Text(t) = &mut n.kind + { + let mode = if limited { TextAutoResize::Height } else { TextAutoResize::WidthAndHeight }; + if t.auto_resize != mode { + t.auto_resize = mode; + } + } + set_size(doc, id, w, h); +} + +/// Clamp an in-flow child's own size to its min/max (fill axes are clamped when distributed). +fn enforce_limits(doc: &mut Document, id: NodeId, m: &dyn Measure) { + let Some(n) = doc.node(id) else { return }; + let cl = &n.child_layout; + if cl.min_w.is_none() && cl.max_w.is_none() && cl.min_h.is_none() && cl.max_h.is_none() { + return; + } + let (w, h) = (n.width, n.height); + let (nw, nh) = (clamp_size(w, cl.min_w, cl.max_w), clamp_size(h, cl.min_h, cl.max_h)); + if (nw - w).abs() < 1e-9 && (nh - h).abs() < 1e-9 { + return; + } + if matches!(n.kind, NodeKind::Text(_)) { + // Keep the text's resize mode (hugging width was handled by `fit_hug_text`). + set_size(doc, id, nw, nh); + } else { + resize_child(doc, id, nw, nh, m); + } +} + +/// Distance from the top of a child's box to its first text baseline: text measures it, an auto +/// layout frame uses its first placed child's baseline. `None` for other layers and rotated ones. +fn item_baseline(doc: &Document, id: NodeId, m: &dyn Measure, depth: usize) -> Option { + let n = doc.node(id)?; + let c = n.transform.as_coeffs(); + if c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0 { + return None; + } + match &n.kind { + NodeKind::Text(_) => m.text_baseline(doc, id).filter(|b| b.is_finite()), + _ if n.layout.is_some() && n.kind.is_frame_like() && depth < 32 => flow_children(doc, id).into_iter().find_map(|k| { + let b = item_baseline(doc, k, m, depth + 1)?; + let kn = doc.node(k)?; + let top = figcraft_geom::transform_rect_bbox(kn.transform, kn.local_rect()).y0; + Some(top + b) + }), + _ => None, + } +} + struct Item { id: NodeId, main: f64, @@ -118,6 +212,10 @@ struct Item { max_main: Option, min_cross: Option, max_cross: Option, + /// Baseline alignment: distance from the item's top to its first baseline. + baseline: Option, + /// Baseline alignment: offset from the line's top (computed per line). + base_off: f64, } /// Lay out one auto layout frame's children and size the frame if it hugs. @@ -126,32 +224,28 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug); - let (fw, fh) = (f.width, f.height); - let stroke_pad = if al.strokes_included && f.has_visible_stroke() { f.stroke.weight } else { 0.0 }; - let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 }); - let (pt, pr, pb, pl) = (pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad); - let kids: Vec = f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect(); + let (ow, oh) = (f.width, f.height); + // The frame's own min/max apply whatever its sizing. + let fcl = f.child_layout.clone(); + let (fw, fh) = (clamp_size(ow, fcl.min_w, fcl.max_w), clamp_size(oh, fcl.min_h, fcl.max_h)); + let (pt, pr, pb, pl) = insets(f, &al); + let kids = flow_children(doc, frame); - // Text children: refresh their natural size first. + // Text children refresh their natural size, then every child honours its min/max. for c in &kids { - if let Some(n) = doc.node(*c) - && let NodeKind::Text(t) = &n.kind - && t.auto_resize == TextAutoResize::WidthAndHeight - && let Some((w, h)) = m.text_size(doc, *c) - { - set_size(doc, *c, w, h); - } + fit_hug_text(doc, *c, m); + enforce_limits(doc, *c, m); } if matches!(al.mode, LayoutMode::Grid) { - layout_grid(doc, frame, &al, &kids, (pt, pr, pb, pl), m); + layout_grid(doc, frame, &al, m); return; } let inner_main_fixed = if horizontal { fw - pl - pr } else { fh - pt - pb }; - let inner_cross_fixed = if horizontal { fh - pt - pb } else { fw - pl - pr }; let hug_main = if horizontal { hug_w } else { hug_h }; let hug_cross = if horizontal { hug_h } else { hug_w }; + let baseline = horizontal && al.cross_align == CrossAlign::Baseline; let mut items: Vec = Vec::with_capacity(kids.len()); for c in &kids { @@ -161,20 +255,23 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { 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) }; + let fill_cross = sc == Sizing::Fill && !hug_cross; items.push(Item { id: *c, main: if horizontal { w } else { h }, cross: if horizontal { h } else { w }, fill_main: sm == Sizing::Fill && !hug_main, - fill_cross: sc == Sizing::Fill && !hug_cross, + fill_cross, min_main, max_main, min_cross, max_cross, + baseline: if baseline && !fill_cross { item_baseline(doc, *c, m, 0) } else { None }, + base_off: 0.0, }); } - let gap = if al.gap.is_finite() { al.gap } else { 0.0 }; + let gap = finite_or_zero(al.gap); let between = al.main_align == MainAlign::SpaceBetween; // Split into lines (wrap only on the main axis of a horizontal layout, like Figma). @@ -234,23 +331,29 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { open = still; } } + // 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 main_used: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum::() + gaps; - let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.cross).fold(0.0, f64::max); + let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max); line_extents.push((main_used, cross)); } // Container size. let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max); - let cross_gap = if al.cross_gap.is_finite() { al.cross_gap } else { 0.0 }; + let cross_gap = finite_or_zero(al.cross_gap); let content_cross: f64 = line_extents.iter().map(|l| l.1).sum::() + cross_gap * lines.len().saturating_sub(1) as f64; let (mut nw, mut nh) = (fw, fh); - let fcl = doc.node(frame).map(|n| n.child_layout.clone()).unwrap_or_default(); if hug_main { let v = content_main + if horizontal { pl + pr } else { pt + pb }; if horizontal { - nw = clamp_size(v, fcl.min_w, fcl.max_w); + nw = v; } else { - nh = clamp_size(v, fcl.min_h, fcl.max_h); + nh = v; } } if hug_cross { @@ -259,15 +362,16 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let cc = if lines.len() <= 1 { content_cross.max(max_fill_cross) } else { content_cross }; let v = cc + if horizontal { pt + pb } else { pl + pr }; if horizontal { - nh = clamp_size(v, fcl.min_h, fcl.max_h); + nh = v; } else { - nw = clamp_size(v, fcl.min_w, fcl.max_w); + nw = v; } } + let (nw, nh) = (clamp_size(nw, fcl.min_w, fcl.max_w), clamp_size(nh, fcl.min_h, fcl.max_h)); set_size(doc, frame, nw, nh); + apply_constraints(doc, frame, ow, oh); let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb }; let inner_cross = if horizontal { nh - pt - pb } else { nw - pl - pr }; - let _ = inner_cross_fixed; // Position. let mut cross_pos = if horizontal { pt } else { pl }; @@ -290,7 +394,9 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { 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 | CrossAlign::Baseline => 0.0, + 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, }; @@ -305,8 +411,6 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } cross_pos += line_cross + cross_gap; } - // Hugging after fill-cross text re-wrapped: heights may have changed; one more pass is cheap. - let _ = AutoLayout::default(); } fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) { @@ -326,81 +430,303 @@ fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) let old = doc.node(id).map(|n| (n.width, n.height)); set_size(doc, id, w, h); if let Some((ow, oh)) = old - && doc.node(id).is_some_and(|n| n.layout.is_none() && n.kind.is_frame_like()) + && doc.node(id).is_some_and(|n| n.kind.is_frame_like()) { apply_constraints(doc, id, ow, oh); - } else if doc.node(id).is_some_and(|n| n.layout.is_some()) { + } + if doc.node(id).is_some_and(|n| n.layout.is_some()) { layout_frame(doc, id, m); } } } -fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, kids: &[NodeId], pad: (f64, f64, f64, f64), m: &dyn Measure) { - let (pt, pr, pb, pl) = pad; - let Some(f) = doc.node(frame) else { return }; - let cols = al.grid_columns.clamp(1, 1000) as usize; - let rows_needed = kids.len().div_ceil(cols).max(al.grid_rows.clamp(1, 1000) as usize); - let (fw, fh) = (f.width, f.height); - let gx = if al.gap.is_finite() { al.gap.max(0.0) } else { 0.0 }; - let gy = if al.cross_gap.is_finite() { al.cross_gap.max(0.0) } else { 0.0 }; - let hug_w = f.child_layout.sizing_h == Sizing::Hug; - let hug_h = f.child_layout.sizing_v == Sizing::Hug; - // Track sizes: equal (1fr) unless hugging, then the max child per track. - let mut col_w = vec![0.0f64; cols]; - let mut row_h = vec![0.0f64; rows_needed]; - for (i, c) in kids.iter().enumerate() { - let (w, h) = outer_size(doc, *c); - if let Some(cw) = col_w.get_mut(i % cols) { - *cw = cw.max(w); - } - if let Some(rh) = row_h.get_mut(i / cols) { - *rh = rh.max(h); - } - } - let total_w = if hug_w { col_w.iter().sum::() + gx * (cols - 1) as f64 + pl + pr } else { fw }; - let total_h = if hug_h { row_h.iter().sum::() + gy * (rows_needed - 1) as f64 + pt + pb } else { fh }; - set_size(doc, frame, total_w, total_h); - if !hug_w { - let cw = ((total_w - pl - pr - gx * (cols - 1) as f64) / cols as f64).max(0.0); - col_w = vec![cw; cols]; - } - if !hug_h { - let rh = ((total_h - pt - pb - gy * (rows_needed - 1) as f64) / rows_needed as f64).max(0.0); - row_h = vec![rh; rows_needed]; - } - for (i, c) in kids.iter().enumerate() { - let (ci, ri) = (i % cols, i / cols); - let x = pl + col_w.iter().take(ci).sum::() + gx * ci as f64; - let y = pt + row_h.iter().take(ri).sum::() + gy * ri as f64; - let (cw, rh) = (col_w.get(ci).copied().unwrap_or(0.0), row_h.get(ri).copied().unwrap_or(0.0)); - let Some(n) = doc.node(*c) else { continue }; - let (fill_w, fill_h) = (n.child_layout.sizing_h == Sizing::Fill, n.child_layout.sizing_v == Sizing::Fill); - let (w, h) = outer_size(doc, *c); - let (nw, nh) = (if fill_w { cw } else { w }, if fill_h { rh } else { h }); - if fill_w || fill_h { - resize_child(doc, *c, nw, nh, m); - } - let ox = match al.main_align { - MainAlign::Center => (cw - nw) / 2.0, - MainAlign::Max => cw - nw, - _ => 0.0, - }; - let oy = match al.cross_align { - CrossAlign::Center => (rh - nh) / 2.0, - CrossAlign::Max => rh - nh, - _ => 0.0, - }; - place(doc, *c, x + ox, y + oy); +/// Largest grid (cells) the layout handles; rows beyond it overlap the last row. +const MAX_GRID_CELLS: usize = 1_000_000; +const MAX_TRACKS: usize = 1000; + +/// A grid auto layout child's cell (0-based anchor column/row and spans, clamped to the grid). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GridCell { + pub id: NodeId, + pub column: usize, + pub row: usize, + pub column_span: usize, + pub row_span: usize, +} + +/// A grid auto layout frame's resolved geometry (frame-local): column x ranges, row y ranges, +/// each placed child's cell, and the frame size the grid wants. +#[derive(Clone, Debug, PartialEq)] +pub struct GridGeometry { + pub columns: Vec<(f64, f64)>, + pub rows: Vec<(f64, f64)>, + pub cells: Vec, + pub width: f64, + pub height: f64, +} + +impl GridGeometry { + /// The frame-local rectangle a cell (with its spans) covers. + pub fn cell_rect(&self, c: &GridCell) -> Rect { + let x0 = self.columns.get(c.column).map(|t| t.0).unwrap_or(0.0); + let x1 = self.columns.get(c.column + c.column_span - 1).map(|t| t.1).unwrap_or(x0); + let y0 = self.rows.get(c.row).map(|t| t.0).unwrap_or(0.0); + let y1 = self.rows.get(c.row + c.row_span - 1).map(|t| t.1).unwrap_or(y0); + Rect::new(x0, y0, x1.max(x0), y1.max(y0)) } } -/// After frame `id` (without auto layout) changed size from `(ow, oh)`, move/resize its children -/// per their constraints (recursively for children frames that resize). +/// Place grid children in cells: children with an anchor column and row go there first; the rest +/// flow left to right, top to bottom into the next free cells that fit their spans. Returns the +/// cells (in child order) and the row count (at least the frame's row count). +pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec, usize) { + let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS); + let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS); + let mut occ: Vec> = Vec::new(); + let free = |occ: &Vec>, c: usize, r: usize, cs: usize, rs: usize| { + (r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false)))) + }; + let mark = |occ: &mut Vec>, c: usize, r: usize, cs: usize, rs: usize| { + while occ.len() < r + rs { + occ.push(vec![false; cols]); + } + for row in occ.iter_mut().skip(r).take(rs) { + for cell in row.iter_mut().skip(c).take(cs) { + *cell = true; + } + } + }; + let spans = |id: NodeId| { + let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default(); + let cs = (cl.column_span() as usize).min(cols); + let rs = (cl.row_span() as usize).min(max_rows); + (cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs) + }; + let mut placed: Vec> = vec![None; kids.len()]; + // 1. Fully anchored children. + for (i, id) in kids.iter().enumerate() { + if let (Some(c), Some(r), cs, rs) = spans(*id) { + mark(&mut occ, c, r, cs, rs); + if let Some(slot) = placed.get_mut(i) { + *slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs }); + } + } + } + // 2. The rest, in order (a cursor keeps auto-placed children in reading order). + let mut cursor = (0usize, 0usize); // (row, column) + for (i, id) in kids.iter().enumerate() { + if placed.get(i).is_some_and(Option::is_some) { + continue; + } + let (col, row, cs, rs) = spans(*id); + let last_row = max_rows - rs; + let (c, r) = match (col, row) { + (Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)), + (None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)), + (None, None) => { + let mut found = None; + let (mut r, mut c) = cursor; + while r <= last_row { + if c + cs <= cols && free(&occ, c, r, cs, rs) { + found = Some((c, r)); + break; + } + c += 1; + if c + cs > cols { + c = 0; + r += 1; + } + } + let (c, r) = found.unwrap_or((0, last_row)); + cursor = (r, c + cs); + (c, r) + } + }; + mark(&mut occ, c, r, cs, rs); + if let Some(slot) = placed.get_mut(i) { + *slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs }); + } + } + let cells: Vec = placed.into_iter().flatten().collect(); + let rows = cells.iter().map(|c| c.row + c.row_span).max().unwrap_or(0).max((al.grid_rows as usize).clamp(1, MAX_TRACKS)); + (cells, rows) +} + +/// Track `i`'s sizing and sanitised value (missing tracks are `1fr`). +fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) { + let t = specs.get(i).copied().unwrap_or_default(); + let v = if t.value.is_finite() { t.value.clamp(0.0, 1e6) } else { 0.0 }; + (t.sizing, v) +} + +/// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks +/// share what is left by their fractional units. `avail` is None when the frame hugs that axis +/// (fill tracks then hug their content too). +fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option) -> Vec { + let mut out: Vec = Vec::with_capacity(content.len()); + let mut total_fr = 0.0; + let mut used = 0.0; + for (i, c) in content.iter().enumerate() { + let size = match track_spec(specs, i) { + (Sizing::Fixed, v) => v, + (Sizing::Fill, v) if avail.is_some() => { + total_fr += v; + 0.0 + } + _ => *c, + }; + used += size; + out.push(size); + } + if let Some(a) = avail + && total_fr > 1e-12 + { + let free = (a - used).max(0.0); + for (i, s) in out.iter_mut().enumerate() { + if let (Sizing::Fill, v) = track_spec(specs, i) { + *s = free * v / total_fr; + } + } + } + out +} + +/// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content too): +/// the largest single-span child; a spanning child that needs more than its tracks give it grows +/// the content-sized tracks it spans (unless a flexible track can absorb it). +fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridTrack], n: usize, gap: f64, columns: bool, hug: bool) -> Vec { + let by_content = |i: usize| match track_spec(specs, i).0 { + Sizing::Hug => true, + Sizing::Fill => hug, + Sizing::Fixed => false, + }; + let mut content: Vec = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect(); + let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) }; + for (c, s) in cells.iter().zip(sizes) { + let (start, len) = span(c); + if len == 1 + && by_content(start) + && let Some(t) = content.get_mut(start) + { + *t = t.max(*s); + } + } + for (c, s) in cells.iter().zip(sizes) { + let (start, len) = span(c); + if len < 2 || (!hug && (start..start + len).any(|i| track_spec(specs, i).0 == Sizing::Fill)) { + continue; + } + let have: f64 = content.iter().skip(start).take(len).sum::() + gap * (len - 1) as f64; + let grow: Vec = (start..start + len).filter(|i| by_content(*i)).collect(); + if *s > have && !grow.is_empty() { + let extra = (*s - have) / grow.len() as f64; + for i in grow { + if let Some(t) = content.get_mut(i) { + *t += extra; + } + } + } + } + content +} + +fn track_ranges(start: f64, sizes: &[f64], gap: f64) -> Vec<(f64, f64)> { + let mut x = start; + sizes + .iter() + .map(|s| { + let r = (x, x + s); + x += s + gap; + r + }) + .collect() +} + +/// Resolve a grid auto layout frame's tracks and cells from its children's current sizes. +pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option { + let f = doc.node(frame)?; + let al = f.layout.as_ref()?; + if al.mode != LayoutMode::Grid { + return None; + } + let (pt, pr, pb, pl) = insets(f, al); + let kids = flow_children(doc, frame); + let (cells, rows) = grid_place(doc, al, &kids); + let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS); + let gx = finite_or_zero(al.gap).max(0.0); + let gy = finite_or_zero(al.cross_gap).max(0.0); + let hug_w = f.child_layout.sizing_h == Sizing::Hug; + let hug_h = f.child_layout.sizing_v == Sizing::Hug; + // What each child asks of its tracks: its size, or (filling a non-hugging axis) its minimum. + let (mut ws, mut hs) = (Vec::with_capacity(cells.len()), Vec::with_capacity(cells.len())); + for c in &cells { + let (w, h) = outer_size(doc, c.id); + let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default(); + ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w }); + hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h }); + } + let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w); + let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h); + let gaps_w = gx * cols.saturating_sub(1) as f64; + let gaps_h = gy * rows.saturating_sub(1) as f64; + let avail_w = (!hug_w).then_some(f.width - pl - pr - gaps_w); + let avail_h = (!hug_h).then_some(f.height - pt - pb - gaps_h); + let col_w = resolve_tracks(&al.column_tracks, &col_content, avail_w); + let row_h = resolve_tracks(&al.row_tracks, &row_content, avail_h); + let cl = &f.child_layout; + let width = if hug_w { col_w.iter().sum::() + gaps_w + pl + pr } else { f.width }; + let height = if hug_h { row_h.iter().sum::() + gaps_h + pt + pb } else { f.height }; + Some(GridGeometry { + columns: track_ranges(pl, &col_w, gx), + rows: track_ranges(pt, &row_h, gy), + cells, + width: clamp_size(width, cl.min_w, cl.max_w), + height: clamp_size(height, cl.min_h, cl.max_h), + }) +} + +fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measure) { + let Some(f) = doc.node(frame) else { return }; + let (fw, fh) = (f.width, f.height); + let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug); + let Some(g) = grid_geometry(doc, frame) else { return }; + set_size(doc, frame, g.width, g.height); + apply_constraints(doc, frame, fw, fh); + // A fixed frame resized by its own min/max: tracks follow the clamped size. + let g = if (g.width - fw).abs() > 1e-9 && !hug_w || (g.height - fh).abs() > 1e-9 && !hug_h { grid_geometry(doc, frame).unwrap_or(g) } else { g }; + for c in &g.cells { + let cell = g.cell_rect(c); + let Some(n) = doc.node(c.id) else { continue }; + let cl = n.child_layout.clone(); + let fill_w = cl.sizing_h == Sizing::Fill && !hug_w; + let fill_h = cl.sizing_v == Sizing::Fill && !hug_h; + let (w, h) = outer_size(doc, c.id); + let nw = if fill_w { clamp_size(cell.width(), cl.min_w, cl.max_w) } else { w }; + let nh = if fill_h { clamp_size(cell.height(), cl.min_h, cl.max_h) } else { h }; + if fill_w || fill_h { + resize_child(doc, c.id, nw, nh, m); + } + let (nw, nh) = outer_size(doc, c.id); + let ox = match al.main_align { + MainAlign::Center => (cell.width() - nw) / 2.0, + MainAlign::Max => cell.width() - nw, + _ => 0.0, + }; + let oy = match al.cross_align { + CrossAlign::Center => (cell.height() - nh) / 2.0, + CrossAlign::Max => cell.height() - nh, + _ => 0.0, + }; + place(doc, c.id, cell.x0 + ox, cell.y0 + oy); + } +} + +/// After frame `id` changed size from `(ow, oh)`, move/resize its children per their constraints +/// (recursively for children frames that resize). In an auto layout frame only the children that +/// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { let Some(f) = doc.node(id) else { return }; - if f.layout.is_some() { - return; - } + let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); if (nw - ow).abs() < 1e-9 && (nh - oh).abs() < 1e-9 { return; @@ -414,6 +740,9 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { } for c in kids { let Some(n) = doc.node(c) else { continue }; + if absolute_only && !n.child_layout.absolute { + continue; + } let lin = linear(n.transform); let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); let (cw, ch) = (n.width, n.height); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 80de15f..196b375 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -162,3 +162,288 @@ fn hostile_values() { layout_frame(&mut d, f, &NoMeasure); let _ = grid_lines(&LayoutGrid { size: 0.0, ..Default::default() }, 1e9, 1e9); } + +/// A fake measurer: each text has a natural (unwrapped) size and a first baseline; wrapping at a +/// narrower width adds lines of the natural height. +struct FakeMeasure(std::collections::HashMap); + +impl Measure for FakeMeasure { + fn text_height(&self, _: &Document, id: NodeId, width: f64) -> Option { + let (w, h, _) = self.0.get(&id).copied()?; + Some(h * (w / width.max(1.0)).ceil().max(1.0)) + } + fn text_size(&self, _: &Document, id: NodeId) -> Option<(f64, f64)> { + self.0.get(&id).map(|(w, h, _)| (*w, *h)) + } + fn text_baseline(&self, _: &Document, id: NodeId) -> Option { + self.0.get(&id).map(|t| t.2) + } +} + +fn text(d: &mut Document, parent: NodeId, name: &str) -> NodeId { + d.create(parent, NodeKind::Text(figcraft_doc::TextData::new(name)), name, 0.0, 0.0, 10.0, 10.0).unwrap() +} + +#[test] +fn min_max_clamp_fixed_children_and_frame() { + let (mut d, f, k) = setup(LayoutMode::Horizontal); + d.get_mut(k[0]).unwrap().child_layout.min_w = Some(80.0); + d.get_mut(k[1]).unwrap().child_layout.max_h = Some(25.0); + d.get_mut(f).unwrap().child_layout.max_w = Some(200.0); + layout_frame(&mut d, f, &NoMeasure); + assert_eq!(pos(&d, k[0]).2, 80.0); + assert_eq!(pos(&d, k[1]), (95.0, 5.0, 30.0, 25.0)); + // A fixed frame wider than its max width is clamped too. + assert_eq!(d.node(f).unwrap().width, 200.0); +} + +#[test] +fn min_max_on_hug_and_fill() { + let (mut d, f, _) = setup(LayoutMode::Horizontal); + { + let n = d.get_mut(f).unwrap(); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.min_w = Some(200.0); + } + layout_frame(&mut d, f, &NoMeasure); + assert_eq!(d.node(f).unwrap().width, 200.0); + { + let n = d.get_mut(f).unwrap(); + n.child_layout.min_w = None; + n.child_layout.max_w = Some(60.0); + } + layout_frame(&mut d, f, &NoMeasure); + assert_eq!(d.node(f).unwrap().width, 60.0); + // A fill child with a min width wider than its share keeps the min. + let (mut d, f, k) = setup(LayoutMode::Horizontal); + for c in &k { + d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill; + } + d.get_mut(k[0]).unwrap().child_layout.min_w = Some(200.0); + layout_frame(&mut d, f, &NoMeasure); + assert_eq!(pos(&d, k[0]).2, 200.0); + assert_eq!(pos(&d, k[1]).2, 80.0); +} + +#[test] +fn min_over_max_and_junk_limits() { + let (mut d, f, k) = setup(LayoutMode::Vertical); + { + let cl = &mut d.get_mut(k[0]).unwrap().child_layout; + cl.min_w = Some(90.0); + cl.max_w = Some(40.0); + cl.min_h = Some(f64::NAN); + cl.max_h = Some(f64::NEG_INFINITY); + } + layout_frame(&mut d, f, &NoMeasure); + // Min wins over a smaller max; non-finite limits are ignored. + assert_eq!(pos(&d, k[0]).2, 90.0); + assert_eq!(pos(&d, k[0]).3, 20.0); + assert_eq!(clamp_size(-5.0, None, None), 0.0); + assert_eq!(clamp_size(f64::NAN, Some(3.0), None), 3.0); +} + +#[test] +fn hug_text_wraps_at_max_width() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(f).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..Default::default() }); + let t = text(&mut d, f, "Long"); + d.get_mut(t).unwrap().child_layout.sizing_h = Sizing::Hug; + let m = FakeMeasure([(t, (200.0, 20.0, 15.0))].into_iter().collect()); + layout_frame(&mut d, f, &m); + assert_eq!(pos(&d, t).2, 200.0); + d.get_mut(t).unwrap().child_layout.max_w = Some(80.0); + layout_frame(&mut d, f, &m); + let n = d.node(t).unwrap(); + assert_eq!((n.width, n.height), (80.0, 60.0)); + assert!(matches!(&n.kind, NodeKind::Text(tx) if tx.auto_resize == TextAutoResize::Height)); + // Without the max it hugs its natural width again. + d.get_mut(t).unwrap().child_layout.max_w = None; + layout_frame(&mut d, f, &m); + let n = d.node(t).unwrap(); + assert_eq!((n.width, n.height), (200.0, 20.0)); + assert!(matches!(&n.kind, NodeKind::Text(tx) if tx.auto_resize == TextAutoResize::WidthAndHeight)); +} + +#[test] +fn absolute_children_follow_constraints() { + let (mut d, f, _) = setup(LayoutMode::Horizontal); + let abs = d.create(f, NodeKind::Rectangle, "Badge", 280.0, 4.0, 10.0, 10.0).unwrap(); + { + let n = d.get_mut(abs).unwrap(); + n.child_layout.absolute = true; + n.constraints = figcraft_doc::Constraints { horizontal: HConstraint::Right, vertical: VConstraint::Top }; + } + d.get_mut(f).unwrap().child_layout.sizing_h = Sizing::Hug; + layout_frame(&mut d, f, &NoMeasure); + // Hugging shrank the frame 300 → 100: the badge keeps its distance from the right edge. + assert_eq!(d.node(f).unwrap().width, 100.0); + assert_eq!(pos(&d, abs), (80.0, 4.0, 10.0, 10.0)); + assert!(!flow_children(&d, f).contains(&abs)); + // A plain resize of an auto layout frame moves only absolute children by their constraints. + let first = pos(&d, d.node(f).unwrap().children[0]); + set_size(&mut d, f, 150.0, 100.0); + apply_constraints(&mut d, f, 100.0, 100.0); + assert_eq!(pos(&d, abs).0, 130.0); + assert_eq!(pos(&d, d.node(f).unwrap().children[0]), first); +} + +#[test] +fn baseline_alignment_horizontal() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(f).unwrap().layout = Some(AutoLayout { padding: [5.0; 4], gap: 0.0, cross_align: CrossAlign::Baseline, ..Default::default() }); + d.get_mut(f).unwrap().child_layout.sizing_v = Sizing::Hug; + let small = text(&mut d, f, "small"); + let big = text(&mut d, f, "big"); + let rect = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 20.0).unwrap(); + let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0))].into_iter().collect()); + layout_frame(&mut d, f, &m); + // Baselines line up at 5 + 30; the rectangle (no baseline) sits at the top. + assert_eq!(pos(&d, small).1, 25.0); + assert_eq!(pos(&d, big).1, 5.0); + assert_eq!(pos(&d, rect).1, 5.0); + // Hug height covers the shifted small text: max(20 + 14, 40) + padding. + assert_eq!(d.node(f).unwrap().height, 50.0); + // Nested auto layout frames align on their first child's baseline. + let outer = d.create(p, NodeKind::Frame, "O", 0.0, 0.0, 400.0, 100.0).unwrap(); + d.get_mut(outer).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], gap: 0.0, cross_align: CrossAlign::Baseline, ..Default::default() }); + d.reparent(f, outer, None).unwrap(); + let t2 = text(&mut d, outer, "t2"); + let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0)), (t2, (20.0, 60.0, 50.0))].into_iter().collect()); + layout_page(&mut d, p, &m); + // Frame baseline = 5 + 30 = 35; t2's is 50 → the frame moves down 15. + assert_eq!(pos(&d, f).1, 15.0); + assert_eq!(pos(&d, t2).1, 0.0); + // Vertical layouts read baseline as left. + d.get_mut(outer).unwrap().layout.as_mut().unwrap().mode = LayoutMode::Vertical; + layout_frame(&mut d, outer, &m); + assert_eq!(pos(&d, f).0, 0.0); + assert_eq!(pos(&d, t2).0, 0.0); +} + +fn grid_frame(cols: u32, w: f64, h: f64) -> (Document, NodeId) { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "G", 0.0, 0.0, w, h).unwrap(); + d.get_mut(f).unwrap().layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: cols, + grid_rows: 1, + gap: 10.0, + cross_gap: 10.0, + padding: [0.0; 4], + ..Default::default() + }); + (d, f) +} + +#[test] +fn grid_tracks_fixed_hug_fr() { + use figcraft_doc::GridTrack; + let (mut d, f) = grid_frame(4, 400.0, 100.0); + d.get_mut(f).unwrap().layout.as_mut().unwrap().column_tracks = + vec![GridTrack::fixed(50.0), GridTrack::hug(), GridTrack::fill(1.0), GridTrack::fill(2.0)]; + let ids: Vec = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 30.0 + i as f64, 20.0).unwrap()).collect(); + for c in &ids { + d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill; + } + // The hug column fits its (fixed) child. + d.get_mut(ids[1]).unwrap().child_layout.sizing_h = Sizing::Fixed; + layout_frame(&mut d, f, &NoMeasure); + let g = grid_geometry(&d, f).unwrap(); + // 400 - 3 gaps (30) - 50 - 31 = 289 free → 1fr 96.33, 2fr 192.67. + assert_eq!(g.columns[0], (0.0, 50.0)); + assert_eq!(g.columns[1], (60.0, 91.0)); + assert!((g.columns[2].1 - g.columns[2].0 - 289.0 / 3.0).abs() < 1e-9); + assert!((g.columns[3].1 - 400.0).abs() < 1e-9); + assert_eq!(pos(&d, ids[0]).2, 50.0); + assert!((pos(&d, ids[3]).2 - 289.0 * 2.0 / 3.0).abs() < 1e-9); + // The only row fills the height. + assert_eq!(g.rows, vec![(0.0, 100.0)]); +} + +#[test] +fn grid_cells_spans_and_anchors() { + let (mut d, f) = grid_frame(3, 320.0, 200.0); + let ids: Vec = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect(); + // R0 spans two columns and fills them; R3 is anchored at column 0, row 2. + { + let cl = &mut d.get_mut(ids[0]).unwrap().child_layout; + cl.grid_column_span = 2; + cl.sizing_h = Sizing::Fill; + } + { + let cl = &mut d.get_mut(ids[3]).unwrap().child_layout; + cl.grid_column = Some(0); + cl.grid_row = Some(2); + } + layout_frame(&mut d, f, &NoMeasure); + let g = grid_geometry(&d, f).unwrap(); + let cell = |id: NodeId| g.cells.iter().find(|c| c.id == id).copied().unwrap(); + assert_eq!((cell(ids[0]).column, cell(ids[0]).row, cell(ids[0]).column_span), (0, 0, 2)); + assert_eq!((cell(ids[1]).column, cell(ids[1]).row), (2, 0)); + assert_eq!((cell(ids[2]).column, cell(ids[2]).row), (0, 1)); + assert_eq!((cell(ids[3]).column, cell(ids[3]).row), (0, 2)); + assert_eq!(g.rows.len(), 3); + // Columns are 100 wide: the spanning child covers 100 + 10 + 100. + assert_eq!(pos(&d, ids[0]).2, 210.0); + assert_eq!(pos(&d, ids[1]).0, 220.0); + assert_eq!(pos(&d, ids[3]).1, 140.0); +} + +#[test] +fn grid_hug_with_span() { + use figcraft_doc::GridTrack; + let (mut d, f) = grid_frame(2, 10.0, 10.0); + { + let n = d.get_mut(f).unwrap(); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.layout.as_mut().unwrap().column_tracks = vec![GridTrack::hug(), GridTrack::fixed(20.0)]; + } + d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 40.0, 10.0).unwrap(); + let wide = d.create(f, NodeKind::Rectangle, "W", 0.0, 0.0, 100.0, 30.0).unwrap(); + d.get_mut(wide).unwrap().child_layout.grid_column_span = 2; + layout_frame(&mut d, f, &NoMeasure); + // Column 0 grows so the span fits: 100 - 10 gap - 20 fixed = 70. + let n = d.node(f).unwrap(); + assert_eq!((n.width, n.height), (100.0, 10.0 + 10.0 + 30.0)); + assert_eq!(pos(&d, wide).1, 20.0); +} + +#[test] +fn grid_hostile_values() { + use figcraft_doc::GridTrack; + let (mut d, f) = grid_frame(0, f64::NAN, -5.0); + { + let l = d.get_mut(f).unwrap().layout.as_mut().unwrap(); + l.column_tracks = vec![GridTrack { sizing: Sizing::Fixed, value: f64::NAN }, GridTrack::fill(f64::INFINITY), GridTrack::fill(-3.0)]; + l.row_tracks = vec![GridTrack::fixed(1e300)]; + l.grid_rows = u32::MAX; + } + let r = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + { + let cl = &mut d.get_mut(r).unwrap().child_layout; + cl.grid_column = Some(u32::MAX); + cl.grid_row = Some(u32::MAX); + cl.grid_column_span = u32::MAX; + cl.grid_row_span = u32::MAX; + cl.sizing_h = Sizing::Fill; + } + for i in 0..20 { + let c = d.create(f, NodeKind::Rectangle, &format!("K{i}"), 0.0, 0.0, 10.0, 10.0).unwrap(); + d.get_mut(c).unwrap().child_layout.grid_column_span = 3; + } + layout_frame(&mut d, f, &NoMeasure); + let g = grid_geometry(&d, f).unwrap(); + assert!(g.columns.len() == 1 && g.rows.len() <= 1000); + assert!(g.width.is_finite() && g.height.is_finite()); + for id in d.node(f).unwrap().children.clone() { + let (x, y, w, h) = pos(&d, id); + assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite()); + } +} diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index d2d756d..3c26253 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -1,6 +1,7 @@ //! On-canvas editing handles that sit above the pointer tools: gradient handles (while a //! gradient fill is open in the colour picker), image crop handles (while a Crop-mode image -//! fill is open) and auto layout padding/gap handles. +//! fill is open) and auto layout padding/gap handles (item gaps, wrapped row gaps, grid column +//! and row gaps, with the grid's tracks outlined). use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2}; use figcraft_color::{ImagePaint, ImageScaleMode, Paint, PaintKind}; @@ -17,34 +18,13 @@ const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); #[derive(Clone, Debug)] enum Drag { /// Gradient handle 0 = from, 1 = to, 2 = width. - Gradient { - id: NodeId, - fills: bool, - index: usize, - which: usize, - }, + Gradient { id: NodeId, fills: bool, index: usize, which: usize }, /// Padding side 0 top, 1 right, 2 bottom, 3 left. - Padding { - id: NodeId, - side: usize, - start: Pos2, - value: f64, - }, - Gap { - id: NodeId, - start: Pos2, - value: f64, - vertical: bool, - }, + Padding { id: NodeId, side: usize, start: Pos2, value: f64 }, + /// A gap band: `key` is the prop it edits (`gap` or `crossGap`), `vertical` the drag axis. + Gap { id: NodeId, start: Pos2, value: f64, vertical: bool, key: &'static str }, /// Image crop: corner 0..4 (tl, tr, br, bl) scales about the opposite corner, 4 moves. - Crop { - id: NodeId, - fills: bool, - index: usize, - which: usize, - start: (f64, f64), - rect: (f64, f64, f64, f64), - }, + Crop { id: NodeId, fills: bool, index: usize, which: usize, start: (f64, f64), rect: (f64, f64, f64, f64) }, } /// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box). @@ -111,9 +91,25 @@ fn screen_to_unit(app: &FigApp, id: NodeId, s: Pos2) -> Option<(f64, f64)> { Some((l.x / n.width.max(1e-9), l.y / n.height.max(1e-9))) } -/// Auto layout zones of a single selected AL frame, in screen space: padding bands and gaps. -/// (frame, padding bands (side, rect, value), gap rects, gap, vertical) -type Zones = (NodeId, Vec<(usize, Rect, f64)>, Vec, f64, bool); +/// A gap band between auto layout items (screen space): the prop it edits, its value and the +/// drag axis. +#[derive(Clone, Copy)] +struct GapZone { + rect: Rect, + key: &'static str, + value: f64, + vertical: bool, +} + +/// Auto layout zones of a single selected AL frame, in screen space. +struct Zones { + id: NodeId, + /// Padding bands: (side, rect, value). + bands: Vec<(usize, Rect, f64)>, + gaps: Vec, + /// Grid frames: the track boundaries to outline (screen-space segments). + tracks: Vec<[Pos2; 2]>, +} fn al_zones(app: &FigApp) -> Option { let ds = app.session.ds().ok()?; @@ -133,30 +129,73 @@ fn al_zones(app: &FigApp) -> Option { let p = w * Point::new(x, y); app.to_screen(p.x, p.y) }; + let band = |x0: f64, y0: f64, x1: f64, y1: f64| Rect::from_two_pos(to(x0, y0), to(x1, y1)); let [pt, pr, pb, pl] = al.padding; let (fw, fh) = (n.width, n.height); let bands = vec![ - (0, Rect::from_two_pos(to(0.0, 0.0), to(fw, pt.max(4.0))), pt), - (1, Rect::from_two_pos(to(fw - pr.max(4.0), 0.0), to(fw, fh)), pr), - (2, Rect::from_two_pos(to(0.0, fh - pb.max(4.0)), to(fw, fh)), pb), - (3, Rect::from_two_pos(to(0.0, 0.0), to(pl.max(4.0), fh)), pl), + (0, band(0.0, 0.0, fw, pt.max(4.0)), pt), + (1, band(fw - pr.max(4.0), 0.0, fw, fh), pr), + (2, band(0.0, fh - pb.max(4.0), fw, fh), pb), + (3, band(0.0, 0.0, pl.max(4.0), fh), pl), ]; - let vertical = al.mode == LayoutMode::Vertical; + let main = |rect: Rect, vertical: bool| GapZone { rect, key: "gap", value: al.gap, vertical }; + let cross = |rect: Rect, vertical: bool| GapZone { rect, key: "crossGap", value: al.cross_gap, vertical }; let mut gaps = Vec::new(); - if al.mode != LayoutMode::Grid && !al.wrap { - let kids: Vec<_> = n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible && !c.child_layout.absolute).collect(); - for pair in kids.windows(2) { - let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue }; - let ra = figcraft_geom::transform_rect_bbox(a.transform, a.local_rect()); - let rb = figcraft_geom::transform_rect_bbox(b.transform, b.local_rect()); - let r = - if vertical { Rect::from_two_pos(to(pl, ra.y1), to(fw - pr, rb.y0)) } else { Rect::from_two_pos(to(ra.x1, pt), to(rb.x0, fh - pb)) }; - if r.width() > 0.5 && r.height() > 0.5 { - gaps.push(r); + let mut tracks = Vec::new(); + if let Some(g) = figcraft_layout::grid_geometry(doc, id) { + // Grid: bands between columns (column gap) and between rows (row gap), and the tracks. + let (top, bottom) = (g.rows.first().map(|r| r.0).unwrap_or(pt), g.rows.last().map(|r| r.1).unwrap_or(fh - pb)); + let (left, right) = (g.columns.first().map(|c| c.0).unwrap_or(pl), g.columns.last().map(|c| c.1).unwrap_or(fw - pr)); + for pair in g.columns.windows(2) { + if let [a, b] = pair { + gaps.push(main(band(a.1, top, b.0, bottom), false)); + } + } + for pair in g.rows.windows(2) { + if let [a, b] = pair { + gaps.push(cross(band(left, a.1, right, b.0), true)); + } + } + for c in &g.columns { + tracks.push([to(c.0, top), to(c.0, bottom)]); + tracks.push([to(c.1, top), to(c.1, bottom)]); + } + for r in &g.rows { + tracks.push([to(left, r.0), to(right, r.0)]); + tracks.push([to(left, r.1), to(right, r.1)]); + } + } else { + let vertical = al.mode == LayoutMode::Vertical; + let boxes: Vec = figcraft_layout::flow_children(doc, id) + .into_iter() + .filter_map(|c| doc.node(c)) + .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())) + .collect(); + // Lines: a wrapped item starts left of the previous one. + let mut lines: Vec> = Vec::new(); + for b in boxes { + match lines.last_mut() { + Some(line) if vertical || !al.wrap || line.last().is_some_and(|a| b.x0 >= a.x1 - 1e-6) => line.push(b), + _ => lines.push(vec![b]), + } + } + let span = |line: &[figcraft_geom::Rect]| line.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| (a.min(r.y0), b.max(r.y1))); + for line in &lines { + let (y0, y1) = if al.wrap { span(line) } else { (pt, fh - pb) }; + for pair in line.windows(2) { + if let [ra, rb] = pair { + gaps.push(main(if vertical { band(pl, ra.y1, fw - pr, rb.y0) } else { band(ra.x1, y0, rb.x0, y1) }, vertical)); + } + } + } + for pair in lines.windows(2) { + if let [a, b] = pair { + gaps.push(cross(band(pl, span(a).1, fw - pr, span(b).0), true)); } } } - Some((id, bands, gaps, al.gap, vertical)) + gaps.retain(|g| g.rect.width() > 0.5 && g.rect.height() > 0.5); + Some(Zones { id, bands, gaps, tracks }) } /// Draw the handles. @@ -203,12 +242,15 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if !tool_move && dragging.is_none() { return; } - let Some((_, bands, gaps, gap, _)) = al_zones(app) else { return }; + let Some(z) = al_zones(app) else { return }; let active_side = match &dragging { Some(Drag::Padding { side, .. }) => Some(*side), _ => None, }; - for (side, r, v) in &bands { + for seg in &z.tracks { + painter.add(Shape::dashed_line(seg, Stroke::new(1.0, PINK.gamma_multiply(0.6)), 3.0, 3.0)); + } + for (side, r, v) in &z.bands { let hot = hover.is_some_and(|h| r.contains(h)) || active_side == Some(*side); if hot { painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.25)); @@ -219,14 +261,18 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE); } } - let gap_drag = matches!(dragging, Some(Drag::Gap { .. })); - for r in &gaps { - let hot = hover.is_some_and(|h| r.contains(h)) || gap_drag; + let drag_key = match &dragging { + Some(Drag::Gap { key, .. }) => Some(*key), + _ => None, + }; + for g in &z.gaps { + let r = g.rect; + let hot = hover.is_some_and(|h| r.contains(h)) || drag_key == Some(g.key); if hot { - painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.3)); + painter.rect_filled(r, CornerRadius::ZERO, PINK.gamma_multiply(0.3)); let c = r.center(); painter.line_segment([pos2(c.x - 3.0, c.y), pos2(c.x + 3.0, c.y)], Stroke::new(1.0, PINK)); - let galley = painter.layout_no_wrap(crate::canvas::fmt(gap), semibold(10.0), Color32::WHITE); + let galley = painter.layout_no_wrap(crate::canvas::fmt(g.value), semibold(10.0), Color32::WHITE); let lr = Rect::from_center_size(c, galley.size() + vec2(8.0, 4.0)); painter.rect_filled(lr, CornerRadius::same(3), PINK); painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE); @@ -303,7 +349,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { if app.session.tool.tool != figcraft_engine::ToolId::Move || app.session.tool.busy() { return false; } - let Some((id, bands, gaps, gap, vertical)) = al_zones(app) else { return false }; + let Some(Zones { id, bands, gaps, .. }) = al_zones(app) else { return false }; for (side, r, v) in bands { if r.contains(pos) { ctx.set_cursor_icon(if side % 2 == 0 { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); @@ -315,12 +361,12 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { return false; } } - for r in gaps { - if r.contains(pos) { - ctx.set_cursor_icon(if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + for g in gaps { + if g.rect.contains(pos) { + ctx.set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); if pressed { let _ = app.session.begin_interaction("Gap"); - ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gap { id, start: pos, value: gap, vertical })); + ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gap { id, start: pos, value: g.value, vertical: g.vertical, key: g.key })); return true; } return false; @@ -396,10 +442,10 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) { let cmd = if *fills { "fill.set" } else { "stroke.set" }; let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint})); } - Drag::Gap { id, start, value, vertical } => { + Drag::Gap { id, start, value, vertical, key } => { let d = f64::from(if *vertical { pos.y - start.y } else { pos.x - start.x }) / zoom; let v = (value + d).round().max(0.0); - let _ = app.session.execute("node.set", &json!({"ids": [id], "props": {"gap": v}})); + let _ = app.session.execute("node.set", &json!({"ids": [id], "props": { *key: v }})); } } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4c23b47..fc8c2ad 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -116,6 +116,8 @@ pub enum Icon { Prototype, Section2, Logo, + /// Ignore auto layout: a layer pinned at an offset inside its frame. + AbsolutePosition, } struct P<'a> { @@ -647,6 +649,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { p.poly(&[(6.0, 10.0), (9.0, 6.0), (12.5, 4.0)], false); p.poly(&[(10.0, 3.0), (12.8, 3.8), (12.0, 6.6)], false); } + AbsolutePosition => { + // The frame, a small layer near its top-right corner, and the two offsets pinning it. + p.rect(2.5, 2.5, 13.5, 13.5, 1.5); + p.fill_rect(7.0, 6.0, 10.5, 9.5, 0.5); + p.line(10.5, 7.75, 13.5, 7.75); + p.line(8.75, 2.5, 8.75, 6.0); + } Logo => { // FigCraft's own mark: three stacked rounded tiles forming an "F" shape. p.fill_rect(3.0, 2.0, 13.0, 5.5, 1.75); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs new file mode 100644 index 0000000..74fece6 --- /dev/null +++ b/crates/ui-egui/src/layout_panel.rs @@ -0,0 +1,433 @@ +//! The Design panel's layout section: auto layout direction, W/H with their sizing (Fixed / Hug / +//! Fill) and min/max limits, alignment (including text baselines), gaps, padding, grid tracks, +//! a grid child's cell and spans, and clip content. Everything goes through `node.set` props or +//! `layout.*` commands. + +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_doc::*; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::right::{block, common, menu_item, row}; +use crate::theme::{ACCENT, Tokens, regular}; +use crate::widgets::{self, FIELD_H, Lead}; + +/// Most tracks listed in the panel (the rest are still laid out; edit them through commands). +const MAX_TRACK_ROWS: usize = 24; + +fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { + doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)) +} + +pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let ids = sel.to_vec(); + let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let al = common(doc, sel, |n| n.layout.clone()); + let has_al = matches!(al, Some(Some(_))); + let title = if has_al { "Auto layout" } else { "Layout" }; + let btns: Vec<(Icon, &str)> = if frames && !has_al { + vec![(Icon::Plus, "Add auto layout ⇧A")] + } else if has_al { + vec![(Icon::Minus, "Remove auto layout ⌥⇧A")] + } else { + vec![] + }; + if widgets::section_header(ui, title, &btns, false).is_some() { + app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids})); + } + if let Some(Some(l)) = &al { + direction(app, ui, &ids, l); + } + dimensions(app, ui, doc, sel, has_al); + if let Some(Some(l)) = &al { + alignment(app, ui, &ids, l); + spacing(app, ui, &ids, l); + if l.mode == LayoutMode::Grid && sel.len() == 1 { + tracks(app, ui, doc, &ids); + } + } + let grid_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid)); + if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) { + grid_cell(app, ui, doc, sel); + } + if frames { + let clip = common(doc, sel, |n| n.clip); + row(ui, 32.0, |ui| { + if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") { + app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)})); + } + }); + } +} + +fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { + row(ui, 32.0, |ui| { + let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")]; + let active = match (l.mode, l.wrap) { + (LayoutMode::Vertical, _) => 0, + (LayoutMode::Horizontal, false) => 1, + (LayoutMode::Horizontal, true) => 2, + (LayoutMode::Grid, _) => 3, + }; + if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) { + let p = match i { + 0 => json!({"layout": "vertical", "wrap": false}), + 1 => json!({"layout": "horizontal", "wrap": false}), + 2 => json!({"layout": "horizontal", "wrap": true}), + _ => json!({"layout": "grid"}), + }; + app.set_props(ids, p); + } + }); +} + +/// One axis' sizing, min/max keys and labels. +struct Axis { + sizing: &'static str, + min: &'static str, + max: &'static str, + noun: &'static str, + letter: &'static str, +} + +const AXES: [Axis; 2] = [ + Axis { sizing: "sizingH", min: "minWidth", max: "maxWidth", noun: "width", letter: "W" }, + Axis { sizing: "sizingV", min: "minHeight", max: "maxHeight", noun: "height", letter: "H" }, +]; + +/// A node's min (`is_min`) or max limit on one axis. +fn limit(n: &Node, horizontal: bool, is_min: bool) -> Option { + let cl = &n.child_layout; + match (horizontal, is_min) { + (true, true) => cl.min_w, + (true, false) => cl.max_w, + (false, true) => cl.min_h, + (false, false) => cl.max_h, + } +} + +fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool) { + let ids = sel.to_vec(); + let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); + let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); + let col = widgets::col_width(ui.available_width()); + widgets::sublabels(ui, "Dimensions", None); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0); + app.field(&ids, "width", e, |v| json!(v.max(0.0))); + let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0); + app.field(&ids, "height", e, |v| json!(v.max(0.0))); + let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); + if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() { + app.set_props(&ids, json!({"constrainProportions": !locked})); + } + }); + let parent_al = sel.iter().all(|i| parent_of(doc, *i).is_some_and(|p| p.layout.is_some())); + let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); + if !(has_al || parent_al || is_text) { + return; + } + // Min/max apply to auto layout frames and their children; absolute children neither fill nor limit. + let can_fill = parent_al && sel.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)); + let can_limit = has_al || can_fill; + let first = sel.first().and_then(|i| doc.node(*i)); + row(ui, 32.0, |ui| { + for (ax, axis) in AXES.iter().enumerate() { + let horizontal = ax == 0; + let cur = common(doc, sel, |n| if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v }); + let any_limit = + sel.iter().filter_map(|i| doc.node(*i)).any(|n| limit(n, horizontal, true).is_some() || limit(n, horizontal, false).is_some()); + let label = cur.map(|s| s.label()).unwrap_or("Mixed"); + let icon = match cur { + Some(Sizing::Hug) => Icon::Hug, + Some(Sizing::Fill) => Icon::FillContainer, + _ => Icon::Fixed, + }; + let resp = widgets::dropdown(ui, label, col, Some(icon)); + if any_limit { + // Limits set: tick marks on the sizing icon's ends. + let c = pos2(resp.rect.min.x + 14.0, resp.rect.center().y); + let tint = Tokens::get(ui.ctx()).icon_secondary; + for dx in [-8.5, 8.5] { + ui.painter().line_segment([c + vec2(dx, -4.0), c + vec2(dx, 4.0)], Stroke::new(1.0, tint)); + } + } + let current = first.map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0); + let ids2 = ids.clone(); + egui::Popup::menu(&resp).width(200.0).show(|ui| { + let mut opts = vec![("Fixed", "fixed")]; + if has_al || is_text { + opts.push(("Hug contents", "hug")); + } + if can_fill { + opts.push(("Fill container", "fill")); + } + for (l, v) in opts { + if menu_item(ui, l, "") { + app.set_props(&ids2, json!({axis.sizing: v})); + ui.close(); + } + } + if can_limit { + ui.separator(); + let (min, max) = (first.and_then(|n| limit(n, horizontal, true)), first.and_then(|n| limit(n, horizontal, false))); + if min.is_none() && menu_item(ui, &format!("Add min {}", axis.noun), "") { + app.set_props(&ids2, json!({axis.min: current})); + ui.close(); + } + if max.is_none() && menu_item(ui, &format!("Add max {}", axis.noun), "") { + app.set_props(&ids2, json!({axis.max: current})); + ui.close(); + } + if any_limit && menu_item(ui, "Remove min and max", "") { + app.set_props(&ids2, json!({axis.min: null, axis.max: null})); + ui.close(); + } + } + }); + } + }); + if can_limit { + for (ax, axis) in AXES.iter().enumerate() { + limit_row(app, ui, doc, sel, ax == 0, axis, col); + } + } +} + +/// The min/max fields of one axis (shown once either is set): min in the first column, max in the +/// second, an empty slot offers to add the missing one; the button removes both. +fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], horizontal: bool, axis: &Axis, col: f32) { + let nodes: Vec<&Node> = sel.iter().filter_map(|i| doc.node(*i)).collect(); + let has = |is_min: bool| nodes.iter().any(|n| limit(n, horizontal, is_min).is_some()); + if !has(true) && !has(false) { + return; + } + let ids = sel.to_vec(); + let current = nodes.first().map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0); + row(ui, 32.0, |ui| { + for (is_min, key) in [(true, axis.min), (false, axis.max)] { + let lead = format!("{} {}", if is_min { "Min" } else { "Max" }, axis.letter); + if has(is_min) { + let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); + let e = widgets::number_field(ui, Id::new(("lay_limit", key)), Lead::Text(&lead), v, "", col, 1.0); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } else if add_slot(ui, &lead, col).clicked() { + app.set_props(&ids, json!({key: current})); + } + } + if icons::button(ui, Icon::Minus, 24.0, false, &format!("Remove min and max {}", axis.noun)).clicked() { + app.set_props(&ids, json!({axis.min: null, axis.max: null})); + } + }); +} + +/// A quiet placeholder button the size of a field ("+ Max W"). +fn add_slot(ui: &mut Ui, label: &str, width: f32) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click()); + let resp = resp.on_hover_text(format!("Add {}", label.to_lowercase())); + let border = if resp.hovered() { t.field_hover_border } else { t.border }; + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, border), StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), Icon::Plus, t.icon_secondary); + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary); + resp +} + +fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { + let t = Tokens::get(ui.ctx()); + let grid_mode = l.mode == LayoutMode::Grid; + block(ui, 76.0, 6.0, |ui| { + let (grid, _) = ui.allocate_exact_size(vec2(72.0, 64.0), Sense::hover()); + ui.painter().rect_filled(grid, CornerRadius::same(5), t.field); + let horizontal = l.mode != LayoutMode::Vertical; + let cur_main = match l.main_align { + MainAlign::Min => 0, + MainAlign::Center => 1, + MainAlign::Max => 2, + MainAlign::SpaceBetween => 9, + }; + let cur_cross = match l.cross_align { + CrossAlign::Min | CrossAlign::Baseline => 0, + CrossAlign::Center => 1, + CrossAlign::Max => 2, + }; + for yi in 0..3 { + for xi in 0..3 { + let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); + let r = Rect::from_center_size(c, vec2(22.0, 20.0)); + let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); + let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; + let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i; + if on { + ui.painter().rect_filled( + Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })), + CornerRadius::same(1), + ACCENT, + ); + } else { + ui.painter().circle_filled(c, if resp.hovered() { 2.5 } else { 1.5 }, if resp.hovered() { ACCENT } else { t.text_tertiary }); + } + if resp.clicked() { + let m = ["min", "center", "max"][main_i]; + let cr = ["min", "center", "max"][cross_i]; + let main = if l.main_align == MainAlign::SpaceBetween && !grid_mode { "spaceBetween" } else { m }; + app.set_props(ids, json!({"mainAlign": main, "crossAlign": cr})); + } + } + } + ui.vertical(|ui| { + if grid_mode { + // Grid: gap between columns and between rows. + let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), Some(l.gap), "", 96.0, 1.0); + app.field(ids, "columnGap", e, |v| json!(v.max(0.0))); + ui.add_space(8.0); + let e = widgets::number_field(ui, Id::new("al_rowgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", 96.0, 1.0); + app.field(ids, "rowGap", e, |v| json!(v.max(0.0))); + return; + } + let auto = l.main_align == MainAlign::SpaceBetween; + let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; + let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(gap_icon), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0); + app.field(ids, "gap", e, |v| json!(v)); + ui.add_space(8.0); + let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); + egui::Popup::menu(&resp).show(|ui| { + if menu_item(ui, "Fixed", "") { + app.set_props(ids, json!({"mainAlign": "min"})); + ui.close(); + } + if menu_item(ui, "Auto (space between)", "") { + app.set_props(ids, json!({"mainAlign": "spaceBetween"})); + ui.close(); + } + }); + }); + }); + // Text baselines: horizontal stacks only. + if l.mode == LayoutMode::Horizontal { + let on = l.cross_align == CrossAlign::Baseline; + row(ui, 28.0, |ui| { + if widgets::checkbox(ui, on, "Align text baselines") { + app.set_props(ids, json!({"crossAlign": if on { "min" } else { "baseline" }})); + } + }); + } +} + +fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { + let col = widgets::col_width(ui.available_width()); + let [pt, pr, pb, pl] = l.padding; + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), (pl == pr).then_some(pl), "", col, 1.0); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), (pt == pb).then_some(pt), "", col, 1.0); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + }); + if l.mode == LayoutMode::Horizontal && l.wrap { + // Wrapped rows: the gap between rows. + widgets::sublabels(ui, "Row gap", None); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_crossgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", col, 1.0); + app.field(ids, "crossGap", e, |v| json!(v.max(0.0))); + }); + } + if l.mode == LayoutMode::Grid { + widgets::sublabels(ui, "Columns", Some("Rows")); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); + app.field(ids, "gridColumns", e, |v| json!(v.max(1.0))); + let e = widgets::number_field(ui, Id::new("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); + app.field(ids, "gridRows", e, |v| json!(v.max(1.0))); + }); + } +} + +/// Track sizes of a single grid frame: one row per column / row with its size and sizing. +fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { + let Some(id) = ids.first().copied() else { return }; + let Some(l) = doc.node(id).and_then(|n| n.layout.clone()) else { return }; + let Some(g) = figcraft_layout::grid_geometry(doc, id) else { return }; + let col = widgets::col_width(ui.available_width()); + for (axis, specs, ranges) in [("columns", &l.column_tracks, &g.columns), ("rows", &l.row_tracks, &g.rows)] { + widgets::sublabels(ui, if axis == "columns" { "Column sizes" } else { "Row sizes" }, None); + for (i, (a, b)) in ranges.iter().enumerate().take(MAX_TRACK_ROWS) { + let track = specs.get(i).copied().unwrap_or_default(); + let size = ((b - a) * 100.0).round() / 100.0; + let base = json!({"ids": [id], "axis": axis, "index": i}); + let with = |sizing: &str, value: Option| { + let mut p = base.clone(); + p["sizing"] = json!(sizing); + if let Some(v) = value { + p["value"] = json!(v); + } + p + }; + row(ui, 32.0, |ui| { + let label = (i + 1).to_string(); + // Fill tracks edit their fr; fixed and hug tracks show (and set) a px size. + let (value, suffix) = if track.sizing == Sizing::Fill { (track.value, "fr") } else { (size, "") }; + let e = widgets::number_field( + ui, + Id::new(("al_track", axis, i)), + Lead::Text(&label), + Some(value), + suffix, + col, + if suffix.is_empty() { 1.0 } else { 0.1 }, + ); + let fill = track.sizing == Sizing::Fill; + app.field_run(e, "layout.setTrack", |v| with(if fill { "fill" } else { "fixed" }, Some(v.max(0.0)))); + let (label, icon) = match track.sizing { + Sizing::Fixed => ("Fixed", Icon::Fixed), + Sizing::Hug => ("Hug", Icon::Hug), + Sizing::Fill => ("Fill", Icon::FillContainer), + }; + let resp = widgets::dropdown(ui, label, col, Some(icon)); + egui::Popup::menu(&resp).show(|ui| { + for (l, v) in [("Fixed", "fixed"), ("Hug contents", "hug"), ("Fill (fr)", "fill")] { + if menu_item(ui, l, "") { + app.run("layout.setTrack", with(v, None)); + ui.close(); + } + } + }); + }); + } + } +} + +/// A grid child's cell (single selection: column and row, 1-based; auto placement resets them) +/// and its spans. +fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let ids = sel.to_vec(); + let col = widgets::col_width(ui.available_width()); + if let [id] = sel + && let Some(cell) = + parent_of(doc, *id).and_then(|p| figcraft_layout::grid_geometry(doc, p.id)).and_then(|g| g.cells.into_iter().find(|c| c.id == *id)) + { + let anchored = doc.node(*id).is_some_and(|n| n.child_layout.grid_column.is_some() || n.child_layout.grid_row.is_some()); + widgets::sublabels(ui, "Column", Some("Row")); + row(ui, 32.0, |ui| { + // Moving on one axis pins the other where it is, so the child doesn't jump. + let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}}); + let (c0, r0) = ((cell.column + 1) as f64, (cell.row + 1) as f64); + let e = widgets::number_field(ui, Id::new("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); + app.field_run(e, "node.set", |v| at(v, r0)); + let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); + app.field_run(e, "node.set", |v| at(c0, v)); + if anchored && icons::button(ui, Icon::Reset, 24.0, false, "Place automatically").clicked() { + app.set_props(&ids, json!({"gridColumn": null, "gridRow": null})); + } + }); + } + let cs = common(doc, sel, |n| f64::from(n.child_layout.column_span())); + let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span())); + widgets::sublabels(ui, "Column span", Some("Row span")); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0); + app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0))); + let e = widgets::number_field(ui, Id::new("al_span_r"), Lead::None, rs, "", col, 1.0); + app.field(&ids, "gridRowSpan", e, |v| json!(v.max(1.0))); + }); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c94e5ef..1aa9947 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod handles; pub mod home; pub mod icons; pub mod layers; +pub mod layout_panel; pub mod menus; pub mod present; pub mod proto_canvas; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 7ba5fc1..8a95810 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -28,11 +28,21 @@ impl FigApp { /// Apply a field edit: scrubs are live previews committed as one step. pub fn field(&mut self, ids: &[NodeId], key: &str, edit: Option, map: impl Fn(f64) -> Value) { + if ids.is_empty() { + return; + } + self.field_run(edit, "node.set", |v| json!({"ids": ids, "props": { key: map(v) }})); + } + + /// Apply a field edit through any command: `params` builds the command's params from the value. + pub fn field_run(&mut self, edit: Option, cmd: &str, params: impl Fn(f64) -> Value) { match edit { - Some(FieldEdit::Set(v)) => self.set_props(ids, json!({ key: map(v) })), + Some(FieldEdit::Set(v)) => { + self.run(cmd, params(v)); + } Some(FieldEdit::Scrub(v, done)) => { let _ = self.session.begin_interaction("Edit"); - let _ = self.session.execute("node.set", &json!({"ids": ids, "props": { key: map(v) }})); + let _ = self.session.execute(cmd, ¶ms(v)); if done { let _ = self.session.commit_interaction(); } @@ -165,11 +175,16 @@ pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { } pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) { + block(ui, h, (h - FIELD_H) / 2.0, add); +} + +/// A panel row `h` tall whose content is inset `inset_y` from its top and bottom (for content +/// taller than one field). +pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); - let mut child = ui.new_child( - egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, (h - FIELD_H) / 2.0))).layout(egui::Layout::left_to_right(egui::Align::Center)), - ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center))); child.spacing_mut().item_spacing = vec2(8.0, 0.0); add(&mut child); } @@ -190,7 +205,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { None => position(app, ui, &doc, &sel), } widgets::separator(ui); - layout(app, ui, &doc, &sel); + crate::layout_panel::layout_section(app, ui, &doc, &sel); widgets::separator(ui); appearance(app, ui, &doc, &sel); widgets::separator(ui); @@ -399,12 +414,23 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0); let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0); let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0); + let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let absolute = common(doc, sel, |n| n.child_layout.absolute); widgets::sublabels(ui, "Position", None); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field(&ids, "x", e, |v| json!(v)); let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); app.field(&ids, "y", e, |v| json!(v)); + // Children of auto layout frames can ignore the layout (absolute position). + if parent_al { + let on = absolute.unwrap_or(false); + if icons::button(ui, Icon::AbsolutePosition, 24.0, on, if on { "Use auto layout" } else { "Ignore auto layout (absolute position)" }) + .clicked() + { + app.set_props(&ids, json!({"absolute": !on})); + } + } }); widgets::sublabels(ui, "Rotation", None); row(ui, 32.0, |ui| { @@ -415,10 +441,12 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); } }); - // Constraints (children of non-auto-layout frames). - let in_frame = sel - .iter() - .all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && p.layout.is_none())); + // Constraints (children of frames without auto layout, or ignoring it). + let in_frame = sel.iter().all(|i| { + doc.node(*i).is_some_and(|n| { + n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)) + }) + }); if in_frame { let c = common(doc, sel, |n| n.constraints); widgets::sublabels(ui, "Constraints", None); @@ -456,191 +484,6 @@ fn pretty(s: &str) -> String { } } -fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let t = Tokens::get(ui.ctx()); - let ids = sel.to_vec(); - let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); - let al = common(doc, sel, |n| n.layout.clone()); - let has_al = matches!(al, Some(Some(_))); - let title = if has_al { "Auto layout" } else { "Layout" }; - let btns: Vec<(Icon, &str)> = if frames && !has_al { - vec![(Icon::Plus, "Add auto layout ⇧A")] - } else if has_al { - vec![(Icon::Minus, "Remove auto layout ⌥⇧A")] - } else { - vec![] - }; - if let Some(_b) = widgets::section_header(ui, title, &btns, false) { - app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids})); - } - if let Some(Some(l)) = &al { - // Direction. - row(ui, 32.0, |ui| { - let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")]; - let active = match (l.mode, l.wrap) { - (LayoutMode::Vertical, _) => 0, - (LayoutMode::Horizontal, false) => 1, - (LayoutMode::Horizontal, true) => 2, - (LayoutMode::Grid, _) => 3, - }; - if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) { - let p = match i { - 0 => json!({"layout": "vertical", "wrap": false}), - 1 => json!({"layout": "horizontal", "wrap": false}), - 2 => json!({"layout": "horizontal", "wrap": true}), - _ => json!({"layout": "grid"}), - }; - app.set_props(&ids, p); - } - }); - } - // W / H with the proportion lock. - let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); - let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); - let col = widgets::col_width(ui.available_width()); - let half = col; - widgets::sublabels(ui, "Dimensions", None); - row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0); - app.field(&ids, "width", e, |v| json!(v.max(0.0))); - let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0); - app.field(&ids, "height", e, |v| json!(v.max(0.0))); - let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); - if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() { - app.set_props(&ids, json!({"constrainProportions": !locked})); - } - }); - let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); - if has_al || parent_al || is_text { - let sh = common(doc, sel, |n| n.child_layout.sizing_h); - let sv = common(doc, sel, |n| n.child_layout.sizing_v); - row(ui, 32.0, |ui| { - for (cur, key) in [(sh, "sizingH"), (sv, "sizingV")] { - let label = cur.map(|s| s.label()).unwrap_or("Mixed"); - let resp = widgets::dropdown( - ui, - label, - half, - Some(match cur { - Some(Sizing::Hug) => Icon::Hug, - Some(Sizing::Fill) => Icon::FillContainer, - _ => Icon::Fixed, - }), - ); - let ids2 = ids.clone(); - egui::Popup::menu(&resp).show(|ui| { - let mut opts = vec![("Fixed", "fixed")]; - if has_al || is_text { - opts.push(("Hug contents", "hug")); - } - if parent_al { - opts.push(("Fill container", "fill")); - } - for (l, v) in opts { - if menu_item(ui, l, "") { - app.set_props(&ids2, json!({key: v})); - ui.close(); - } - } - }); - } - }); - } - if let Some(Some(l)) = &al { - // Alignment grid + gap. - row(ui, 72.0, |ui| { - let (grid, _) = ui.allocate_exact_size(vec2(72.0, 64.0), Sense::hover()); - ui.painter().rect_filled(grid, CornerRadius::same(5), t.field); - let horizontal = l.mode != LayoutMode::Vertical; - for yi in 0..3 { - for xi in 0..3 { - let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); - let r = Rect::from_center_size(c, vec2(22.0, 20.0)); - let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); - let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; - let cur_main = match l.main_align { - MainAlign::Min => 0, - MainAlign::Center => 1, - MainAlign::Max => 2, - MainAlign::SpaceBetween => 9, - }; - let cur_cross = match l.cross_align { - CrossAlign::Min | CrossAlign::Baseline => 0, - CrossAlign::Center => 1, - CrossAlign::Max => 2, - }; - let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i; - if on { - ui.painter().rect_filled( - Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })), - CornerRadius::same(1), - ACCENT, - ); - } else { - ui.painter().circle_filled(c, if resp.hovered() { 2.5 } else { 1.5 }, if resp.hovered() { ACCENT } else { t.text_tertiary }); - } - if resp.clicked() { - let m = ["min", "center", "max"][main_i]; - let cr = ["min", "center", "max"][cross_i]; - let main = if l.main_align == MainAlign::SpaceBetween { "spaceBetween" } else { m }; - app.set_props(&ids, json!({"mainAlign": main, "crossAlign": cr})); - } - } - } - ui.vertical(|ui| { - let auto = l.main_align == MainAlign::SpaceBetween; - let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0); - app.field(&ids, "gap", e, |v| json!(v)); - ui.add_space(8.0); - let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); - egui::Popup::menu(&resp).show(|ui| { - if menu_item(ui, "Fixed", "") { - app.set_props(&ids, json!({"mainAlign": "min"})); - ui.close(); - } - if menu_item(ui, "Auto (space between)", "") { - app.set_props(&ids, json!({"mainAlign": "spaceBetween"})); - ui.close(); - } - }); - }); - }); - // Padding. - let [pt, pr, pb, pl] = l.padding; - row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), if pl == pr { Some(pl) } else { None }, "", half, 1.0); - app.field(&ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), if pt == pb { Some(pt) } else { None }, "", half, 1.0); - app.field(&ids, "paddingVertical", e, |v| json!(v.max(0.0))); - }); - if l.mode == LayoutMode::Grid { - row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_cols"), Lead::Text("Cols"), Some(f64::from(l.grid_columns)), "", half, 1.0); - app.field(&ids, "gridColumns", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_rows"), Lead::Text("Rows"), Some(f64::from(l.grid_rows)), "", half, 1.0); - app.field(&ids, "gridRows", e, |v| json!(v.max(1.0))); - }); - } - } - if frames { - let clip = common(doc, sel, |n| n.clip); - row(ui, 32.0, |ui| { - if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") { - app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)})); - } - }); - } - if parent_al { - let abs = common(doc, sel, |n| n.child_layout.absolute); - row(ui, 32.0, |ui| { - if widgets::checkbox(ui, abs.unwrap_or(false), "Ignore auto layout (absolute position)") { - app.set_props(&ids, json!({"absolute": !abs.unwrap_or(false)})); - } - }); - } -} - fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7221aea..20fbfe7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,8 +52,11 @@ images, the text being edited and the undo history. One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation, absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius, -layout, sizing, constraints, clip, text properties, component/instance links, overrides, -boundVariables, interactions, exports. +layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxWidth` / +`minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute +children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties, +component/instance links, overrides, boundVariables, interactions, exports. The auto layout props +that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). ### `ui.inspect {}` diff --git a/docs/mcp.md b/docs/mcp.md index 67ffa26..b10675f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,7 +58,19 @@ Or in a project's `.mcp.json`: no parent, so they are page coordinates then). `inspect_document` reports parent-relative `x`/`y` plus `absoluteBounds` in page coordinates. - Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`, - `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. + `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props: + - `minWidth` / `maxWidth` / `minHeight` / `maxHeight` (px, `null` removes) bound Hug, Fill and + Fixed sizes; a hugging text wider than its max width wraps. + - `absolute: true` takes a child out of the flow (it stays where it is and follows its + `constraints` when the frame resizes; Fill becomes Fixed). + - `crossAlign: "baseline"` lines up the first text baselines in horizontal layouts (other layers + align to the top; nested auto layout frames use their first child's baseline). + - `wrap` with `crossGap` (alias `rowGap`) for the gap between wrapped rows. + - Grid (`layout: "grid"`): `gridColumns`, `gridRows` (minimum; rows grow), `columnGap` / + `rowGap`, track sizes `columnTracks` / `rowTracks` as `[120, "1fr", "auto"]` (px fixed, `fr` + fill share, `auto` hug) or the `layout.setTrack {axis, index, sizing, value?}` command; on a + child, `gridColumn` / `gridRow` (0-based anchor cell, `null` = placed automatically) and + `gridColumnSpan` / `gridRowSpan`. `inspect` reports a grid child's resolved `gridCell`. - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. - Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP diff --git a/docs/parity.md b/docs/parity.md index aba686e..0b044db 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (327 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (328 commands in the engine). **193 / 199 menu items (97.0%)**