mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-09 03:50:04 -06:00
feat(ui): Properties palette shows the type's properties for several objects of one type
With more than one object selected the palette showed only General. When all selected objects are of one type (line, circle, arc, text, mtext, polyline, hatch, block reference, dimension) it now shows that type's properties, `*VARIES*` where they differ; an edit applies to every selected object. Positions stay read-only, since typing one would move all objects to the same place. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
7cee28580d
commit
c223550cb3
+284
-12
@@ -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<String> = 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<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
|
||||
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<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
|
||||
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::<Option<Vec<_>>>()
|
||||
{
|
||||
// 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<String> = 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::<u8>().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>| -> String {
|
||||
let vals: Vec<Option<String>> = 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<String>) {
|
||||
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<String> {
|
||||
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<String> = 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user