diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e5..bfb80c48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/doc/src/flex.rs b/crates/doc/src/flex.rs new file mode 100644 index 00000000..2b76be47 --- /dev/null +++ b/crates/doc/src/flex.rs @@ -0,0 +1,341 @@ +//! Flex Layout (InDesign 2026): a frame whose objects are placed in rows or columns by direction, +//! wrap, justification, alignment, padding and spacing, like CSS Flexbox. The container is a frame +//! holding its objects as nested items (as Edit › Paste Into does), so it draws its fill, clips its +//! objects and strokes like any frame; [`layout`] moves the objects to where the settings put them. + +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use serde::{Deserialize, Serialize}; + +use crate::{Content, Item}; + +/// The main axis and the way along it (Flex Layout panel › Direction). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexDirection { + #[default] + Row, + RowReverse, + Column, + ColumnReverse, +} + +impl FlexDirection { + pub fn is_row(self) -> bool { + matches!(self, FlexDirection::Row | FlexDirection::RowReverse) + } + pub fn is_reverse(self) -> bool { + matches!(self, FlexDirection::RowReverse | FlexDirection::ColumnReverse) + } +} + +/// How the objects of a row (or column) share its length (Flex Layout panel › Justification). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexJustify { + #[default] + FlexStart, + Center, + FlexEnd, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +impl FlexJustify { + /// The space modes work the spacing out themselves (the panel shows it, greyed). + pub fn distributes(self) -> bool { + matches!(self, FlexJustify::SpaceBetween | FlexJustify::SpaceAround | FlexJustify::SpaceEvenly) + } +} + +/// Where rows sit across the container, and objects across their row (the alignment grid's other +/// axis). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexAlign { + #[default] + Start, + Center, + End, +} + +/// Width or height of a container: Fixed, or Auto (it grows and shrinks with its objects). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexSize { + #[default] + Fixed, + Auto, +} + +/// A Flex Layout container's settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FlexLayout { + pub direction: FlexDirection, + /// Wrap: objects that don't fit start a new row (column). + pub wrap: bool, + pub justify: FlexJustify, + pub align: FlexAlign, + pub width: FlexSize, + pub height: FlexSize, + /// Padding inside the container: top, left, bottom, right. + pub padding: [f64; 4], + /// Spacing between objects horizontally and vertically. + pub gap_h: f64, + pub gap_v: f64, +} + +impl Default for FlexLayout { + fn default() -> Self { + FlexLayout { + direction: FlexDirection::Row, + wrap: false, + justify: FlexJustify::FlexStart, + align: FlexAlign::Start, + width: FlexSize::Fixed, + height: FlexSize::Fixed, + padding: [0.0; 4], + gap_h: 0.0, + gap_v: 0.0, + } + } +} + +impl FlexLayout { + /// Spacing along the main axis and across it. + pub fn gaps(&self) -> (f64, f64) { + if self.direction.is_row() { (self.gap_h, self.gap_v) } else { (self.gap_v, self.gap_h) } + } +} + +/// Where [`arrange`] puts things: the container's rectangle and each object's offset, in the +/// container's inner space. +#[derive(Clone, Debug, PartialEq)] +pub struct Arrangement { + pub rect: Rect, + pub moves: Vec, + /// The spacing the main axis ends up with between the first row's objects (what the panel shows + /// for a space mode). + pub main_gap: f64, +} + +/// Lay out objects of the given bounds in `rect` (both in the container's inner space). +pub fn arrange(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> Arrangement { + let row = f.direction.is_row(); + let fin = |v: f64| if v.is_finite() { v.max(0.0) } else { 0.0 }; + let [pt, pl, pb, pr] = f.padding.map(fin); + let (gap_main, gap_cross) = f.gaps(); + let (gap_main, gap_cross) = (fin(gap_main), fin(gap_cross)); + let size = |b: &Rect| if row { (b.width(), b.height()) } else { (b.height(), b.width()) }; + let (main_auto, cross_auto) = + if row { (f.width == FlexSize::Auto, f.height == FlexSize::Auto) } else { (f.height == FlexSize::Auto, f.width == FlexSize::Auto) }; + let (pad_main_start, pad_main_end, pad_cross_start, pad_cross_end) = if row { (pl, pr, pt, pb) } else { (pt, pb, pl, pr) }; + let (rect_main, rect_cross) = if row { (rect.width(), rect.height()) } else { (rect.height(), rect.width()) }; + let mut main_len = (rect_main - pad_main_start - pad_main_end).max(0.0); + // Rows: the objects in each, broken where the next one wouldn't fit (Wrap on a fixed length). + let mut lines: Vec> = Vec::new(); + let mut used = 0.0; + for (i, b) in bounds.iter().enumerate() { + let (m, _) = size(b); + let breaks = f.wrap && !main_auto && lines.last().is_some_and(|l| !l.is_empty() && used + gap_main + m > main_len + 1e-6); + match lines.last_mut() { + Some(l) if !breaks => { + used += if l.is_empty() { m } else { gap_main + m }; + l.push(i); + } + _ => { + lines.push(vec![i]); + used = m; + } + } + } + let line_main = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).0).sum::() + gap_main * l.len().saturating_sub(1) as f64; + let line_cross = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).1).fold(0.0, f64::max); + if main_auto { + main_len = lines.iter().map(|l| line_main(l)).fold(0.0, f64::max); + } + let content_cross = lines.iter().map(|l| line_cross(l)).sum::() + gap_cross * lines.len().saturating_sub(1) as f64; + let cross_len = if cross_auto { content_cross } else { (rect_cross - pad_cross_start - pad_cross_end).max(0.0) }; + // The container: Auto sides fit the objects, from its top left. + let (w, h) = if row { + (main_len + pad_main_start + pad_main_end, cross_len + pad_cross_start + pad_cross_end) + } else { + (cross_len + pad_cross_start + pad_cross_end, main_len + pad_main_start + pad_main_end) + }; + let out_rect = Rect::new( + rect.x0, + rect.y0, + rect.x0 + if (row && main_auto) || (!row && cross_auto) { w } else { rect.width() }, + rect.y0 + if (!row && main_auto) || (row && cross_auto) { h } else { rect.height() }, + ); + let shift = |free: f64| match f.align { + FlexAlign::Start => 0.0, + FlexAlign::Center => free / 2.0, + FlexAlign::End => free, + }; + let mut moves = vec![Vec2::ZERO; bounds.len()]; + let mut cross_at = shift(cross_len - content_cross); + let mut main_gap = gap_main; + for (li, l) in lines.iter().enumerate() { + let lc = line_cross(l); + let free = main_len - line_main(l); + let n = l.len() as f64; + let (start, gap) = match f.justify { + _ if free <= 0.0 => (0.0, gap_main), + FlexJustify::FlexStart => (0.0, gap_main), + FlexJustify::Center => (free / 2.0, gap_main), + FlexJustify::FlexEnd => (free, gap_main), + FlexJustify::SpaceBetween if l.len() > 1 => (0.0, gap_main + free / (n - 1.0)), + FlexJustify::SpaceBetween => (0.0, gap_main), + FlexJustify::SpaceAround => (free / (2.0 * n), gap_main + free / n), + FlexJustify::SpaceEvenly => (free / (n + 1.0), gap_main + free / (n + 1.0)), + }; + if li == 0 { + main_gap = gap; + } + let mut at = start; + for &i in l { + let (m, c) = size(&bounds[i]); + // Reverse directions run from the end. + let main = if f.direction.is_reverse() { main_len - at - m } else { at }; + let cross = cross_at + shift(lc - c); + let (x, y) = if row { + (out_rect.x0 + pad_main_start + main, out_rect.y0 + pad_cross_start + cross) + } else { + (out_rect.x0 + pad_cross_start + cross, out_rect.y0 + pad_main_start + main) + }; + moves[i] = Vec2::new(x - bounds[i].x0, y - bounds[i].y0); + at += m + gap; + } + cross_at += lc + gap_cross; + } + Arrangement { rect: out_rect, moves, main_gap } +} + +/// Lay out a container's objects (and size it, for Auto sides). Returns whether anything moved. +pub fn layout(it: &mut Item) -> bool { + let Some(f) = it.flex.clone() else { return false }; + let Content::Group { items } = &it.content else { return false }; + let rect = it.inner_bounds(); + let bounds: Vec = items.iter().map(|c| c.bounds()).collect(); + let a = arrange(&f, rect, &bounds); + let mut changed = false; + if (a.rect.width() - rect.width()).abs() > 1e-6 || (a.rect.height() - rect.height()).abs() > 1e-6 { + it.path = shapes::rectangle(a.rect); + changed = true; + } + if let Content::Group { items } = &mut it.content { + for (c, d) in items.iter_mut().zip(&a.moves) { + if d.hypot() > 1e-6 { + let c = std::sync::Arc::make_mut(c); + c.xf = Affine::translate(*d) * c.xf; + changed = true; + } + } + } + changed +} + +/// Lay out every container among `items` and the objects nested in them, inner containers first +/// (an Auto container's size is its parent's object size). Only changed items are copied. +pub fn layout_all(items: &mut [std::sync::Arc]) { + for a in items.iter_mut() { + if !contains_flex(a) { + continue; + } + let it = std::sync::Arc::make_mut(a); + if let Content::Group { items } = &mut it.content { + layout_all(items); + } + layout(it); + } +} + +/// Is `it` a container, or does it hold one? +pub fn contains_flex(it: &Item) -> bool { + it.flex.is_some() || matches!(&it.content, Content::Group { items } if items.iter().any(|c| contains_flex(c))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(x0: f64, y0: f64, w: f64, h: f64) -> Rect { + Rect::new(x0, y0, x0 + w, y0 + h) + } + + fn placed(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> (Rect, Vec<(f64, f64)>) { + let a = arrange(f, rect, bounds); + (a.rect, bounds.iter().zip(&a.moves).map(|(b, m)| ((b.x0 + m.x).round(), (b.y0 + m.y).round())).collect()) + } + + #[test] + fn a_row_justifies_and_aligns() { + let boxes = [r(500.0, 500.0, 100.0, 50.0), r(0.0, 0.0, 100.0, 80.0), r(0.0, 0.0, 100.0, 50.0)]; + let mut f = FlexLayout { gap_h: 10.0, ..Default::default() }; + let rect = r(0.0, 0.0, 400.0, 100.0); + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (110.0, 0.0), (220.0, 0.0)]); + f.justify = FlexJustify::FlexEnd; + assert_eq!(placed(&f, rect, &boxes).1[0], (80.0, 0.0)); + f.justify = FlexJustify::Center; + assert_eq!(placed(&f, rect, &boxes).1[0], (40.0, 0.0)); + f.justify = FlexJustify::SpaceBetween; + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (150.0, 0.0), (300.0, 0.0)]); + assert_eq!(arrange(&f, rect, &boxes).main_gap, 50.0); + f.justify = FlexJustify::SpaceEvenly; + assert_eq!(placed(&f, rect, &boxes).1[0], (20.0, 0.0)); + f.justify = FlexJustify::SpaceAround; + assert_eq!(placed(&f, rect, &boxes).1[0], (13.0, 0.0)); + // Across: the 80 tall row; the 50 tall boxes centre or end in it, and the row in the container. + f.justify = FlexJustify::FlexStart; + f.align = FlexAlign::Center; + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 25.0), (110.0, 10.0), (220.0, 25.0)]); + f.align = FlexAlign::End; + assert_eq!(placed(&f, rect, &boxes).1[1], (110.0, 20.0)); + f.direction = FlexDirection::RowReverse; + f.align = FlexAlign::Start; + assert_eq!(placed(&f, rect, &boxes).1, [(300.0, 0.0), (190.0, 0.0), (80.0, 0.0)]); + } + + /// The video's second layout: a column that wraps into two, 56 apart, 38 between objects, + /// centred in the container both ways. + #[test] + fn a_wrapped_column_centres() { + let boxes = [r(0.0, 0.0, 300.0, 400.0); 4]; + let f = FlexLayout { + direction: FlexDirection::Column, + wrap: true, + justify: FlexJustify::Center, + align: FlexAlign::Center, + gap_h: 56.0, + gap_v: 38.0, + ..Default::default() + }; + let (rect, at) = placed(&f, r(0.0, 0.0, 868.0, 912.0), &boxes); + assert_eq!(rect, r(0.0, 0.0, 868.0, 912.0)); + // Each column 400 + 38 + 400 = 838 tall, centred in 912; the two 300 wide columns and the + // 56 between them (656) centred in 868. + assert_eq!(at, [(106.0, 37.0), (106.0, 475.0), (462.0, 37.0), (462.0, 475.0)]); + } + + #[test] + fn auto_sides_fit_the_objects_and_padding() { + let boxes = [r(0.0, 0.0, 100.0, 50.0), r(0.0, 0.0, 60.0, 70.0)]; + let f = FlexLayout { width: FlexSize::Auto, height: FlexSize::Auto, gap_h: 20.0, padding: [5.0, 10.0, 15.0, 30.0], ..Default::default() }; + let (rect, at) = placed(&f, r(40.0, 40.0, 10.0, 10.0), &boxes); + assert_eq!(rect, Rect::new(40.0, 40.0, 40.0 + 10.0 + 180.0 + 30.0, 40.0 + 5.0 + 70.0 + 15.0)); + assert_eq!(at, [(50.0, 45.0), (170.0, 45.0)]); + // Auto ignores Wrap: one row. + let wrapped = FlexLayout { wrap: true, ..f }; + assert_eq!(placed(&wrapped, r(40.0, 40.0, 10.0, 10.0), &boxes).1, at); + } + + #[test] + fn hostile_numbers_dont_break_the_layout() { + let f = FlexLayout { gap_h: f64::NAN, padding: [f64::INFINITY, -5.0, 0.0, 0.0], wrap: true, ..Default::default() }; + let a = arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[r(0.0, 0.0, 50.0, 50.0), r(0.0, 0.0, 50.0, 50.0)]); + assert!(a.moves.iter().all(|m| m.x.is_finite() && m.y.is_finite())); + assert!(arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[]).moves.is_empty()); + } +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 70d56cef..966039c5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1016,6 +1016,9 @@ pub struct Item { pub isolate: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub knockout: bool, + /// Flex Layout: this frame lays out the objects nested in it (see [`crate::flex`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flex: Option, } fn identity() -> Affine { @@ -1061,6 +1064,7 @@ impl Item { active_state: 0, isolate: false, knockout: false, + flex: None, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 49a81830..c0fdc8fe 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ pub mod cjk; pub mod datamerge; mod edit; pub mod endnotes; +pub mod flex; pub mod ids; pub mod index; pub mod item; @@ -47,6 +48,7 @@ pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; +pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; pub use ids::*; pub use index::{INDEX_MARK, IndexRef}; pub use item::*; diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs new file mode 100644 index 00000000..bdb7f73e --- /dev/null +++ b/crates/engine/src/cmd/flex.rs @@ -0,0 +1,571 @@ +//! Flex Layout: Object › Flex Layout and the Flex Layout panel. A container is a frame whose +//! nested objects [`designcraft_doc::flex::layout`] places; [`sync`] lays every container out +//! after each edit, so moving, resizing or editing an object inside one reflows it. + +use std::sync::Arc; + +use designcraft_doc::flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; +use designcraft_doc::{Content, Document, Item, ItemId, Selection, Shape}; +use designcraft_geom::{Rect, shapes}; +use serde_json::{Value, json}; + +use super::object::RemoveKeep; +use super::{CommandSpec, bad, cmd, has_doc, has_selection, id_param, targets}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "flex.add", + "Add to Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — the selected objects (top level, one spread) in a new Flex Layout container where they are; aligned objects get that direction, Auto size and Space Between, others a wrapping row of their width. One group or empty rectangle frame is converted instead (flex.convert) → {id}", + has_selection, + add + ), + cmd!( + "flex.convert", + "Convert to Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — a group (its objects) or a rectangle frame (with what is pasted into it) becomes a Flex Layout container → {id}", + has_selection, + convert + ), + cmd!( + "flex.remove", + "Remove Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — the container goes; its objects stay where they are, grouped when there are several (a container with its own fill or stroke stays as a frame holding them) → {ids}", + is_container, + remove + ), + cmd!( + "flex.set", + "Flex Layout Options", + [], + None, + "{ids?, direction?: row|rowReverse|column|columnReverse, wrap?: bool, justify?: flexStart|center|flexEnd|spaceBetween|spaceAround|spaceEvenly, align?: start|center|end (across), cell?: 0..8 (the alignment grid, row by row from the top left: sets justification and alignment by direction), width?: auto|fixed|number, height?: auto|fixed|number, padding?: number|[top, left, bottom, right], gapH?, gapV?} → {changed}", + is_container, + set + ), + cmd!( + "flex.reorder", + "Reorder in Flex Layout", + [], + None, + "{id (an object in a container), to?: index | by?: ±n} — its place in the container's order → {index}", + has_doc, + reorder + ), + ] +} + +fn is_container(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document")?; + if st.selection.items.iter().any(|i| st.doc.item(*i).is_some_and(|it| it.flex.is_some())) { + Ok(()) + } else { + Err("select a Flex Layout container".into()) + } +} + +/// Lay out every container (called after each edit). Spreads without one aren't copied. +pub(crate) fn sync(d: &mut Document) { + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if sp.items.iter().any(|i| designcraft_doc::flex::contains_flex(i)) { + designcraft_doc::flex::layout_all(&mut Arc::make_mut(sp).items); + } + } +} + +/// InDesign's detection when objects become a container: objects in a row (or column) keep it, +/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. +fn detect(bounds: &[Rect]) -> FlexLayout { + let tol = 0.5; + let in_line = |row: bool| { + let mut v: Vec<&Rect> = bounds.iter().collect(); + v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); + let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); + let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); + (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) + }; + let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol); + let align = |row: bool| { + let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = + if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; + if same(a) { + FlexAlign::Start + } else if same(c) { + FlexAlign::Center + } else if same(e) { + FlexAlign::End + } else { + FlexAlign::Start + } + }; + let auto = |gap: f64, row: bool| FlexLayout { + direction: if row { FlexDirection::Row } else { FlexDirection::Column }, + justify: FlexJustify::SpaceBetween, + align: align(row), + width: FlexSize::Auto, + height: FlexSize::Auto, + gap_h: if row { gap.max(0.0) } else { 0.0 }, + gap_v: if row { 0.0 } else { gap.max(0.0) }, + ..Default::default() + }; + match (in_line(true), in_line(false)) { + (Some(gap), _) => auto(gap, true), + (None, Some(gap)) => auto(gap, false), + _ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() }, + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + if let [one] = ids.as_slice() + && s.doc()?.doc.item(*one).is_some_and(convertible) + { + return convert(s, p); + } + if ids.is_empty() { + return Err(bad("flex.add", "select the objects to put in a Flex Layout")); + } + s.edit(|d, sel| { + let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?; + let order: Vec = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default(); + if order.len() != ids.len() { + return Err(bad("flex.add", "select objects on one spread, not inside a group or frame")); + } + let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0); + let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default(); + let mut kids: Vec = Vec::new(); + for id in &order { + kids.push(d.remove_item_keep_story(*id)?); + } + // Their order in the container: along the line they are in, else top to bottom, left to right. + let bounds: Vec = kids.iter().map(Item::bounds).collect(); + let f = detect(&bounds); + // A row reads left to right (wrapped: row by row); a column top to bottom (wrapped: column by + // column). + let key = |r: &Rect| if f.direction.is_row() != f.wrap { (r.x0, r.y0) } else { (r.y0, r.x0) }; + kids.sort_by(|a, b| { + let (ka, kb) = (key(&a.bounds()), key(&b.bounds())); + ka.0.total_cmp(&kb.0).then(ka.1.total_cmp(&kb.1)) + }); + let rect = bounds.iter().copied().reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let cid = ItemId(d.alloc()); + let mut c = Item::new(cid, layer, Shape::Rectangle, shapes::rectangle(rect)); + c.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() }; + c.flex = Some(f); + d.insert_item(sr, c, Some(front + 1 - order.len()))?; + *sel = Selection::items(vec![cid]); + Ok(json!({"id": cid.0})) + }) +} + +/// A group, or a rectangle frame holding nothing or pasted-in objects. +fn convertible(it: &Item) -> bool { + it.flex.is_none() && (it.is_group() || it.shape == Shape::Rectangle && matches!(it.content, Content::Unassigned | Content::Group { .. })) +} + +fn convert(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let [id] = ids.as_slice() else { return Err(bad("flex.convert", "select one group or rectangle frame")) }; + let id = *id; + s.edit(|d, sel| { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?.clone(); + if !convertible(&it) { + return Err(bad("flex.convert", "select one group or rectangle frame")); + } + let kids: Vec> = match &it.content { + Content::Group { items } => items.clone(), + _ => Vec::new(), + }; + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.is_group() { + // The group's box becomes the container's, in its own space. + let rect = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + it.shape = Shape::Rectangle; + it.path = shapes::rectangle(rect); + } + let bounds: Vec = kids.iter().map(|k| k.bounds()).collect(); + it.flex = Some(if bounds.is_empty() { FlexLayout::default() } else { detect(&bounds) }); + it.content = Content::Group { items: kids }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0})) + }) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|d, sel| { + let mut out = Vec::new(); + for id in &ids { + let Some(loc) = d.find(*id) else { continue }; + let Some(c) = d.item(*id).cloned() else { continue }; + if c.flex.is_none() { + continue; + } + let kids = match &c.content { + Content::Group { items } => items.clone(), + _ => Vec::new(), + }; + // A frame with its own look stays one, holding its objects. + if !c.fill.is_none() || !c.stroke.is_none() || kids.is_empty() { + if let Some(it) = d.item_mut(*id) { + it.flex = None; + } + out.push(*id); + continue; + } + if loc.path.len() != 1 { + return Err(bad("flex.remove", "a Flex Layout inside a group or another container keeps its objects; remove the outer one first")); + } + let Some(sp) = d.spread_mut(loc.spread) else { continue }; + sp.items.remove(loc.path[0]); + let new = if let [one] = kids.as_slice() { + let mut k = (**one).clone(); + k.xf = c.xf * k.xf; + k + } else { + let mut g = Item::new(c.id, c.layer, Shape::Group, designcraft_geom::PathData::default()); + g.xf = c.xf; + g.content = Content::Group { items: kids }; + g + }; + out.push(new.id); + sp.items.insert(loc.path[0], Arc::new(new)); + } + *sel = Selection::items(out.clone()); + Ok(json!({"ids": out.iter().map(|i| i.0).collect::>()})) + }) +} + +fn parse(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("flex.set", format!("`{key}`: unknown value {v}"))), + } +} + +fn set(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let direction: Option = parse(p, "direction")?; + let justify: Option = parse(p, "justify")?; + let align: Option = parse(p, "align")?; + let cell = p.get("cell").and_then(Value::as_u64); + if cell.is_some_and(|c| c > 8) { + return Err(bad("flex.set", "`cell` is 0..8")); + } + let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 100_000.0)); + let padding: Option<[f64; 4]> = match p.get("padding") { + None => None, + Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).map(|v| [v.clamp(0.0, 100_000.0); 4]), + Some(v) => { + let a: Vec = serde_json::from_value(v.clone()).map_err(|_| bad("flex.set", "`padding`: a number or [top, left, bottom, right]"))?; + let [t, l, b, r] = a.as_slice() else { return Err(bad("flex.set", "`padding`: a number or [top, left, bottom, right]")) }; + Some([*t, *l, *b, *r].map(|v| if v.is_finite() { v.clamp(0.0, 100_000.0) } else { 0.0 })) + } + }; + let size = |k: &str| -> Result)>> { + match p.get(k) { + None => Ok(None), + Some(Value::String(s)) if s == "auto" => Ok(Some((FlexSize::Auto, None))), + Some(Value::String(s)) if s == "fixed" => Ok(Some((FlexSize::Fixed, None))), + Some(Value::Number(n)) => Ok(n.as_f64().filter(|v| v.is_finite() && *v > 0.0).map(|v| (FlexSize::Fixed, Some(v.min(100_000.0))))), + Some(v) => Err(bad("flex.set", format!("`{k}`: auto, fixed or a size, not {v}"))), + } + }; + let (width, height) = (size("width")?, size("height")?); + let (gap_h, gap_v, wrap) = (num("gapH"), num("gapV"), p.get("wrap").and_then(Value::as_bool)); + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Some(f) = it.flex.as_mut() else { continue }; + if let Some(v) = direction { + f.direction = v; + } + if let Some(v) = wrap { + f.wrap = v; + } + if let Some(v) = justify { + f.justify = v; + } + if let Some(v) = align { + f.align = v; + } + if let Some(c) = cell { + // The grid is what you see: its column is left/centre/right, its row top/middle/bottom. + let pos = |i: u64| [FlexAlign::Start, FlexAlign::Center, FlexAlign::End][i as usize % 3]; + let (h, v) = (pos(c % 3), pos(c / 3)); + let (main, cross) = if f.direction.is_row() { (h, v) } else { (v, h) }; + let main = if f.direction.is_reverse() { + match main { + FlexAlign::Start => FlexAlign::End, + FlexAlign::End => FlexAlign::Start, + m => m, + } + } else { + main + }; + if !f.justify.distributes() { + f.justify = match main { + FlexAlign::Start => FlexJustify::FlexStart, + FlexAlign::Center => FlexJustify::Center, + FlexAlign::End => FlexJustify::FlexEnd, + }; + } + f.align = cross; + } + if let Some(v) = padding { + f.padding = v; + } + if let Some(v) = gap_h { + f.gap_h = v; + } + if let Some(v) = gap_v { + f.gap_v = v; + } + let r = it.inner_bounds(); + let mut rect = r; + if let Some((mode, value)) = width { + if let Some(f) = it.flex.as_mut() { + f.width = mode; + } + if let Some(w) = value { + rect.x1 = rect.x0 + w; + } + } + if let Some((mode, value)) = height { + if let Some(f) = it.flex.as_mut() { + f.height = mode; + } + if let Some(h) = value { + rect.y1 = rect.y0 + h; + } + } + if rect != r { + it.path = shapes::rectangle(rect); + } + changed += 1; + } + Ok(json!({"changed": changed})) + }) +} + +fn reorder(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("flex.reorder", "missing `id`"))?; + let to = p.get("to").and_then(Value::as_i64); + let by = p.get("by").and_then(Value::as_i64); + if to.is_none() && by.is_none() { + return Err(bad("flex.reorder", "give `to` or `by`")); + } + s.edit(|d, sel| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let (last, parent_path) = loc.path.split_last().ok_or(designcraft_doc::DocError::NoItem(id))?; + let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent_path.to_vec() }; + let parent = d + .item_at(&parent_loc) + .filter(|c| c.flex.is_some()) + .map(|c| c.id) + .ok_or_else(|| bad("flex.reorder", "the object isn't in a Flex Layout"))?; + let c = d.item_mut(parent).ok_or(designcraft_doc::DocError::NoItem(parent))?; + let Content::Group { items } = &mut c.content else { return Err(bad("flex.reorder", "the object isn't in a Flex Layout")) }; + let n = items.len() as i64; + let from = *last as i64; + let target = to.unwrap_or(from + by.unwrap_or(0)).clamp(0, n - 1); + let moved = items.remove(from as usize); + items.insert(target as usize, moved); + *sel = Selection::items(vec![id]); + Ok(json!({"index": target})) + }) +} + +/// An object moved inside a container goes to the place it was dropped on: before the nearest +/// object if it lies before that one's centre along the main axis, else after it. +pub(crate) fn drop_in_container(d: &mut Document, id: ItemId) { + let Some(loc) = d.find(id) else { return }; + let Some((&at, parent)) = loc.path.split_last() else { return }; + let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent.to_vec() }; + let Some(cid) = d.item_at(&parent_loc).filter(|c| c.flex.is_some()).map(|c| c.id) else { return }; + let Some(c) = d.item_mut(cid) else { return }; + let Some(f) = c.flex.clone() else { return }; + let Content::Group { items } = &mut c.content else { return }; + if at >= items.len() { + return; + } + let moved = items.remove(at); + let m = moved.bounds().center(); + let along = |p: designcraft_geom::Point| if f.direction.is_row() { p.x } else { p.y }; + let nearest = items.iter().enumerate().map(|(i, k)| (i, k.bounds().center())).min_by(|a, b| (a.1 - m).hypot().total_cmp(&(b.1 - m).hypot())); + let index = match nearest { + Some((i, k)) => { + let before = (along(m) < along(k)) != f.direction.is_reverse(); + if before { i } else { i + 1 } + } + None => 0, + }; + items.insert(index.min(items.len()), moved); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_boxes(boxes: &[[f64; 4]]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1920, "height": 1080, "margins": 84})).unwrap(); + let ids = boxes.iter().map(|r| s.execute("frame.create", &json!({"rect": r})).unwrap()["id"].as_u64().unwrap()).collect(); + (s, ids) + } + + fn rect_of(s: &Session, id: u64) -> Rect { + s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds() + } + + /// The video: four objects in a row become a container that keeps them where they are (Row, + /// Auto size, Space Between at their spacing, aligned to the top), then a fixed 2-column layout. + #[test] + fn four_objects_in_a_row_then_two_wrapped_columns() { + // As in the video after Shift+Cmd shrinks them: 400 × 430, 22 apart. + let boxes = [[84.0, 461.0, 484.0, 891.0], [506.0, 461.0, 906.0, 891.0], [928.0, 461.0, 1328.0, 891.0], [1350.0, 461.0, 1750.0, 891.0]]; + let (mut s, ids) = session_with_boxes(&boxes); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(ItemId(c)).unwrap(); + let f = it.flex.clone().unwrap(); + assert_eq!( + (f.direction, f.justify, f.align, f.width, f.height, f.wrap), + (FlexDirection::Row, FlexJustify::SpaceBetween, FlexAlign::Start, FlexSize::Auto, FlexSize::Auto, false) + ); + assert!((f.gap_h - 22.0).abs() < 1e-9); + assert_eq!(it.bounds(), Rect::new(84.0, 461.0, 1750.0, 891.0)); + for (id, b) in ids.iter().zip(&boxes) { + assert_eq!(rect_of(&s, *id), Rect::new(b[0], b[1], b[2], b[3]), "nothing moves"); + } + assert_eq!(d.spreads[0].items.len(), 1, "the objects are in the container"); + // Resize the container (Fixed), Flex Start, a wrapping column, spacing, centred. + s.execute( + "flex.set", + &json!({"width": 868, "height": 912, "justify": "flexStart", "direction": "column", "wrap": true, "gapH": 56, "gapV": 38}), + ) + .unwrap(); + s.execute("flex.set", &json!({"cell": 4})).unwrap(); + let f = s.doc().unwrap().doc.item(ItemId(c)).unwrap().flex.clone().unwrap(); + assert_eq!((f.justify, f.align, f.width), (FlexJustify::Center, FlexAlign::Center, FlexSize::Fixed)); + // Two columns of two: 400 + 56 + 400 across 868, 430 + 38 + 430 down 912, centred. + let r: Vec = ids.iter().map(|i| rect_of(&s, *i)).collect(); + assert_eq!(r[0].x0, r[1].x0); + assert_eq!(r[2].x0, r[3].x0); + assert!((r[2].x0 - r[0].x1 - 56.0).abs() < 1e-6); + assert!((r[1].y0 - r[0].y1 - 38.0).abs() < 1e-6); + let c_rect = s.doc().unwrap().doc.item(ItemId(c)).unwrap().bounds(); + assert!((r[0].x0 - c_rect.x0 - 6.0).abs() < 1e-6 && (r[0].y0 - c_rect.y0 - 7.0).abs() < 1e-6, "{r:?} in {c_rect:?}"); + // Reorder: 2 goes to the top of the second column. + s.execute("flex.reorder", &json!({"id": ids[1], "to": 2})).unwrap(); + assert_eq!(rect_of(&s, ids[1]).x0, r[2].x0, "now in the second column"); + assert_eq!(rect_of(&s, ids[1]).y0, r[0].y0); + // One undo step per command; undo the reorder. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rect_of(&s, ids[1]), r[1]); + } + + /// Resizing the container by its handles changes its box (Fixed), not its objects' size. + #[test] + fn resizing_a_container_reflows_instead_of_scaling() { + let (mut s, ids) = session_with_boxes(&[[100.0, 100.0, 200.0, 200.0], [220.0, 100.0, 320.0, 200.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("flex.set", &json!({"justify": "flexEnd"})).unwrap(); + s.execute("transform.resize", &json!({"from": [100, 100, 320, 200], "to": [100, 100, 520, 300]})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(c)).unwrap().clone(); + let b = it.bounds(); + assert!((b.x1 - 520.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6 && b.x0 == 100.0, "{b:?}"); + let f = it.flex.unwrap(); + assert_eq!((f.width, f.height), (FlexSize::Fixed, FlexSize::Fixed)); + let r1 = rect_of(&s, ids[1]); + assert!((r1.x1 - 520.0).abs() < 1e-6 && (r1.width() - 100.0).abs() < 1e-6 && r1.y0 == 100.0, "same size, at the end of the row: {r1:?}"); + assert_eq!(rect_of(&s, ids[0]).width(), 100.0); + s.execute("transform.set", &json!({"width": 300, "ref": 0})).unwrap(); + assert!((rect_of(&s, ids[1]).x1 - 400.0).abs() < 1e-6); + } + + /// Moving an object inside the container is undone by the layout (it places them); a new + /// object in the container is laid out at once. + #[test] + fn the_layout_follows_every_edit() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("transform.set", &json!({"ids": [ids[0]], "width": 300, "ref": 0})).unwrap(); + // Auto width: the container grows, the second object follows at the spacing. + assert_eq!(rect_of(&s, ids[1]).x0, 350.0); + } + + #[test] + fn remove_groups_the_objects_where_they_are() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("flex.set", &json!({"justify": "flexStart", "gapH": 10})).unwrap(); + let before = rect_of(&s, ids[1]); + let r = s.execute("flex.remove", &json!({})).unwrap(); + let g = r["ids"][0].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(g)).unwrap().is_group()); + assert_eq!(rect_of(&s, ids[1]), before); + assert_eq!(before.x0, 110.0); + // A group converts back. + s.execute("flex.convert", &json!({"ids": [g]})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(g)).unwrap().flex.is_some()); + } + + /// Dragging an object in a container drops it in the nearest place (the video moves group 2 to + /// the top of the second column). + #[test] + fn dragging_an_object_reorders_the_container() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0], [220.0, 0.0, 320.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("flex.set", &json!({"justify": "flexStart"})).unwrap(); + // The first box dropped past the third's centre: it goes last. + s.execute("transform.move", &json!({"ids": [ids[0]], "dx": 250, "dy": 5})).unwrap(); + let x: Vec = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect(); + assert_eq!(x, [220.0, 0.0, 110.0]); + // Back before the second (now at 0): first again. + s.execute("transform.move", &json!({"ids": [ids[0]], "dx": -225, "dy": 0})).unwrap(); + let x: Vec = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect(); + assert_eq!(x, [0.0, 110.0, 220.0]); + } + + /// Flex settings are saved with the document. + #[test] + fn a_container_round_trips_through_the_file() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("flex.set", &json!({"direction": "columnReverse", "wrap": true, "padding": [1, 2, 3, 4], "gapV": 7})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d)).unwrap(); + assert_eq!(back.item(ItemId(c)).unwrap().flex, d.item(ItemId(c)).unwrap().flex); + assert!(back.item(ItemId(c)).unwrap().flex.is_some()); + } + + #[test] + fn bad_params_are_refused() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + assert!(s.execute("flex.set", &json!({"direction": "diagonal"})).is_err()); + assert!(s.execute("flex.set", &json!({"cell": 9})).is_err()); + assert!(s.execute("flex.set", &json!({"padding": [1, 2]})).is_err()); + assert!(s.execute("flex.reorder", &json!({"id": ids[0]})).is_err()); + s.execute("flex.set", &json!({"gapH": f64::MAX, "width": -5})).unwrap(); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b3bbb180..0bfb9727 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod export; mod file; pub(crate) mod find; pub use find::same_find_domain; +pub(crate) mod flex; mod fonts; mod guides; mod index; @@ -162,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(anchors::specs()); v.extend(articles::specs()); v.extend(paths::specs()); + v.extend(flex::specs()); v.extend(book::specs()); v.extend(buttons::specs()); v.extend(captions::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82c58c50..fb9c084f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1078,6 +1078,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { } it.xf = Affine::translate((dx, dy)) * it.xf; } + // An object dragged inside a Flex Layout takes the place it was dropped on. + if !copy { + for id in &ids { + super::flex::drop_in_container(d, *id); + } + } Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::>()})) }) } @@ -1108,6 +1114,8 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { it.xf = Affine::translate(m * c - c) * it.xf; } else if scale_content { scale_item(it, m, (sx * sy).abs().sqrt(), strokes); + } else if it.flex.is_some() { + resize_container(it, m); } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { @@ -1127,6 +1135,23 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { }) } +/// A Flex Layout container takes the new box (its objects keep their size; the layout places them), +/// and a side whose length changes becomes Fixed, as dragging a container's handle does in InDesign. +fn resize_container(it: &mut Item, m: Affine) { + let before = it.inner_bounds(); + let inner = it.xf.inverse() * m * it.xf; + bake(it, inner); + let after = it.inner_bounds(); + if let Some(f) = it.flex.as_mut() { + if (after.width() - before.width()).abs() > 1e-6 { + f.width = designcraft_doc::FlexSize::Fixed; + } + if (after.height() - before.height()).abs() > 1e-6 { + f.height = designcraft_doc::FlexSize::Fixed; + } + } +} + fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } @@ -1374,7 +1399,9 @@ fn transform_set(s: &mut Session, p: &Value) -> Result { * Affine::translate((-geom.x0, -geom.y0)); for id in &ids { if let Some(it) = d.item_mut(*id) { - if matches!(it.content, Content::Group { .. }) { + if it.flex.is_some() { + resize_container(it, m); + } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { let inner = it.xf.inverse() * m * it.xf; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff8fb87b..2b6f4663 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -560,6 +560,7 @@ impl Session { let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; crate::cmd::sync_placeholders(&mut doc); + crate::cmd::flex::sync(&mut doc); doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e99..755165a1 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -26,6 +26,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("table", "Table", "panel-table"), ("align", "Align", "panel-align"), ("pathfinder", "Pathfinder", "tool-scissors"), + ("flexLayout", "Flex Layout", "panel-flex"), ("links", "Links", "panel-links"), ("info", "Info", "panel-info"), ("preflight", "Preflight", "panel-preflight"), @@ -210,6 +211,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), "pathfinder" => panels::properties::pathfinder_panel(app, ui), + "flexLayout" => panels::properties::flex_panel(app, ui), "color" => panels::swatches::color_panel(app, ui), "gradient" => panels::swatches::gradient_panel(app, ui), "effects" => panels::properties::effects_panel(app, ui), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c..cc171865 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,75 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Flex Layout", ["Flex-Layout", "Mise en page flexible", "Diseño flexible", "フレックスレイアウト", "弹性布局"]), + ( + "Create Flex Layout", + ["Flex-Layout erstellen", "Créer une mise en page flexible", "Crear diseño flexible", "フレックスレイアウトを作成", "创建弹性布局"], + ), + ( + "Add to Flex Layout", + [ + "Zu Flex-Layout hinzufügen", + "Ajouter à la mise en page flexible", + "Añadir a diseño flexible", + "フレックスレイアウトに追加", + "添加到弹性布局", + ], + ), + ( + "Convert to Flex Layout", + [ + "In Flex-Layout umwandeln", + "Convertir en mise en page flexible", + "Convertir en diseño flexible", + "フレックスレイアウトに変換", + "转换为弹性布局", + ], + ), + ( + "Remove Flex Layout", + ["Flex-Layout entfernen", "Supprimer la mise en page flexible", "Quitar diseño flexible", "フレックスレイアウトを削除", "移除弹性布局"], + ), + ( + "Show Flex Controls", + [ + "Flex-Steuerelemente einblenden", + "Afficher les commandes flexibles", + "Mostrar controles flexibles", + "フレックスコントロールを表示", + "显示弹性控件", + ], + ), + ( + "Flex Layout Options", + ["Flex-Layout-Optionen", "Options de mise en page flexible", "Opciones de diseño flexible", "フレックスレイアウトオプション", "弹性布局选项"], + ), + ( + "Reorder in Flex Layout", + [ + "Im Flex-Layout umordnen", + "Réorganiser dans la mise en page flexible", + "Reordenar en diseño flexible", + "フレックスレイアウト内で並べ替え", + "在弹性布局中重新排序", + ], + ), + ("Flex Start", ["Flex-Anfang", "Début flexible", "Inicio flexible", "フレックス開始", "弹性起点"]), + ("Flex End", ["Flex-Ende", "Fin flexible", "Fin flexible", "フレックス終了", "弹性终点"]), + ("Space Between", ["Abstand dazwischen", "Espace entre", "Espacio entre", "間隔を均等", "两端对齐"]), + ("Space Around", ["Abstand ringsum", "Espace autour", "Espacio alrededor", "周囲に間隔", "周围间距"]), + ("Space Evenly", ["Gleichmäßig verteilen", "Espace uniforme", "Espacio uniforme", "均等に配置", "均匀分布"]), + ("Row", ["Zeile", "Ligne", "Fila", "行", "行"]), + ("Reverse Row", ["Zeile umgekehrt", "Ligne inversée", "Fila invertida", "行(逆方向)", "反向行"]), + ("Reverse Column", ["Spalte umgekehrt", "Colonne inversée", "Columna invertida", "列(逆方向)", "反向列"]), + ("Wrap Content", ["Inhalt umbrechen", "Renvoyer le contenu à la ligne", "Ajustar contenido", "コンテンツを折り返す", "内容换行"]), + ("Spacing", ["Abstände", "Espacement", "Espaciado", "間隔", "间距"]), + ("Horizontal Spacing", ["Horizontaler Abstand", "Espacement horizontal", "Espaciado horizontal", "水平方向の間隔", "水平间距"]), + ("Vertical Spacing", ["Vertikaler Abstand", "Espacement vertical", "Espaciado vertical", "垂直方向の間隔", "垂直间距"]), + ("Constrain proportions", ["Proportionen beibehalten", "Conserver les proportions", "Restringir proporciones", "縦横比を固定", "约束比例"]), + ("Auto", ["Automatisch", "Automatique", "Automático", "自動", "自动"]), + ("W:", ["B:", "L :", "An.:", "幅:", "宽:"]), + ("H:", ["H:", "H :", "Al.:", "高さ:", "高:"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f68e7587..a1c5824d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Create Flex Layout", "إنشاء تخطيط مرن"), + ("Add to Flex Layout", "إضافة إلى التخطيط المرن"), + ("Convert to Flex Layout", "تحويل إلى تخطيط مرن"), + ("Remove Flex Layout", "إزالة التخطيط المرن"), + ("Show Flex Controls", "إظهار عناصر التحكم المرنة"), + ("Flex Layout Options", "خيارات التخطيط المرن"), + ("Reorder in Flex Layout", "إعادة الترتيب في التخطيط المرن"), + ("Flex Start", "بداية مرنة"), + ("Flex End", "نهاية مرنة"), + ("Space Between", "مسافة بينية"), + ("Space Around", "مسافة حول"), + ("Space Evenly", "توزيع متساوٍ"), + ("Reverse Row", "صف معكوس"), + ("Reverse Column", "عمود معكوس"), + ("Wrap Content", "التفاف المحتوى"), + ("Spacing", "التباعد"), + ("Horizontal Spacing", "التباعد الأفقي"), + ("Vertical Spacing", "التباعد الرأسي"), + ("Constrain proportions", "تقييد النسب"), + ("Auto", "تلقائي"), + ("W:", "ع:"), + ("H:", "ا:"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e91307f1..bf278376 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Create Flex Layout", "Crea Layout Flex"), + ("Add to Flex Layout", "Aggiungi a Layout Flex"), + ("Convert to Flex Layout", "Converti in Layout Flex"), + ("Remove Flex Layout", "Rimuovi Layout Flex"), + ("Show Flex Controls", "Mostra controlli Flex"), + ("Flex Layout Options", "Opzioni Layout Flex"), + ("Reorder in Flex Layout", "Riordina nel Layout Flex"), + ("Flex Start", "Inizio Flex"), + ("Flex End", "Estremità Flex"), + ("Space Between", "Spazio tra"), + ("Space Around", "Spazio intorno"), + ("Space Evenly", "Disponi lo spazio in modo uniforme"), + ("Reverse Row", "Riga inversa"), + ("Reverse Column", "Colonna inversa"), + ("Wrap Content", "Disponi testo"), + ("Spacing", "Spaziatura"), + ("Horizontal Spacing", "Spaziatura orizzontale"), + ("Vertical Spacing", "Spaziatura verticale"), + ("Constrain proportions", "Vincola proporzioni"), + ("Auto", "Automatico"), + ("W:", "L:"), + ("H:", "A:"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), @@ -1502,7 +1524,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Slug", "Indicazioni"), ("Binding", "Rilegatura"), ("Rotation", "Rotazione"), - ("Flex Layout", "Layout flessibile"), + ("Flex Layout", "Layout Flex"), ("Auto Size", "Dimensione automatica"), ("Kerning", "Crenatura"), ("Alignment", "Allineamento"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f45ee094..d422b45a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Create Flex Layout", "フレックスレイアウトを作成"), + ("Add to Flex Layout", "フレックスレイアウトに追加"), + ("Convert to Flex Layout", "フレックスレイアウトに変換"), + ("Remove Flex Layout", "フレックスレイアウトを削除"), + ("Show Flex Controls", "フレックスコントロールを表示"), + ("Flex Layout Options", "フレックスレイアウトオプション"), + ("Reorder in Flex Layout", "フレックスレイアウト内で並べ替え"), + ("Flex Start", "フレックス開始"), + ("Flex End", "フレックス終了"), + ("Space Between", "間隔を均等"), + ("Space Around", "周囲に間隔"), + ("Space Evenly", "均等に配置"), + ("Reverse Row", "行(逆方向)"), + ("Reverse Column", "列(逆方向)"), + ("Wrap Content", "コンテンツを折り返す"), + ("Spacing", "間隔"), + ("Horizontal Spacing", "水平方向の間隔"), + ("Vertical Spacing", "垂直方向の間隔"), + ("Constrain proportions", "縦横比を固定"), + ("Auto", "自動"), + ("W:", "幅:"), + ("H:", "高さ:"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 09e639e5..625863e4 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Create Flex Layout", "Criar layout flexível"), + ("Add to Flex Layout", "Adicionar ao layout flexível"), + ("Convert to Flex Layout", "Converter em layout flexível"), + ("Remove Flex Layout", "Remover layout flexível"), + ("Show Flex Controls", "Mostrar controles flexíveis"), + ("Flex Layout Options", "Opções de layout flexível"), + ("Reorder in Flex Layout", "Reordenar no layout flexível"), + ("Flex Start", "Início flexível"), + ("Flex End", "Fim flexível"), + ("Space Between", "Espaço entre"), + ("Space Around", "Espaço ao redor"), + ("Space Evenly", "Espaço uniforme"), + ("Reverse Row", "Linha invertida"), + ("Reverse Column", "Coluna invertida"), + ("Wrap Content", "Quebrar conteúdo"), + ("Spacing", "Espaçamento"), + ("Horizontal Spacing", "Espaçamento horizontal"), + ("Vertical Spacing", "Espaçamento vertical"), + ("Constrain proportions", "Restringir proporções"), + ("Auto", "Automático"), + ("W:", "L:"), + ("H:", "A:"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index eab4159d..87cbe15c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Create Flex Layout", "Створити гнучкий макет"), + ("Add to Flex Layout", "Додати до гнучкого макета"), + ("Convert to Flex Layout", "Перетворити на гнучкий макет"), + ("Remove Flex Layout", "Прибрати гнучкий макет"), + ("Show Flex Controls", "Показати елементи гнучкого макета"), + ("Flex Layout Options", "Параметри гнучкого макета"), + ("Reorder in Flex Layout", "Змінити порядок у гнучкому макеті"), + ("Flex Start", "Початок"), + ("Flex End", "Кінець"), + ("Space Between", "Простір між"), + ("Space Around", "Простір навколо"), + ("Space Evenly", "Рівномірно"), + ("Reverse Row", "Рядок у зворотному порядку"), + ("Reverse Column", "Стовпець у зворотному порядку"), + ("Wrap Content", "Переносити вміст"), + ("Spacing", "Відступи"), + ("Horizontal Spacing", "Горизонтальний відступ"), + ("Vertical Spacing", "Вертикальний відступ"), + ("Constrain proportions", "Зберігати пропорції"), + ("Auto", "Авто"), + ("W:", "Ш:"), + ("H:", "В:"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c9346437..a0c665c6 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -550,6 +550,56 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } } } + // Flex Layout panel (original drawings): direction arrows, wrap, justification, spacing. + "flex-right" | "flex-left" | "flex-down" | "flex-up" => { + let pts: [(f32, f32); 5] = match name { + "flex-right" => [(3.0, 10.0), (16.0, 10.0), (11.5, 6.0), (16.0, 10.0), (11.5, 14.0)], + "flex-left" => [(17.0, 10.0), (4.0, 10.0), (8.5, 6.0), (4.0, 10.0), (8.5, 14.0)], + "flex-down" => [(10.0, 3.0), (10.0, 16.0), (6.0, 11.5), (10.0, 16.0), (14.0, 11.5)], + _ => [(10.0, 17.0), (10.0, 4.0), (6.0, 8.5), (10.0, 4.0), (14.0, 8.5)], + }; + pen.line(&pts[..2]); + pen.line(&pts[2..]); + } + "flex-wrap" => { + pen.line(&[(16.0, 4.0), (16.0, 12.0), (5.0, 12.0)]); + pen.line(&[(8.5, 8.5), (5.0, 12.0), (8.5, 15.5)]); + } + "flex-start" | "flex-center" | "flex-end" | "flex-between" | "flex-around" | "flex-evenly" => { + // Three bars in a box, where the justification puts them. + pen.rect(1.5, 6.0, 18.5, 14.0); + let xs: [f32; 3] = match name { + "flex-start" => [3.0, 6.0, 9.0], + "flex-center" => [6.5, 9.5, 12.5], + "flex-end" => [10.0, 13.0, 16.0], + "flex-between" => [3.0, 9.5, 16.0], + "flex-around" => [4.5, 9.5, 14.5], + _ => [5.0, 9.5, 14.0], + }; + for x in xs { + pen.frect(x, 7.5, x + 1.8, 12.5); + } + } + "gap-h" => { + pen.line(&[(3.0, 4.0), (3.0, 16.0)]); + pen.line(&[(17.0, 4.0), (17.0, 16.0)]); + pen.line(&[(5.5, 10.0), (14.5, 10.0)]); + pen.line(&[(8.0, 7.5), (5.5, 10.0), (8.0, 12.5)]); + pen.line(&[(12.0, 7.5), (14.5, 10.0), (12.0, 12.5)]); + } + "gap-v" => { + pen.line(&[(4.0, 3.0), (16.0, 3.0)]); + pen.line(&[(4.0, 17.0), (16.0, 17.0)]); + pen.line(&[(10.0, 5.5), (10.0, 14.5)]); + pen.line(&[(7.5, 8.0), (10.0, 5.5), (12.5, 8.0)]); + pen.line(&[(7.5, 12.0), (10.0, 14.5), (12.5, 12.0)]); + } + "panel-flex" => { + pen.rect(2.5, 3.5, 17.5, 16.5); + pen.frect(5.0, 6.0, 8.5, 14.0); + pen.frect(11.5, 6.0, 15.0, 14.0); + pen.line(&[(8.5, 10.0), (11.5, 10.0)]); + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9a581d4c..8c7fe1e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -482,6 +482,13 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d..52eecfc3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Flex Layout"), true); + flex_section(app, ui); + divider(ui); if i.count > 1 { section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); pathfinder_row(app, ui); @@ -2520,3 +2523,282 @@ mod effects_panel_tests { assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } } + +// ---------------------------------------------------------------- Flex Layout + +/// (justification, icon, label) in the Justification menu's order. +const FLEX_JUSTIFY: [(&str, &str, &str); 6] = [ + ("flexStart", "flex-start", "Flex Start"), + ("center", "flex-center", "Center"), + ("flexEnd", "flex-end", "Flex End"), + ("spaceBetween", "flex-between", "Space Between"), + ("spaceAround", "flex-around", "Space Around"), + ("spaceEvenly", "flex-evenly", "Space Evenly"), +]; + +/// The selected Flex Layout container (the first), with its objects' bounds. +fn selected_container(app: &DesignApp) -> Option<(designcraft_doc::Item, Vec)> { + let st = app.session.active()?; + let it = st.selection.items.iter().filter_map(|i| st.doc.item(*i)).find(|it| it.flex.is_some())?.clone(); + let kids = match &it.content { + designcraft_doc::Content::Group { items } => items.iter().map(|c| c.bounds()).collect(), + _ => Vec::new(), + }; + Some((it, kids)) +} + +/// The alignment grid: 3 × 3 cells (one column or row of three when a space justification spreads +/// the objects along the main axis); the chosen cell shows the direction's arrow. +fn flex_grid(ui: &mut Ui, r: Rect, f: Option<&designcraft_doc::FlexLayout>) -> Option { + use designcraft_doc::{FlexAlign, FlexJustify}; + let t = Tokens::get(ui.ctx()); + let resp = ui.interact(r, ui.id().with("flex_grid"), Sense::click()); + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let cell = r.width() / 3.0; + let pos = |a: FlexAlign| match a { + FlexAlign::Start => 0, + FlexAlign::Center => 1, + FlexAlign::End => 2, + }; + // The chosen (column, row), and whether a space justification leaves only one axis. + let (chosen, spread_row) = match f { + Some(f) => { + let main = match f.justify { + FlexJustify::Center => 1, + FlexJustify::FlexEnd => 2, + _ => 0, + }; + let main = if f.direction.is_reverse() { 2 - main } else { main }; + let cross = pos(f.align); + (if f.direction.is_row() { (main, cross) } else { (cross, main) }, f.justify.distributes().then_some(f.direction.is_row())) + } + None => ((0, 0), None), + }; + let mut picked = None; + for row in 0..3usize { + for col in 0..3usize { + match spread_row { + Some(true) if col != 0 => continue, + Some(false) if row != 0 => continue, + _ => {} + } + let c = Rect::from_min_size(r.min + vec2(col as f32 * cell, row as f32 * cell), vec2(cell, cell)); + let on = match spread_row { + Some(true) => row == chosen.1, + Some(false) => col == chosen.0, + None => (col, row) == chosen, + }; + if on && f.is_some() { + ui.painter().rect_filled(c.shrink(2.0), 2.0, t.well); + let icon = match f.map(|f| f.direction) { + Some(designcraft_doc::FlexDirection::RowReverse) => "flex-left", + Some(designcraft_doc::FlexDirection::Column) => "flex-down", + Some(designcraft_doc::FlexDirection::ColumnReverse) => "flex-up", + _ => "flex-right", + }; + icons::paint(ui.painter(), c.shrink(3.0), icon, t.text_strong); + } else { + ui.painter().circle_filled(c.center(), 1.5, t.icon); + } + if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| c.contains(p)) { + picked = Some((row * 3 + col) as u8); + } + } + } + picked +} + +/// Flex Layout controls, as InDesign's panel: Create Flex Layout; alignment grid, width and height +/// (Auto/Fixed); justification, direction and wrap; padding and spacing. +fn flex_section(app: &mut DesignApp, ui: &mut Ui) { + use designcraft_doc::{FlexSize, flex}; + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let has_sel = app.session.active().is_some_and(|s| !s.selection.items.is_empty()); + let container = selected_container(app); + let f = container.as_ref().and_then(|(it, _)| it.flex.clone()); + let mut on = f.is_some(); + let resp = ui.add_enabled_ui(has_sel, |ui| widgets::checkbox(ui, &mut on, &tr("Create Flex Layout"))).inner; + if resp.changed() { + let _ = app.run(if on { "flex.add" } else { "flex.remove" }, json!({})); + return; + } + ui.add_space(4.0); + let enabled = f.is_some(); + let rect = container.as_ref().map(|(it, _)| it.inner_bounds()); + let arranged = container.as_ref().and_then(|(it, kids)| it.flex.as_ref().map(|f| flex::arrange(f, it.inner_bounds(), kids))); + let mut set: Option = None; + // Alignment grid, W and H with Auto/Fixed, constrain proportions. + let b = block(ui, 2.0 * FIELD_H + 5.0); + ui.add_enabled_ui(enabled, |ui| { + if let Some(c) = flex_grid(ui, sub(b, 0.0, 0.0, 48.0, 48.0), f.as_ref()) { + set = Some(json!({"cell": c})); + } + let linked = temp_bool(ui, "flex_size_linked", false); + for (k, (label, key, mode, len)) in [ + ("W:", "width", f.as_ref().map(|f| f.width), rect.map(|r| r.width())), + ("H:", "height", f.as_ref().map(|f| f.height), rect.map(|r| r.height())), + ] + .into_iter() + .enumerate() + { + let y = k as f32 * (FIELD_H + 5.0); + ui.painter().text( + sub(b, 56.0, y, 18.0, FIELD_H).right_center(), + egui::Align2::RIGHT_CENTER, + tr(label), + egui::FontId::proportional(11.5), + t.text, + ); + let nv = place(ui, sub(b, 78.0, y, 66.0, FIELD_H), |ui| NumField::measure(key, len, u).width(66.0).range(0.0, 100_000.0).show(ui)); + if let Some(v) = nv { + let mut p = json!({ key: v }); + if linked + && let (Some(r), Some(len)) = (rect, len) + && len > 1e-9 + { + let other = if key == "width" { ("height", r.height()) } else { ("width", r.width()) }; + p[other.0] = json!(other.1 * v / len); + } + set = Some(p); + } + let names = [tr("Fixed"), tr("Auto")]; + let cur = if mode == Some(FlexSize::Auto) { 1 } else { 0 }; + if let Some(i) = place(ui, sub(b, 148.0, y, 62.0, FIELD_H), |ui| widgets::dropdown_list(ui, &names[cur], 62.0, &names, Some(cur))) { + set = Some(json!({ key: if i == 1 { "auto" } else { "fixed" } })); + } + } + let clicked = place(ui, sub(b, 214.0, 12.5, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Constrain proportions"), vec2(22.0, 21.0)) + .clicked() + }); + if clicked { + set_temp_bool(ui, "flex_size_linked", !linked); + } + }); + ui.add_space(6.0); + // Justification, direction, wrap. + let b = block(ui, FIELD_H); + ui.add_enabled_ui(enabled, |ui| { + let cur = f.as_ref().map(|f| f.justify).unwrap_or_default(); + let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); + let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { + preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + }); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for (id, icon, label) in FLEX_JUSTIFY { + let r = ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(22.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.selectable_label(id == cur, crate::rtl::widget(ui, tr(label))) + }); + if r.inner.clicked() { + set = Some(json!({"justify": id})); + ui.close(); + } + } + }); + let dir = f.as_ref().map(|f| f.direction).unwrap_or_default(); + place(ui, sub(b, 78.0, 0.0, 132.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (d, icon, tip) in [ + (designcraft_doc::FlexDirection::Row, "flex-right", "Row"), + (designcraft_doc::FlexDirection::RowReverse, "flex-left", "Reverse Row"), + (designcraft_doc::FlexDirection::Column, "flex-down", "Column"), + (designcraft_doc::FlexDirection::ColumnReverse, "flex-up", "Reverse Column"), + ] { + if widgets::icon_toggle_sized(ui, icon, enabled && dir == d, &tr(tip), vec2(31.0, 21.0)).clicked() { + set = Some(json!({"direction": d})); + } + } + }); + let wrap = f.as_ref().is_some_and(|f| f.wrap); + if place(ui, sub(b, 214.0, 0.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, "flex-wrap", wrap, &tr("Wrap Content"), vec2(22.0, 21.0)).clicked() + }) { + set = Some(json!({"wrap": !wrap})); + } + }); + // Spacing: padding (linked by default) and the spacing between objects. + section(ui, &tr("Spacing"), true); + let b = block(ui, 3.0 * FIELD_H + 10.0); + ui.add_enabled_ui(enabled, |ui| { + let pad = f.as_ref().map(|f| f.padding).unwrap_or_default(); + let linked = temp_bool(ui, "flex_padding_linked", true); + for (k, (icon, tip, x, y)) in [ + ("margin-top", "Top", 0.0, 0.0), + ("margin-inside", "Left", 112.0, 0.0), + ("margin-bottom", "Bottom", 0.0, 26.0), + ("margin-outside", "Right", 112.0, 26.0), + ] + .into_iter() + .enumerate() + { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(("flex_pad", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let value = f.as_ref().map(|_| pad[k]); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(tip, value, u).width(76.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(v) = nv { + let p = if linked { + [v; 4] + } else { + let mut p = pad; + p[k] = v; + p + }; + set = Some(json!({"padding": p})); + } + } + if place(ui, sub(b, 214.0, 12.5, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + set_temp_bool(ui, "flex_padding_linked", !linked); + } + // Spacing between objects: a space justification works the main axis' out (shown greyed); + // the cross axis' only counts when objects wrap. + let (row, distributes, wrap) = f.as_ref().map(|f| (f.direction.is_row(), f.justify.distributes(), f.wrap)).unwrap_or((true, false, false)); + let main_gap = arranged.as_ref().map(|a| a.main_gap); + for (k, (icon, key, tip, x)) in + [("gap-h", "gapH", "Horizontal Spacing", 0.0), ("gap-v", "gapV", "Vertical Spacing", 112.0)].into_iter().enumerate() + { + let main = (k == 0) == row; + let live = if main { !distributes } else { wrap }; + let value = match &f { + Some(_) if main && distributes => main_gap, + Some(f) => Some(if k == 0 { f.gap_h } else { f.gap_v }), + None => None, + }; + let y = 2.0 * FIELD_H + 10.0; + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, if live { t.icon } else { t.text_dim }); + ui.interact(ir, ui.id().with(("flex_gap", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, value, u).width(76.0).spinner().range(0.0, 8000.0).enabled(live).show(ui) + }); + if let Some(v) = nv { + set = Some(json!({ key: v })); + } + } + }); + if let Some(p) = set { + let _ = app.run("flex.set", p); + } +} + +/// Window › Flex Layout. +pub fn flex_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + ui.spacing_mut().item_spacing = vec2(6.0, 5.0); + flex_section(app, ui); +} diff --git a/docs/gaps.md b/docs/gaps.md index 4ba05bc4..e966ad31 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -160,7 +160,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, - Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026), + Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), View ▸ Extras badges and Story Editor options. - **Estimate:** 40–70 h (most are thin wrappers over existing engine features). - **Doc:** [ui-parity.md](ui-parity.md) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b8b71540..2716c6e1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p bound. -**Weighted breadth parity: 99%** over 276 features. +**Weighted breadth parity: 99%** over 277 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | -| P1 | 98 | 98 | 0 | 0 | 100% | +| P1 | 99 | 99 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity | @@ -95,6 +95,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Documents, pages, spreads | Ruler guides | P0 | D | | Documents, pages, spreads | Guides on layers | P1 | D | | Documents, pages, spreads | Liquid guides | P2 | D | +| Documents, pages, spreads | Flex Layout | P1 | D | | Documents, pages, spreads | Smart guides | P0 | D | | Documents, pages, spreads | Snap to guides / grid / baseline | P0 | D | | Layers | Layers panel | P0 | D | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad..df3ff0d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -363,6 +363,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. - Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. +- Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas. - Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).