From f522e6bd3d1d128de00daad9c2ea6cc7cbccf8a0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:32:58 -0400 Subject: [PATCH] =?UTF-8?q?M7.3:=20Area=20Type=20Options=20=E2=80=94=20row?= =?UTF-8?q?s,=20columns,=20gutter,=20inset,=20first=20baseline=20stored=20?= =?UTF-8?q?on=20area=20type,=20laid=20out=20by=20the=20text=20engine;=20Ty?= =?UTF-8?q?pe=20=E2=86=92=20Area=20Type=20Options=E2=80=A6=20dialog=20pref?= =?UTF-8?q?illed=20from=20the=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 2 +- crates/doc/src/text.rs | 40 ++++++++++++++ crates/engine/src/cmd/textedit.rs | 18 ++++++- crates/engine/src/cmd/typecmd.rs | 88 +++++++++++++++++++++++++++++++ crates/svg/src/import.rs | 3 +- crates/text/src/layout.rs | 12 ++++- crates/text/src/lib.rs | 16 +----- crates/ui-egui/src/menus.rs | 12 ++++- 8 files changed, 171 insertions(+), 20 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2d651545..8bf48df7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -37,7 +37,7 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; -pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef}; +pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef}; use drawcraft_color::{Swatch, SwatchGroup}; use drawcraft_geom::{Point, Rect}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c6733c63..7b93595a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -116,6 +116,42 @@ pub struct ParaStyle { pub style_name: Option, } +/// Area Type Options "First Baseline" offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + /// The tallest glyph ascent touches the frame top (Illustrator's default). + #[default] + Ascent, + CapHeight, + XHeight, + /// The first line's leading. + Leading, + /// Exactly `first_baseline_min` below the top. + Fixed, +} + +/// Area Type Options: rows and columns, gutters, inset and first baseline of area type. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AreaOptions { + pub rows: usize, + pub columns: usize, + /// Gutter between rows/columns in points. + pub gutter: f64, + /// Inset from the frame edges in points. + pub inset: f64, + pub first_baseline: FirstBaseline, + /// Minimum first-baseline offset in points. + pub first_baseline_min: f64, +} + +impl Default for AreaOptions { + fn default() -> Self { + Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 } + } +} + /// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or /// [`ParaStyle`] fields, by their serialized names). Text using it records the name. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -152,6 +188,9 @@ pub struct TextObject { pub runs: Vec, #[serde(default)] pub para: ParaStyle, + /// Area Type Options (area type only). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub area: AreaOptions, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -164,6 +203,7 @@ impl TextObject { xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], para: ParaStyle::default(), + area: AreaOptions::default(), cached_bounds: None, } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a67b2bb2..ea5d9d55 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -315,7 +315,14 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { _ => 0.0, }; let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } }; - let mut t = TextObject { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], para: Default::default(), cached_bounds: None }; + let mut t = TextObject { + kind, + xf: Affine::IDENTITY, + runs: vec![TextRun { text, style }], + para: Default::default(), + area: Default::default(), + cached_bounds: None, + }; refresh_bounds(&mut t); let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| { let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?; @@ -342,7 +349,14 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { } let head = edit::slice_runs(&t.runs, 0, end); let measure = |tr: f64| { - let mut h = TextObject { kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), para: Default::default(), cached_bounds: None }; + let mut h = TextObject { + kind: TextKind::Point, + xf: Affine::IDENTITY, + runs: head.clone(), + para: Default::default(), + area: Default::default(), + cached_bounds: None, + }; for r in &mut h.runs { r.style.tracking = tr; } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index aea08830..882802a1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -31,6 +31,15 @@ pub fn specs() -> Vec { has_doc, set_text ), + cmd!( + "text.areaOptions", + "Area Type Options…", + ["Type"], + None, + "{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options", + has_selection, + area_options + ), cmd!( "text.setStyle", "Character", @@ -207,3 +216,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } + +fn area_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.areaOptions"; + let ids: Vec = { + let d = &s.doc()?.doc; + text_targets(s, p, C)? + .into_iter() + .filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, drawcraft_doc::TextKind::Area { .. }))) + .collect() + }; + let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?; + let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.area.clone(), + _ => unreachable!(), + }; + let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?; + let mut changed = false; + if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) { + for (k, val) in src { + if o.contains_key(k) && k != "ids" { + o.insert(k.clone(), val.clone()); + changed = true; + } + } + } + if !changed { + return Ok(v); + } + let mut opts: drawcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?; + opts.rows = opts.rows.clamp(1, 100); + opts.columns = opts.columns.clamp(1, 100); + opts.gutter = opts.gutter.clamp(0.0, 10_000.0); + opts.inset = opts.inset.clamp(0.0, 10_000.0); + opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0); + let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?; + s.edit("Area Type Options", |d, _| { + for id in &ids { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + t.area = opts.clone(); + refresh_bounds(t); + } + } + Ok(()) + })?; + Ok(out) +} + +#[cfg(test)] +mod area_tests { + use super::*; + + #[test] + fn area_options_columns_change_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let text = "word ".repeat(80); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1))); + let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => drawcraft_text::layout(drawcraft_text::FontDb::global(), t), + _ => panic!(), + }; + assert_eq!(lay(&s).frames.len(), 1); + let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap(); + assert_eq!(r["firstBaseline"], "capHeight"); + let l = lay(&s); + assert_eq!(l.frames.len(), 3, "three column cells"); + // Glyphs land in more than one column. + let xs: Vec = l.glyphs.iter().map(|g| g.origin.x).collect(); + assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(lay(&s).frames.len(), 1); + assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); + } +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 07b88510..2dce9689 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -690,7 +690,8 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool) Some("end") => Justify::Right, _ => Justify::Left, }; - let mut obj = TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), cached_bounds: None }; + let mut obj = + TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), area: Default::default(), cached_bounds: None }; obj.para.justify = justify; let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj))); if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::().ok()) { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f2acfbde..af1ceec7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -53,7 +53,17 @@ impl Ctx<'_> { /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { - layout_with(db, t, &LayoutOptions::default()) + let a = &t.area; + let opts = LayoutOptions { + rows: a.rows, + columns: a.columns, + gutter: a.gutter, + inset: a.inset, + first_baseline: a.first_baseline, + first_baseline_min: a.first_baseline_min, + ..LayoutOptions::default() + }; + layout_with(db, t, &opts) } /// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 46e878c3..5463a80f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -23,19 +23,7 @@ pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace}; use kurbo::{BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; -/// Area Type Options "First Baseline" offset. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum FirstBaseline { - /// The tallest glyph ascent touches the frame top (Illustrator's default). - #[default] - Ascent, - CapHeight, - XHeight, - /// The first line's leading. - Leading, - /// Exactly `first_baseline_min` below the top. - Fixed, -} +pub use drawcraft_doc::FirstBaseline; /// Paragraph composer (Paragraph panel menu). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -48,7 +36,7 @@ pub enum Composer { } /// Layout parameters that the document model doesn't store per object (Area Type Options, -/// composer, OpenType features). [`layout`] uses the defaults. +/// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object. #[derive(Clone, Debug, PartialEq)] pub struct LayoutOptions { /// Area type rows and columns (text flows down each column, then across). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 593e217c..f54c8203 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -805,7 +805,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Convert To Area Type", "type.convertToAreaType"), c("Convert To Point Type", "type.convertToPointType"), - todo("Area Type Options…"), + c("Area Type Options…", "text.areaOptions"), sub( "Type on a Path", vec![ @@ -1164,6 +1164,16 @@ pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) { let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); return; } + // Area Type Options: a dialog with the selected area type's current values. + if id == "text.areaOptions" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(fields) => { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Area Type Options", "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } if let Err(e) = app.run(id, p) { app.status(e); } else if matches!(id, "object.pattern.make" | "object.pattern.edit") {