From b6162dd11d8cd1e6f0b0bd8f36d355a45b9eddea Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 11:11:56 -0400 Subject: [PATCH 1/2] Transform dialogs: Rotate and Shear open their dialogs (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parameter written as `key (description)` was dropped by the command form parser, so Object › Transform › Rotate… and Shear… (and Basic Feather) had no required field, opened no dialog and ran with defaults. The key is now the leading word and the parenthetical becomes its hint. Double-clicking the Selection, Rotate, Scale or Shear tool opens Move, Rotate, Scale or Shear. --- crates/ui-egui/src/dialogs.rs | 22 +++++++++++++++++++--- crates/ui-egui/src/menus.rs | 10 ++++++++++ crates/ui-egui/src/toolbar.rs | 19 ++++++++++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfa83d4a..fa88e8d2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2380,10 +2380,14 @@ pub fn command_fields(doc: &str) -> Vec { let ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !s.starts_with(|c: char| c.is_ascii_digit()); let mut out = Vec::new(); for part in parts { + // `key`, `key?`, `key: spec` or `key (spec)`: the key is the leading word. let part = part.trim(); - let (k, spec) = match part.split_once(':') { - Some((k, v)) => (k.trim(), v.trim()), - None => (part, ""), + let k_end = part.find(|c: char| c.is_whitespace() || c == ':' || c == '(').unwrap_or(part.len()); + let (k, rest) = part.split_at(k_end); + let rest = rest.trim_start(); + let spec = match rest.strip_prefix(':') { + Some(v) => v.trim(), + None => rest.strip_prefix('(').and_then(|r| r.strip_suffix(')')).unwrap_or(rest).trim(), }; let optional = k.ends_with('?'); let key = k.trim_end_matches('?'); @@ -2643,6 +2647,18 @@ mod tests { assert!(f[4].boolean); assert!(command_fields("{}").is_empty()); assert!(command_fields("no params").is_empty()); + // A parenthetical after the key describes the value. + let f = command_fields("{angle (degrees, CCW), ids?}"); + let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect(); + assert_eq!(keys, ["angle", "ids"]); + assert!(!f[0].optional && f[1].optional); + assert_eq!(f[0].hint, "degrees, CCW"); + let f = command_fields("{scaleX? (%), ref?: 0..8, ids?: parent items (default: all)}"); + let keys: Vec<&str> = f.iter().map(|f| f.key.as_str()).collect(); + assert_eq!(keys, ["scaleX", "ref", "ids"]); + assert!(f.iter().all(|f| f.optional)); + assert_eq!(f[0].hint, "%"); + assert_eq!(f[2].hint, "parent items (default: all)"); // Every command with a "…" label parses without panicking. for c in designcraft_engine::command_specs() { let _ = command_fields(c.params); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f192..1b50bafc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1996,6 +1996,16 @@ mod tests { } } + #[test] + fn transform_menu_items_open_their_dialogs() { + for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + activate(&mut app, id, &Value::Null); + let dialog = app.ui.dialog.as_ref().map(|d| d.id.clone()); + assert_eq!(dialog.as_deref(), Some(format!("cmd:{id}").as_str()), "{id} opens its dialog"); + } + } + #[test] fn snap_preferences_are_a_command() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4f110df4..a3d49547 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -3,7 +3,7 @@ use designcraft_tools::TOOL_GROUPS; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; -use serde_json::json; +use serde_json::{Value, json}; use crate::theme::Tokens; use crate::{DesignApp, icons}; @@ -91,6 +91,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default(); app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur)); } + // Double-click a transform tool: its Object › Transform dialog. + if resp.double_clicked() + && let Some(cmd) = transform_dialog_of(shown.id) + { + crate::menus::activate(app, cmd, &Value::Null); + } if g.len() > 1 && (resp.secondary_clicked() || resp.long_touched() @@ -343,6 +349,17 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }); } +/// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move). +fn transform_dialog_of(tool: &str) -> Option<&'static str> { + match tool { + "selection" => Some("transform.move"), + "rotate" => Some("transform.rotate"), + "scale" => Some("transform.scale"), + "shear" => Some("transform.shear"), + _ => None, + } +} + fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) { let q = r.right_bottom() - vec2(2.0, 2.0); p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE)); From 0193ac9285a2b4c9aaa991d6aa27e021e8a4e10a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 02:00:34 -0400 Subject: [PATCH 2/2] UI: the Scale and Shear tools get their own icons Scale: a small square grown into a larger one from their shared corner, with a diagonal arrow. Shear: a rectangle slanted into a parallelogram along its base, with an arrow. Both are drawn in code in the icon set's stroke weight; Free Transform keeps its drawing. --- crates/ui-egui/src/icons.rs | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 00169d47..fee8369f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -147,13 +147,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, 12.5), (14.0, 3.0)]); pen.line(&[(12.5, 12.5), (6.0, 3.0)]); } - "tool-free-transform" | "tool-scale" | "tool-shear" => { + "tool-free-transform" => { pen.rect(4.0, 4.0, 13.0, 13.0); pen.frect(2.8, 2.8, 5.2, 5.2); pen.frect(11.8, 11.8, 14.2, 14.2); pen.line(&[(14.0, 14.0), (18.0, 18.0)]); pen.line(&[(15.0, 18.0), (18.0, 18.0), (18.0, 15.0)]); } + "tool-scale" => { + // A small square grown into a larger one from their shared corner. + Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 3.0, 17.0, 17.0); + pen.rect(3.0, 10.0, 10.0, 17.0); + pen.line(&[(10.0, 10.0), (15.0, 5.0)]); + pen.line(&[(11.5, 5.0), (15.0, 5.0), (15.0, 8.5)]); + } + "tool-shear" => { + // A rectangle slanted into a parallelogram along its base. + Pen { p, r, c: c.gamma_multiply(0.45), w: 1.0 }.rect(3.0, 8.0, 13.0, 17.0); + pen.closed(&[(7.0, 8.0), (17.0, 8.0), (13.0, 17.0), (3.0, 17.0)]); + pen.line(&[(7.0, 4.0), (16.0, 4.0)]); + pen.line(&[(13.5, 2.0), (16.0, 4.0), (13.5, 6.0)]); + } "tool-rotate" => { let pts: Vec<(f32, f32)> = (0..24) .map(|i| { @@ -791,3 +805,31 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st }) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What icon `name` paints into a 20×20 rect, as the painter's shape list. + fn drawing(name: &str) -> String { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); + }); + out.textures_delta.clear(); + format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::>()) + } + + #[test] + fn transform_tools_have_their_own_icons() { + let names = ["tool-free-transform", "tool-scale", "tool-shear"]; + let unknown = drawing("no-such-icon"); + let drawn: Vec = names.iter().map(|n| drawing(n)).collect(); + for (n, d) in names.iter().zip(&drawn) { + assert_ne!(*d, unknown, "{n} falls back to the placeholder"); + } + assert_ne!(drawn[0], drawn[1], "Scale draws like Free Transform"); + assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); + assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); + } +}