M2: auto layout depth: min/max UI, ignore auto layout toggle, text baseline alignment, grid tracks and cells (#3)
- Min/max width/height: the W/H sizing menus add min/max (at the current size) and remove them;
limit rows edit or add the missing bound. The layout clamps Fixed, Hug and Fill children and
the frame itself; a hugging text wider than its max width wraps at the max. min > max is kept
ordered by node.set (the other bound follows); in the layout min wins.
- Ignore auto layout (absolute position): a toggle in the Position section; the child stays
where it is, can't Fill, shows constraints and follows them when the auto layout frame resizes
(by hugging, filling or a direct resize).
- Text baseline alignment for horizontal layouts ("Align text baselines"; crossAlign baseline):
first-line baselines line up, other layers align to the top, nested auto layout frames use
their first child's baseline. Measure gains text_baseline.
- Grid auto layout: column/row tracks (fixed px, hug, fr fill) via columnTracks / rowTracks or
layout.setTrack, cells anchored by gridColumn / gridRow with gridColumnSpan / gridRowSpan,
auto placement in reading order, rows grow to fit, hugging grids size to their tracks.
Panel: column/row gap, track size rows, a child's cell and spans. Old files load unchanged
(serde defaults; missing tracks are 1fr).
- Canvas handles: wrapped-row gaps and grid column/row gaps are draggable; a selected grid's
tracks are outlined.
- The layout section moved from right.rs to layout_panel.rs; inspect reports min/max and a
grid child's resolved gridCell; docs list the new props.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
94e689fad6
commit
4286a3b74b
|
|
@ -78,7 +78,7 @@
|
|||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Layouts that build themselves</h3>
|
||||
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.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Components that scale</h3>
|
||||
|
|
|
|||
15
ROADMAP.md
15
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% |
|
||||
|
|
|
|||
|
|
@ -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<GridTrack>,
|
||||
/// Grid mode: row track sizes (missing tracks are `1fr`).
|
||||
pub row_tracks: Vec<GridTrack>,
|
||||
/// 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<f64>,
|
||||
pub min_h: Option<f64>,
|
||||
pub max_h: Option<f64>,
|
||||
/// Grid auto layout: anchor column / row (0-based); `None` = placed automatically.
|
||||
pub grid_column: Option<u32>,
|
||||
pub grid_row: Option<u32>,
|
||||
/// 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)]
|
||||
|
|
|
|||
|
|
@ -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::<GridTrack>("{}").unwrap(), GridTrack::fill(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_id_parse() {
|
||||
assert_eq!("12".parse::<NodeId>().unwrap(), NodeId(12));
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<NodeId> =
|
||||
(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<Option<GridTrack>> =
|
||||
[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())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
|||
serde_json::from_value(Value::String(norm)).ok()
|
||||
}
|
||||
|
||||
fn sizing(v: &Value) -> Option<Sizing> {
|
||||
pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
|
||||
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<GridTrack> {
|
||||
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::<f64>().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<f64>) {
|
||||
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<String, Valu
|
|||
_ => 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<String, Valu
|
|||
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
}
|
||||
"wrap" => 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<String, Valu
|
|||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, false)?;
|
||||
}
|
||||
"absolute" => 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::<Option<Vec<_>>>().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(())
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result<String> {
|
|||
}
|
||||
|
||||
pub fn boolean(p: &Value, key: &str) -> Option<bool> {
|
||||
match get(p, key)? {
|
||||
bool_of(get(p, key)?)
|
||||
}
|
||||
|
||||
/// A lenient boolean: `true`, `1`, `"yes"`, `"on"`…
|
||||
pub fn bool_of(v: &Value) -> Option<bool> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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<f64>;
|
||||
/// 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<f64> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A measurer that never changes text sizes.
|
||||
|
|
@ -36,7 +43,8 @@ impl Measure for NoMeasure {
|
|||
}
|
||||
}
|
||||
|
||||
fn clamp_size(v: f64, min: Option<f64>, max: Option<f64>) -> 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<f64>, max: Option<f64>) -> 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<f64>, max: Option<f64>) -> 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<NodeId> {
|
||||
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<f64> {
|
||||
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<f64>,
|
||||
min_cross: Option<f64>,
|
||||
max_cross: Option<f64>,
|
||||
/// Baseline alignment: distance from the item's top to its first baseline.
|
||||
baseline: Option<f64>,
|
||||
/// 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<NodeId> = 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<Item> = 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::<f64>() + 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::<f64>() + 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::<f64>() + gx * (cols - 1) as f64 + pl + pr } else { fw };
|
||||
let total_h = if hug_h { row_h.iter().sum::<f64>() + 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::<f64>() + gx * ci as f64;
|
||||
let y = pt + row_h.iter().take(ri).sum::<f64>() + 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<GridCell>,
|
||||
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<GridCell>, 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<bool>> = Vec::new();
|
||||
let free = |occ: &Vec<Vec<bool>>, 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<Vec<bool>>, 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<Option<GridCell>> = 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<GridCell> = 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<f64>) -> Vec<f64> {
|
||||
let mut out: Vec<f64> = 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<f64> {
|
||||
let by_content = |i: usize| match track_spec(specs, i).0 {
|
||||
Sizing::Hug => true,
|
||||
Sizing::Fill => hug,
|
||||
Sizing::Fixed => false,
|
||||
};
|
||||
let mut content: Vec<f64> = (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::<f64>() + gap * (len - 1) as f64;
|
||||
let grow: Vec<usize> = (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<GridGeometry> {
|
||||
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::<f64>() + gaps_w + pl + pr } else { f.width };
|
||||
let height = if hug_h { row_h.iter().sum::<f64>() + 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);
|
||||
|
|
|
|||
|
|
@ -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<NodeId, (f64, f64, f64)>);
|
||||
|
||||
impl Measure for FakeMeasure {
|
||||
fn text_height(&self, _: &Document, id: NodeId, width: f64) -> Option<f64> {
|
||||
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<f64> {
|
||||
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<NodeId> = (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<NodeId> = (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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Rect>, 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<GapZone>,
|
||||
/// Grid frames: the track boundaries to outline (screen-space segments).
|
||||
tracks: Vec<[Pos2; 2]>,
|
||||
}
|
||||
|
||||
fn al_zones(app: &FigApp) -> Option<Zones> {
|
||||
let ds = app.session.ds().ok()?;
|
||||
|
|
@ -133,30 +129,73 @@ fn al_zones(app: &FigApp) -> Option<Zones> {
|
|||
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_geom::Rect> = 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<figcraft_geom::Rect>> = 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 }}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<f64> {
|
||||
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<f64>| {
|
||||
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)));
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<FieldEdit>, 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<FieldEdit>, 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);
|
||||
|
|
|
|||
|
|
@ -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 {}`
|
||||
|
||||
|
|
|
|||
14
docs/mcp.md
14
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
|
||||
|
|
|
|||
|
|
@ -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%)**
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue