diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 7e01a15d4..63e18c8f9 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -705,6 +705,9 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// What the palette shows for a property that differs between the selected objects. +const VARIES: &str = "*VARIES*"; + /// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands. pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); @@ -874,11 +877,11 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let vals: Vec = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect(); match vals.first() { Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), - _ => "*VARIES*".into(), + _ => VARIES.into(), } }; let selected_color = Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c)); - let color = selected_color.map_or_else(|| "*VARIES*".into(), Color::name); + let color = selected_color.map_or_else(|| VARIES.into(), Color::name); let effective_color = |e: &cadcraft_doc::Entity| { let layer = d.layer(&e.common.layer).map_or(Color::WHITE, |l| l.color); e.common.color.resolve(layer, Color::WHITE) @@ -993,18 +996,18 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); }); + let real = |v: f64| format!("{v:.4}"); + // Rotations show as the drawing measures directions (ANGBASE, ANGDIR). + let angles = app.session.angle_settings(); + let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option, bad: &mut Option, ids: &Vec| { + prop_row(ui, label, |ui| { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) { + *set = Some(json!({ "handles": ids, key: f })); + } + }); + }; if sel.len() == 1 { group(ui, "Geometry"); - let real = |v: f64| format!("{v:.4}"); - // Rotations show as the drawing measures directions (ANGBASE, ANGDIR). - let angles = app.session.angle_settings(); - let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option, bad: &mut Option, ids: &Vec| { - prop_row(ui, label, |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) { - *set = Some(json!({ "handles": ids, key: f })); - } - }); - }; match &e.kind { EntityKind::Line(l) => { prop_row(ui, "Start X", |ui| { @@ -1159,6 +1162,184 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } _ => {} } + } else if let Some(dims) = sel + .iter() + .map(|h| match d.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Dimension(dm)) => Some(dm), + _ => None, + }) + .collect::>>() + { + // Several dimensions: every property of one dimension, `*VARIES*` where they differ; an + // edit applies to all of them. + group(ui, "Geometry"); + let shared = |f: &dyn Fn(&cadcraft_doc::Dimension, &cadcraft_doc::DimStyle) -> String| -> String { + let vals: Vec = dims + .iter() + .map(|dm| { + let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + f(dm, &st) + }) + .collect(); + match vals.first() { + Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), + _ => VARIES.into(), + } + }; + let text = shared(&|dm, _| dm.text.clone()); + prop_row(ui, "Text override", |ui| { + if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), text.clone()) { + set = Some(json!({ "handles": ids, "textOverride": s })); + } + }); + let style = shared(&|dm, _| dm.style.clone()); + prop_row(ui, "Dim style", |ui| { + if let Some(n) = pick_menu(ui, &style, d.dim_styles.iter().map(|st| st.name.clone())) { + set = Some(json!({ "handles": ids, "dimStyle": n })); + } + }); + let numbers = [ + ("Text height", "textHeight", shared(&|_, st| len(st.text_height))), + ("Arrow size", "arrowSize", shared(&|_, st| len(st.arrow_size))), + ("Dim scale overall", "dimScale", shared(&|_, st| real(st.scale))), + ]; + for (label, key, shown) in numbers { + prop_row(ui, label, |ui| { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, &ids)), shown), &mut bad) { + set = Some(json!({ "handles": ids, key: f })); + } + }); + } + let precision = shared(&|_, st| st.decimals.to_string()); + prop_row(ui, "Precision", |ui| { + if let Some(n) = pick_menu(ui, &precision, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::().ok()) { + set = Some(json!({ "handles": ids, "precision": n })); + } + }); + } else if sel.iter().filter_map(|h| d.entity(*h)).all(|x| std::mem::discriminant(&x.kind) == std::mem::discriminant(&e.kind)) { + // Several objects of one type: the properties of one object, `*VARIES*` where they differ. + // Edits apply to all of them; positions are shown but stay read-only, since typing one + // would move every object to the same place. + group(ui, "Geometry"); + let shared = |f: &dyn Fn(&EntityKind) -> Option| -> String { + let vals: Vec> = sel.iter().filter_map(|h| d.entity(*h)).map(|x| f(&x.kind)).collect(); + match vals.first() { + Some(Some(v0)) if vals.iter().all(|v| v.as_ref() == Some(v0)) => v0.clone(), + _ => VARIES.into(), + } + }; + let deg = |v: f64| real(angles.shown(v).to_degrees()); + match &e.kind { + EntityKind::Line(_) => { + let line = |k: &EntityKind| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None }; + for (label, value) in [ + ("Start X", shared(&|k| line(k).map(|l| len(l.a.x)))), + ("Start Y", shared(&|k| line(k).map(|l| len(l.a.y)))), + ("End X", shared(&|k| line(k).map(|l| len(l.b.x)))), + ("End Y", shared(&|k| line(k).map(|l| len(l.b.y)))), + ("Delta X", shared(&|k| line(k).map(|l| len(l.b.x - l.a.x)))), + ("Delta Y", shared(&|k| line(k).map(|l| len(l.b.y - l.a.y)))), + ] { + prop_row(ui, label, |ui| read_only(ui, &value)); + } + num(ui, "Length", "length", shared(&|k| line(k).map(|l| len((l.b.xy() - l.a.xy()).len()))), &mut set, &mut bad, &ids); + num(ui, "Angle", "angle", shared(&|k| line(k).map(|l| real((l.b.xy() - l.a.xy()).angle().to_degrees()))), &mut set, &mut bad, &ids); + } + EntityKind::Circle(_) => { + let circle = |k: &EntityKind| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None }; + prop_row(ui, "Center X", |ui| read_only(ui, &shared(&|k| circle(k).map(|c| len(c.center.x))))); + prop_row(ui, "Center Y", |ui| read_only(ui, &shared(&|k| circle(k).map(|c| len(c.center.y))))); + num(ui, "Radius", "radius", shared(&|k| circle(k).map(|c| len(c.radius))), &mut set, &mut bad, &ids); + num(ui, "Diameter", "diameter", shared(&|k| circle(k).map(|c| len(c.radius * 2.0))), &mut set, &mut bad, &ids); + num( + ui, + "Circumference", + "circumference", + shared(&|k| circle(k).map(|c| len(c.radius * std::f64::consts::TAU))), + &mut set, + &mut bad, + &ids, + ); + num(ui, "Area", "area", shared(&|k| circle(k).map(|c| real(c.radius * c.radius * std::f64::consts::PI))), &mut set, &mut bad, &ids); + } + EntityKind::Arc(_) => { + let arc = |k: &EntityKind| if let EntityKind::Arc(a) = k { Some(a.clone()) } else { None }; + num(ui, "Radius", "radius", shared(&|k| arc(k).map(|a| len(a.radius))), &mut set, &mut bad, &ids); + num(ui, "Start angle", "startAngle", shared(&|k| arc(k).map(|a| real(a.start.to_degrees()))), &mut set, &mut bad, &ids); + num(ui, "End angle", "endAngle", shared(&|k| arc(k).map(|a| real(a.end.to_degrees()))), &mut set, &mut bad, &ids); + } + EntityKind::Text(_) => { + let text = |k: &EntityKind| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None }; + let contents = shared(&|k| text(k).map(|t| t.value)); + prop_row(ui, "Contents", |ui| { + if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), contents.clone()) { + set = Some(json!({ "handles": ids, "text": s })); + } + }); + num(ui, "Height", "height", shared(&|k| text(k).map(|t| len(t.height))), &mut set, &mut bad, &ids); + num(ui, "Rotation", "rotation", shared(&|k| text(k).map(|t| deg(t.rotation))), &mut set, &mut bad, &ids); + num(ui, "Width factor", "widthFactor", shared(&|k| text(k).map(|t| real(t.width_factor))), &mut set, &mut bad, &ids); + } + EntityKind::MText(_) => { + let mtext = |k: &EntityKind| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None }; + let contents = shared(&|k| mtext(k).map(|t| t.contents)); + prop_row(ui, "Contents", |ui| { + if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), contents.clone()) { + set = Some(json!({ "handles": ids, "text": s })); + } + }); + num(ui, "Text height", "height", shared(&|k| mtext(k).map(|t| len(t.height))), &mut set, &mut bad, &ids); + num(ui, "Defined width", "width", shared(&|k| mtext(k).map(|t| len(t.width))), &mut set, &mut bad, &ids); + } + EntityKind::LwPolyline(_) => { + let poly = |k: &EntityKind| { + if let EntityKind::LwPolyline(p) = k { + Some((p.const_width, p.closed, cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed })) + } else { + None + } + }; + num(ui, "Global width", "width", shared(&|k| poly(k).map(|(w, ..)| len(w))), &mut set, &mut bad, &ids); + prop_row(ui, "Length", |ui| read_only(ui, &shared(&|k| poly(k).map(|(.., g)| len(g.len()))))); + prop_row(ui, "Area", |ui| { + read_only(ui, &shared(&|k| poly(k).map(|(_, closed, g)| if closed { real(g.area().abs()) } else { String::new() }))) + }); + let closed = shared(&|k| poly(k).map(|(_, c, _)| crate::i18n::t(if c { "Yes" } else { "No" }).to_string())); + prop_row(ui, "Closed", |ui| { + if let Some(n) = pick_menu(ui, &closed, ["Yes", "No"].map(|s| crate::i18n::t(s).to_string())) { + set = Some(json!({ "handles": ids, "closed": n == crate::i18n::t("Yes") })); + } + }); + } + EntityKind::Hatch(_) => { + let hatch = |k: &EntityKind| if let EntityKind::Hatch(h) = k { Some(h.clone()) } else { None }; + let pattern = shared(&|k| hatch(k).map(|h| h.pattern)); + prop_row(ui, "Pattern", |ui| { + ui.menu_button(pattern.clone(), |ui| { + if let Some(name) = patterns::menu(ui, &pattern) { + set = Some(json!({ "handles": ids, "pattern": name })); + ui.close(); + } + }); + }); + num(ui, "Scale", "scale", shared(&|k| hatch(k).map(|h| real(h.scale))), &mut set, &mut bad, &ids); + num(ui, "Angle", "angle", shared(&|k| hatch(k).map(|h| real(h.angle.to_degrees()))), &mut set, &mut bad, &ids); + } + EntityKind::Insert(_) => { + let insert = |k: &EntityKind| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None }; + for (label, value) in [ + ("Name", shared(&|k| insert(k).map(|i| i.block))), + ("Position X", shared(&|k| insert(k).map(|i| len(i.insert.x)))), + ("Position Y", shared(&|k| insert(k).map(|i| len(i.insert.y)))), + ("Position Z", shared(&|k| insert(k).map(|i| len(i.insert.z)))), + ] { + prop_row(ui, label, |ui| read_only(ui, &value)); + } + num(ui, "Rotation", "rotation", shared(&|k| insert(k).map(|i| deg(i.rotation))), &mut set, &mut bad, &ids); + num(ui, "Scale", "scale", shared(&|k| insert(k).map(|i| real(i.scale.x))), &mut set, &mut bad, &ids); + } + _ => {} + } } if let Some(mut p) = set { // A rotation is typed as shown (from ANGBASE, in the ANGDIR sense); properties.set takes it @@ -1209,4 +1390,95 @@ mod tests { } assert!(painted_blue, "a ByLayer entity on a blue layer needs a blue Properties swatch"); } + + /// The texts of a painted shape, one string per text. + fn collect_text(shape: &Shape, out: &mut Vec) { + match shape { + Shape::Text(t) => out.push(t.galley.text().to_string()), + Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)), + _ => {} + } + } + + /// Everything the Properties palette paints for the current selection, one string per text. + fn painted_texts(app: &mut CadApp, section: fn(&mut CadApp, &mut egui::Ui)) -> Vec { + let ctx = egui::Context::default(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() }; + let mut texts = Vec::new(); + for _ in 0..2 { + app.logic(&ctx); + let mut output = ctx.run_ui(input.clone(), |ui| section(app, ui)); + texts.clear(); + output.shapes.iter().for_each(|s| collect_text(&s.shape, &mut texts)); + output.textures_delta.clear(); + } + texts + } + + /// Several dimensions selected: the Properties palette shows all of a dimension's properties, + /// `*VARIES*` for those that differ, and an edit applies to every dimension. + #[test] + fn several_dimensions_show_every_property_with_varies_for_different_ones() { + let mut app = CadApp::new(Session::default(), Services::default()); + let first = app.run("dimlinear", json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 3] })).unwrap()["handle"].as_str().unwrap().to_string(); + let second = + app.run("dimlinear", json!({ "p1": [0, 0], "p2": [20, 0], "at": [5, 6], "text": "X" })).unwrap()["handle"].as_str().unwrap().to_string(); + app.run("select", json!({ "handles": [first, second] })).unwrap(); + + let texts = painted_texts(&mut app, properties_section); + for want in ["Text override", "Dim style", "Text height", "Arrow size", "Dim scale overall", "Precision"] { + assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "the palette shows `{want}`; painted: {texts:?}"); + } + assert!(texts.iter().any(|t| t == VARIES), "the differing text override shows {VARIES}; painted: {texts:?}"); + + app.run("properties.set", json!({ "handles": [first, second], "textHeight": 7 })).unwrap(); + let texts = painted_texts(&mut app, properties_section); + assert!(texts.iter().any(|t| t == "7.0000" || t == "7"), "the common text height is shown; painted: {texts:?}"); + } + + /// Two objects of each type, selected together: the palette shows the type's properties (not + /// just General), `*VARIES*` for the ones that differ, and an edit reaches both objects. + #[test] + fn several_objects_of_one_type_show_their_properties_with_varies() { + let cases: [(&str, [Value; 2], &[&str]); 6] = [ + ("line", [json!({ "points": [[0, 0], [10, 0]] }), json!({ "points": [[0, 5], [20, 5]] })], &["Start X", "Length", "Angle"]), + ("circle", [json!({ "center": [0, 0], "radius": 2 }), json!({ "center": [9, 9], "radius": 5 })], &["Center X", "Radius", "Area"]), + ( + "text", + [json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })], + &["Contents", "Height", "Rotation"], + ), + ( + "pline", + [json!({ "vertices": [[0, 0], [4, 0], [4, 4]] }), json!({ "vertices": [[0, 9], [8, 9], [8, 12]] })], + &["Global width", "Length", "Closed"], + ), + ( + "arc", + [json!({ "center": [0, 0], "radius": 3, "start": 0, "end": 90 }), json!({ "center": [9, 0], "radius": 6, "start": 10, "end": 80 })], + &["Radius", "Start angle", "End angle"], + ), + ( + "mtext", + [json!({ "at": [0, 0], "text": "A", "height": 2 }), json!({ "at": [0, 9], "text": "B", "height": 3 })], + &["Contents", "Text height", "Defined width"], + ), + ]; + for (command, params, labels) in cases { + let mut app = CadApp::new(Session::default(), Services::default()); + let handles: Vec = params + .iter() + .map(|p| { + let out = app.run(command, p.clone()).unwrap(); + out["handle"].as_str().map_or_else(|| out["handles"][0].as_str().unwrap().to_string(), str::to_string) + }) + .collect(); + app.run("select", json!({ "handles": handles })).unwrap(); + let texts = painted_texts(&mut app, properties_section); + for want in labels { + assert!(texts.iter().any(|t| t.as_str() == crate::i18n::t(want)), "{command}: the palette shows `{want}`; painted: {texts:?}"); + } + assert!(texts.iter().any(|t| t == VARIES), "{command}: differing values show {VARIES}; painted: {texts:?}"); + } + } }