diff --git a/ROADMAP.md b/ROADMAP.md index ed072db..202e808 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Newest first. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9..dba5a7a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -178,6 +178,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&pts); pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); } + // Properties › Transform: rotate a quarter turn (an arc over a square), flip (a shape and its + // mirror image across a dashed axis). + "rotate-cw" | "rotate-ccw" => { + let m = |x: f32| if name == "rotate-ccw" { 20.0 - x } else { x }; + pen.rect(m(3.0).min(m(11.0)), 9.0, m(3.0).max(m(11.0)), 17.0); + let pts: Vec<(f32, f32)> = (0..12) + .map(|i| { + let a = std::f32::consts::PI * (1.0 - i as f32 / 11.0); + (m(11.0 + 5.0 * a.cos()), 9.0 - 5.0 * a.sin()) + }) + .collect(); + pen.line(&pts); + pen.line(&[(m(14.0), 7.0), (m(16.0), 9.5), (m(18.0), 7.0)]); + } + "flip-h" | "flip-v" => { + let t = |x: f32, y: f32| if name == "flip-v" { (y, x) } else { (x, y) }; + pen.fill(&[t(3.0, 16.0), t(8.5, 4.0), t(8.5, 16.0)]); + pen.closed(&[t(11.5, 16.0), t(11.5, 4.0), t(17.0, 16.0)]); + for k in 0..4 { + let y0 = 2.5 + k as f32 * 4.0; + pen.line(&[t(10.0, y0), t(10.0, y0 + 2.0)]); + } + } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { let a = 30 + i * 35; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920..17473fb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -460,11 +460,62 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { set.insert("ref".into(), json!(rp)); let _ = app.run("transform.set", Value::Object(set)); } + transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("info".into()); } } +/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the +/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. +fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tv = app.session.execute("transform.info", &json!({})).ok(); + let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 4.0); + let mut set = serde_json::Map::new(); + let rows = [ + [("tool-rotate", "Rotation Angle", "rotation", "°", "p_rot"), ("tool-scale", "Scale X Percentage", "scaleX", "%", "p_sx")], + [("tool-shear", "Shear X Angle", "shear", "°", "p_shear"), ("tool-scale", "Scale Y Percentage", "scaleY", "%", "p_sy")], + ]; + for (row, cells) in rows.into_iter().enumerate() { + let yy = row as f32 * 25.0; + for (col, (icon, tip, key, suffix, id)) in cells.into_iter().enumerate() { + let x = if col == 0 { 40.0 } else { 120.0 }; + let ir = sub(b, x, yy + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { + set.insert(key.into(), json!(v)); + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, tip, cmd, params) in [ + ("rotate-cw", "Rotate 90° Clockwise", "transform.rotate", json!({"angle": -90})), + ("rotate-ccw", "Rotate 90° Counterclockwise", "transform.rotate", json!({"angle": 90})), + ("flip-h", "Flip Horizontal", "transform.flip", json!({"axis": "horizontal"})), + ("flip-v", "Flip Vertical", "transform.flip", json!({"axis": "vertical"})), + ] { + let label = crate::i18n::tr(&lang, tip); + let resp = icon_toggle(ui, icon, false, label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if resp.clicked() { + let _ = app.run(cmd, params); + } + } + }); +} + /// A stroke-type preview dropdown (light box with the line style). fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ @@ -2207,4 +2258,44 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + #[test] + fn transform_flips_and_rotates_from_properties() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let xf = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().xf; + // Flip Horizontal mirrors the frame (a negative determinant); Flip Vertical as well brings it + // back to a half turn, as in the tutorial's petal. + h.get_by_label("Flip Horizontal").click(); + h.run_steps(3); + assert!(xf(&h).determinant() < 0.0, "{:?}", xf(&h)); + h.get_by_label("Flip Vertical").click(); + h.run_steps(3); + let c = xf(&h).as_coeffs(); + assert!((c[0] + 1.0).abs() < 1e-9 && (c[3] + 1.0).abs() < 1e-9, "a half turn: {c:?}"); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap().abs() - 180.0).abs() < 1e-6, "{info}"); + // A quarter turn clockwise from there. + h.get_by_label("Rotate 90° Clockwise").click(); + h.run_steps(3); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap() - 90.0).abs() < 1e-6, "{info}"); + // The angle and scale fields are there. + assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); + } }