feat(ui): Rotation, shear, scale, rotate and flip in Properties › Transform

InDesign's Properties › Transform shows, under X/Y/W/H, the rotation
and shear angles beside the X and Y scale, then buttons to rotate a
quarter turn either way and to flip horizontally or vertically.
DesignCraft's showed only X/Y/W/H; the rest was in the Control bar
and Object › Transform. The fields set totals through transform.set
around the reference point; the buttons run transform.rotate and
transform.flip. Rotate and flip icons are original drawings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
moppeeerandClaude Opus 5.5 authored and paaspaas00 committed 2026-10-12 18:29:08 +02:00
1 parent d020b4e85d
commit 8fd67c8674
3 files changed
+115

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ Newest first.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. 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.
+23
View File
@@ -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;
+91
View File
@@ -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::<bool>(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());
}
}