mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
d020b4e85d
commit
8fd67c8674
3 files changed
+115
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user