From 2cd41024697ce709827c29bbac4d3bd06fac1ec4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:11:07 -0400 Subject: [PATCH] O: Select submenu, Convert Shape, split table cells Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/table.rs | 116 +++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/select.rs | 249 ++++++++++++++++++++++++ crates/engine/src/cmd/table.rs | 29 +++ crates/ui-egui/src/menus.rs | 28 +++ crates/ui-egui/src/panels/properties.rs | 18 +- docs/parity.md | 12 +- 8 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/cmd/select.rs diff --git a/ROADMAP.md b/ROADMAP.md index a9f0215..6bc74cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style). - Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics. - Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. +- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d802607..c282c5a 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -602,6 +602,95 @@ impl Table { self.set_regions(®ions); } + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell + /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a + /// new row (column) that the cells beside it span, the row height (column width) shared. + pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { + if r >= self.nrows() || c >= self.ncols() { + return; + } + let (or, oc) = self.owner(r, c); + let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + let span = if horizontal { rs } else { cs }; + if span > 1 { + let a = span.div_ceil(2); + if horizontal { + regions.push((or, oc, a, cs)); + regions.push((or + a, oc, rs - a, cs)); + } else { + regions.push((or, oc, rs, a)); + regions.push((or, oc + a, rs, cs - a)); + } + self.set_regions(®ions); + return; + } + if horizontal { + let h = self.rows[or].height; + self.insert_rows(or + 1, 1); + self.rows[or].height = h / 2.0; + self.rows[or + 1].height = h / 2.0; + // insert_rows doesn't grow regions that end at the split row; everything else on it + // spans the new row now. + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { + *reg = (rr, cc, rrs + 1, ccs); + } + } + for x in 0..nc { + if (oc..oc + cs).contains(&x) || covered[or * nc + x] { + continue; + } + regions.push((or, x, 2, 1)); + } + if cs > 1 { + regions.push((or + 1, oc, 1, cs)); + } + self.set_regions(®ions); + } else { + let w = self.columns[oc].width; + self.insert_cols(oc + 1, 1, w / 2.0); + self.columns[oc].width = w / 2.0; + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { + *reg = (rr, cc, rrs, ccs + 1); + } + } + for y in 0..nr { + if (or..or + rs).contains(&y) || covered[y * nc + oc] { + continue; + } + regions.push((y, oc, 1, 2)); + } + if rs > 1 { + regions.push((or, oc + 1, rs, 1)); + } + self.set_regions(®ions); + } + } + /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. pub fn set_header_footer(&mut self, header: usize, footer: usize) { let n = self.nrows(); @@ -971,3 +1060,30 @@ mod tests { assert!(st.tables.is_empty()); } } + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn split_single_and_merged_cells() { + let mut t = Table::new(1, 2, 2, 0, 0, 100.0); + t.split_cell(0, 0, true); + assert_eq!((t.nrows(), t.ncols()), (3, 2)); + // The cell beside the split one spans both new rows. + assert_eq!(t.cell(0, 1).unwrap().row_span, 2); + assert_eq!(t.owner(1, 1), (0, 1)); + assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); + t.split_cell(2, 1, false); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().col_span, 1); + assert_eq!(t.owner(2, 2), (2, 2)); + assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); + // A merged cell splits its own span instead of adding rows. + let mut m = Table::new(1, 4, 1, 0, 0, 100.0); + m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); + m.split_cell(0, 0, true); + assert_eq!(m.nrows(), 4); + assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7873da3..556efe9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -27,6 +27,7 @@ mod place_text; pub mod preflight; mod prefs; mod print; +mod select; pub mod spelling; mod style; pub mod table; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(captions::specs()); v.extend(lists::specs()); v.extend(path_type::specs()); + v.extend(select::specs()); v.extend(package::specs()); v.extend(print::specs()); v.extend(text::specs()); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs new file mode 100644 index 0000000..3872267 --- /dev/null +++ b/crates/engine/src/cmd/select.rs @@ -0,0 +1,249 @@ +//! Object › Select (stacking order, container and content, objects in a group) and Object › +//! Convert Shape. + +use designcraft_doc::{Content, Document, Item, ItemId, ItemLoc, Selection, Shape, SpreadRef}; +use designcraft_geom::corners::{CornerOptions, CornerShape}; +use designcraft_geom::{Point, Rect, shapes}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, has_selection, ok, str_param, targets}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "select.firstAbove", "First Object Above", ["Object", "Select"], Some("Cmd+Alt+Shift+]"), "{} — the frontmost object on the spread (layers included)", has_doc, |s, _| select_stacked(s, Step::Top)), + cmd!(noundo "select.nextAbove", "Next Object Above", ["Object", "Select"], Some("Cmd+Alt+]"), "{}", has_doc, |s, _| select_stacked(s, Step::Up)), + cmd!(noundo "select.nextBelow", "Next Object Below", ["Object", "Select"], Some("Cmd+Alt+["), "{}", has_doc, |s, _| select_stacked(s, Step::Down)), + cmd!(noundo "select.lastBelow", "Last Object Below", ["Object", "Select"], Some("Cmd+Alt+Shift+["), "{} — the backmost object on the spread", has_doc, |s, _| select_stacked(s, Step::Bottom)), + cmd!(noundo "select.container", "Container", ["Object", "Select"], None, "{} — the group or frame holding the selection", has_selection, |s, _| select_container(s)), + cmd!(noundo "select.content", "Content", ["Object", "Select"], None, "{} — the graphic in a frame, or the first object in a group", has_selection, |s, _| select_content(s)), + cmd!(noundo "select.nextInGroup", "Next Object in Group", ["Object", "Select"], None, "{}", has_selection, |s, _| select_sibling(s, 1)), + cmd!(noundo "select.previousInGroup", "Previous Object in Group", ["Object", "Select"], None, "{}", has_selection, |s, _| select_sibling(s, -1)), + cmd!( + "object.convertShape", + "Convert Shape", + ["Object", "Convert Shape"], + None, + "{to: rectangle|roundedRectangle|beveledRectangle|inverseRoundedRectangle|ellipse|triangle|polygon|line|orthogonalLine|openPath|closedPath, ids?}", + has_selection, + convert_shape + ), + ] +} + +#[derive(Clone, Copy)] +enum Step { + Top, + Up, + Down, + Bottom, +} + +/// Selectable top-level items of spread `r`, back to front. +fn stacking(d: &Document, r: SpreadRef) -> Vec { + let Some(sp) = d.spread(r) else { return vec![] }; + let mut v = Vec::new(); + for layer in d.layers.iter().rev() { + if !layer.visible || layer.locked { + continue; + } + v.extend(sp.items.iter().filter(|it| it.layer == layer.id && !it.hidden && !it.locked).map(|it| it.id)); + } + v +} + +fn select_stacked(s: &mut Session, how: Step) -> Result { + let st = s.doc_mut()?; + let cur = st.selection.items.first().and_then(|id| st.doc.find(*id).map(|l| (*id, l))); + let r = cur.as_ref().map(|(_, l)| l.spread).or_else(|| st.doc.spread_refs().next()).ok_or_else(|| bad("select", "no spread"))?; + let order = stacking(&st.doc, r); + if order.is_empty() { + return ok(); + } + // Nested selections step from their top-level object. + let at = cur + .and_then(|(_, l)| st.doc.spread(l.spread).and_then(|sp| sp.items.get(l.top())).map(|it| it.id)) + .and_then(|id| order.iter().position(|x| *x == id)); + let n = order.len(); + let i = match (how, at) { + (Step::Top, _) | (Step::Down, None) => n - 1, + (Step::Bottom, _) | (Step::Up, None) => 0, + (Step::Up, Some(i)) => (i + 1).min(n - 1), + (Step::Down, Some(i)) => i.saturating_sub(1), + }; + st.selection = Selection::items(vec![order[i]]); + st.revision += 1; + Ok(json!({"id": order[i].0})) +} + +fn parent_loc(l: &ItemLoc) -> Option { + (l.path.len() > 1).then(|| ItemLoc { spread: l.spread, path: l.path[..l.path.len() - 1].to_vec() }) +} + +fn select_container(s: &mut Session) -> Result { + let st = s.doc_mut()?; + let Some(id) = st.selection.items.first().copied() else { return ok() }; + if st.selection.content { + st.selection = Selection::items(vec![id]); + } else if let Some(p) = st.doc.find(id).and_then(|l| parent_loc(&l)).and_then(|l| st.doc.item_at(&l).map(|it| it.id)) { + st.selection = Selection::items(vec![p]); + } else { + return ok(); + } + st.revision += 1; + Ok(json!({"id": st.selection.items[0].0})) +} + +fn select_content(s: &mut Session) -> Result { + let st = s.doc_mut()?; + let Some(id) = st.selection.items.first().copied() else { return ok() }; + let Some(it) = st.doc.item(id) else { return ok() }; + if let Some(c) = it.children().first() { + st.selection = Selection::items(vec![c.id]); + } else if matches!(it.content, Content::Graphic(_)) { + st.selection = Selection { content: true, ..Selection::items(vec![id]) }; + } else { + return ok(); + } + st.revision += 1; + Ok(json!({"id": st.selection.items[0].0})) +} + +fn select_sibling(s: &mut Session, dir: isize) -> Result { + let st = s.doc_mut()?; + let Some(id) = st.selection.items.first().copied() else { return ok() }; + let Some(l) = st.doc.find(id) else { return ok() }; + let Some(pl) = parent_loc(&l) else { return ok() }; + let Some(parent) = st.doc.item_at(&pl) else { return ok() }; + let kids = parent.children(); + let n = kids.len() as isize; + let i = *l.path.last().expect("nested") as isize; + // InDesign's "next" goes down the stack (towards the back), wrapping. + let j = (i - dir).rem_euclid(n) as usize; + let next = kids[j].id; + st.selection = Selection::items(vec![next]); + st.revision += 1; + Ok(json!({"id": next.0})) +} + +fn convert_shape(s: &mut Session, p: &Value) -> Result { + const ID: &str = "object.convertShape"; + let to = str_param(p, "to").ok_or_else(|| bad(ID, "`to` required"))?.to_string(); + let ids = targets(s, p)?; + let sides = s.prefs.polygon_sides; + let inset = s.prefs.star_inset; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.shape == Shape::Group { + continue; + } + convert(it, &to, sides, inset).map_err(|e| bad(ID, e))?; + changed += 1; + } + Ok(json!({"changed": changed})) + }) +} + +fn convert(it: &mut Item, to: &str, sides: u32, inset: f64) -> std::result::Result<(), String> { + let r: Rect = it.path.bounds().unwrap_or(Rect::ZERO); + let text = matches!(it.content, Content::Text(_)); + let corners = |shape: CornerShape| CornerOptions::uniform(shape, (r.width().min(r.height()) / 6.0).clamp(1.0, 12.0)); + let (path, shape, c) = match to { + "rectangle" => (shapes::rectangle(r), Shape::Rectangle, CornerOptions::default()), + "roundedRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::Rounded)), + "beveledRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::Bevel)), + "inverseRoundedRectangle" => (shapes::rectangle(r), Shape::Rectangle, corners(CornerShape::InverseRounded)), + "ellipse" => (shapes::ellipse(r), Shape::Oval, CornerOptions::default()), + "triangle" => (super::object::polygon_in(r, 3, 0.0), Shape::Polygon, CornerOptions::default()), + "polygon" => (super::object::polygon_in(r, sides, inset), Shape::Polygon, CornerOptions::default()), + "line" | "orthogonalLine" if text => return Err("a text frame can't become a line".into()), + "line" => (shapes::line(Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)), Shape::GraphicLine, CornerOptions::default()), + "orthogonalLine" => { + let c = r.center(); + let (a, b) = + if r.width() >= r.height() { (Point::new(r.x0, c.y), Point::new(r.x1, c.y)) } else { (Point::new(c.x, r.y0), Point::new(c.x, r.y1)) }; + (shapes::line(a, b), Shape::GraphicLine, CornerOptions::default()) + } + "openPath" | "closedPath" => { + let closed = to == "closedPath"; + if !closed && text { + return Err("a text frame needs a closed path".into()); + } + let mut path = it.path.clone(); + for sp in &mut path.subpaths { + sp.closed = closed; + } + (path, Shape::Path, it.corners) + } + o => return Err(format!("unknown shape `{o}`")), + }; + it.path = path; + it.shape = shape; + it.corners = c; + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn ids(s: &Session) -> Vec { + s.doc().unwrap().selection.items.iter().map(|i| i.0).collect() + } + + #[test] + fn select_by_stacking_order_and_groups() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mk = |s: &mut Session, x: f64| s.execute("frame.create", &json!({"rect": [x, 100.0, x + 50.0, 150.0]})).unwrap()["id"].as_u64().unwrap(); + let (a, b, c) = (mk(&mut s, 0.0), mk(&mut s, 60.0), mk(&mut s, 120.0)); + s.execute("select.lastBelow", &json!({})).unwrap(); + assert_eq!(ids(&s), [a]); + s.execute("select.nextAbove", &json!({})).unwrap(); + assert_eq!(ids(&s), [b]); + s.execute("select.firstAbove", &json!({})).unwrap(); + assert_eq!(ids(&s), [c]); + s.execute("select.nextAbove", &json!({})).unwrap(); + assert_eq!(ids(&s), [c], "already on top"); + s.execute("select.nextBelow", &json!({})).unwrap(); + assert_eq!(ids(&s), [b]); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::items(vec![designcraft_doc::ItemId(g)]); + s.execute("select.content", &json!({})).unwrap(); + let first = ids(&s)[0]; + assert!(first == a || first == b); + s.execute("select.nextInGroup", &json!({})).unwrap(); + assert_ne!(ids(&s)[0], first); + s.execute("select.nextInGroup", &json!({})).unwrap(); + assert_eq!(ids(&s)[0], first, "wraps"); + s.execute("select.container", &json!({})).unwrap(); + assert_eq!(ids(&s), [g]); + } + + #[test] + fn convert_shape_keeps_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); + let item = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().clone(); + for to in ["ellipse", "triangle", "polygon", "roundedRectangle", "rectangle"] { + s.execute("object.convertShape", &json!({"to": to})).unwrap(); + let b = item(&s).bounds(); + assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6, "{to}: {b:?}"); + } + s.execute("object.convertShape", &json!({"to": "orthogonalLine"})).unwrap(); + let it = item(&s); + assert_eq!(it.shape, designcraft_doc::Shape::GraphicLine); + assert_eq!(it.bounds().height(), 0.0); + s.execute("object.convertShape", &json!({"to": "closedPath"})).unwrap(); + assert!(item(&s).path.subpaths[0].closed); + let t = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "content": "text"})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("object.convertShape", &json!({"to": "line", "ids": [t]})).is_err()); + assert!(s.execute("object.convertShape", &json!({"to": "blob", "ids": [a]})).is_err()); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2d214d0..f92b383 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -122,6 +122,24 @@ fn table_specs() -> Vec { sort_rows ), cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge), + cmd!( + "table.splitHorizontally", + "Split Cell Horizontally", + ["Table"], + None, + "{} — the target cell becomes two, one above the other", + in_table, + |s, p| split(s, p, true) + ), + cmd!( + "table.splitVertically", + "Split Cell Vertically", + ["Table"], + None, + "{} — the target cell becomes two, side by side", + in_table, + |s, p| split(s, p, false) + ), cmd!( "table.setCell", "Cell Options", @@ -497,6 +515,14 @@ fn merge(s: &mut Session, p: &Value) -> Result { ok() } +fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { + let g = target(s, p, "table.split")?; + edit_table(s, &g, "table.split", |t| { + t.split_cell(g.range.r0, g.range.c0, horizontal); + Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) + }) +} + fn unmerge(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.unmerge")?; edit_table(s, &g, "table.unmerge", |t| { @@ -1128,5 +1154,8 @@ mod sort_tests { assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive"); s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap(); assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value"); + s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap(); + let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7862342..48c1c57 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -338,6 +338,32 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.outerGlow", "cmd:object.feather", "<", + ">Select", + "cmd:select.firstAbove", + "cmd:select.nextAbove", + "cmd:select.nextBelow", + "cmd:select.lastBelow", + "-", + "cmd:select.container", + "cmd:select.content", + "-", + "cmd:select.previousInGroup", + "cmd:select.nextInGroup", + "<", + ">Convert Shape", + "cmd:object.convertShape|Rectangle|{\"to\": \"rectangle\"}", + "cmd:object.convertShape|Rounded Rectangle|{\"to\": \"roundedRectangle\"}", + "cmd:object.convertShape|Beveled Rectangle|{\"to\": \"beveledRectangle\"}", + "cmd:object.convertShape|Inverse Rounded Rectangle|{\"to\": \"inverseRoundedRectangle\"}", + "cmd:object.convertShape|Ellipse|{\"to\": \"ellipse\"}", + "cmd:object.convertShape|Triangle|{\"to\": \"triangle\"}", + "cmd:object.convertShape|Polygon|{\"to\": \"polygon\"}", + "cmd:object.convertShape|Line|{\"to\": \"line\"}", + "cmd:object.convertShape|Orthogonal Line|{\"to\": \"orthogonalLine\"}", + "-", + "cmd:object.convertShape|Open Path|{\"to\": \"openPath\"}", + "cmd:object.convertShape|Closed Path|{\"to\": \"closedPath\"}", + "<", ">Captions", "cmd:object.caption", "<", @@ -385,6 +411,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:table.merge", "cmd:table.unmerge", + "cmd:table.splitHorizontally", + "cmd:table.splitVertically", "cmd:table.distributeColumns", "-", "ui:app.tablePanel", diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7d0fb8e..8148abd 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -754,11 +754,21 @@ fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { fn convert_shape_menu(app: &mut DesignApp, resp: &egui::Response) { egui::Popup::menu(resp).show(|ui| { - for (label, shape) in - [("Rectangle", "none"), ("Rounded Rectangle", "rounded"), ("Beveled Rectangle", "bevel"), ("Inverse Rounded Rectangle", "inverseRounded")] - { + for (label, to) in [ + ("Rectangle", "rectangle"), + ("Rounded Rectangle", "roundedRectangle"), + ("Beveled Rectangle", "beveledRectangle"), + ("Inverse Rounded Rectangle", "inverseRoundedRectangle"), + ("Ellipse", "ellipse"), + ("Triangle", "triangle"), + ("Polygon", "polygon"), + ("Line", "line"), + ("Orthogonal Line", "orthogonalLine"), + ] { if ui.button(label).clicked() { - let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": 12.0})); + if let Err(e) = app.run("object.convertShape", json!({"to": to})) { + app.status(format!("Convert Shape: {e}")); + } ui.close(); } } diff --git a/docs/parity.md b/docs/parity.md index 64d8b27..614b75a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 68 | 27 | 2 | 84% | +| P1 | 97 | 71 | 24 | 2 | 86% | | P2 | 76 | 12 | 13 | 51 | 24% | | Area | Parity | @@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Application shell & workspace | 80% | | Documents, pages, spreads | 84% | | Layers | 95% | -| Frames, shapes & paths | 88% | +| Frames, shapes & paths | 90% | | Transform | 100% | | Fill, stroke, colour | 86% | | Effects & transparency | 62% | @@ -26,7 +26,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Type & text frames | 88% | | Typography | 87% | | Styles | 92% | -| Tables | 85% | +| Tables | 88% | | Long documents | 79% | | Interactivity & digital | 35% | | Output & production | 70% | @@ -102,7 +102,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Frames, shapes & paths | Pencil, Smooth, Erase | P1 | D | | Frames, shapes & paths | Scissors | P1 | D | | Frames, shapes & paths | Content type | P0 | D | -| Frames, shapes & paths | Convert Shape | P1 | P | +| Frames, shapes & paths | Convert Shape | P1 | D | | Frames, shapes & paths | Corner Options | P0 | D | | Frames, shapes & paths | Pathfinder | P1 | D | | Frames, shapes & paths | Compound paths | P1 | D | @@ -122,7 +122,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Frames, shapes & paths | Group/Ungroup | P0 | D | | Frames, shapes & paths | Lock / Unlock All on Spread | P0 | D | | Frames, shapes & paths | Hide / Show All on Spread | P1 | D | -| Frames, shapes & paths | Select submenu | P1 | P | +| Frames, shapes & paths | Select submenu | P1 | D | | Frames, shapes & paths | Select All / Deselect All | P0 | D | | Frames, shapes & paths | Duplicate | P0 | D | | Frames, shapes & paths | Paste Into / Paste in Place | P0 | D | @@ -248,7 +248,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Tables | Create / Insert Table | P1 | D | | Tables | Convert Text to Table / Table to Text | P1 | D | | Tables | Rows/columns insert/delete/resize/distribute | P1 | D | -| Tables | Merge/Unmerge/Split cells | P1 | P | +| Tables | Merge/Unmerge/Split cells | P1 | D | | Tables | Header/footer rows | P1 | D | | Tables | Table options | P1 | D | | Tables | Cell options | P1 | D |