Files
OneCAD/crates/ui-egui/src/design_properties.rs
T
Lasse Lian a4a8b48567 Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
2026-10-14 18:25:35 -04:00

217 lines
10 KiB
Rust

//! The Design Properties window (#1015): the design's project, part number, revision, author,
//! organization, description and licence, and a read-only list of the data other tools keep in
//! the design. OK applies the edited fields through one `document.metadata.set` (one undo step);
//! Cancel and the close button change nothing. File → Design Properties…, the Browser's
//! Document Settings, the root component's menu and `ui.start` of `document.metadata` or
//! `document.metadata.set` open it.
use egui::RichText;
use serde_json::{Map, Value};
use solidcraft_engine::doc::metadata::{DESCRIPTION, WELL_KNOWN};
use crate::SolveApp;
use crate::theme::Tokens;
/// Longest preview of another tool's value in the list.
const PREVIEW_CHARS: usize = 80;
/// What is being typed; the properties themselves live in the document.
pub struct DesignProperties {
/// Each well-known key's text when the window opened, in [`WELL_KNOWN`] order.
pub opened: [String; WELL_KNOWN.len()],
/// The fields as edited.
pub fields: [String; WELL_KNOWN.len()],
/// The last refusal from `document.metadata.set`.
pub error: Option<String>,
}
impl DesignProperties {
pub fn new(app: &SolveApp) -> Self {
let m = &app.session.doc.metadata;
let opened = WELL_KNOWN.map(|k| m.text(k).unwrap_or_default().to_string());
DesignProperties { fields: opened.clone(), opened, error: None }
}
/// The fields that differ from what the window opened with, as `document.metadata.set`
/// takes them (empty text removes the key).
pub fn changes(&self) -> Map<String, Value> {
WELL_KNOWN
.iter()
.zip(self.opened.iter().zip(&self.fields))
.filter(|(_, (was, now))| was != now)
.map(|(k, (_, now))| (k.to_string(), Value::String(now.clone())))
.collect()
}
}
/// Open the window (a fresh copy of the design's properties).
pub fn open(app: &mut SolveApp) {
app.design_properties = Some(DesignProperties::new(app));
}
/// Apply the edits; the window stays open with the reason when they are refused.
fn apply(app: &mut SolveApp, d: &mut DesignProperties) -> bool {
let values = d.changes();
if values.is_empty() {
return true;
}
match app.run("document.metadata.set", serde_json::json!({ "values": values })) {
Ok(_) => true,
Err(e) => {
d.error = Some(e);
false
}
}
}
fn label(key: &str) -> &'static str {
match key {
"project" => crate::i18n::tr("ui.design_properties.project"),
"part_number" => crate::i18n::tr("ui.design_properties.part_number"),
"revision" => crate::i18n::tr("ui.design_properties.revision"),
"author" => crate::i18n::tr("ui.design_properties.author"),
"organization" => crate::i18n::tr("ui.design_properties.organization"),
"description" => crate::i18n::tr("ui.design_properties.description"),
_ => crate::i18n::tr("ui.design_properties.license"),
}
}
/// A short one-line view of a value another tool keeps.
fn preview(v: &Value) -> String {
let text = serde_json::to_string(v).unwrap_or_default();
if text.chars().count() <= PREVIEW_CHARS { text } else { format!("{}…", text.chars().take(PREVIEW_CHARS).collect::<String>()) }
}
/// Draw the window when it is open. Called once per frame.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut d) = app.design_properties.take() else { return };
let t = Tokens::get();
let mut open = true;
let (mut ok, mut cancel) = (false, false);
let metadata = app.session.doc.metadata.clone();
let title = RichText::new(crate::i18n::tr_label("Design Properties")).strong().size(13.0);
crate::frame::window(ctx, title, crate::frame::Width::Normal)
.id(egui::Id::new("sc_design_properties"))
.open(&mut open)
.resizable(false)
.min_width(420.0)
.show(ctx, |ui| {
egui::Grid::new("sc_design_properties_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
for (i, key) in WELL_KNOWN.iter().enumerate() {
ui.label(label(key));
let Some(field) = d.fields.get_mut(i) else { continue };
ui.vertical(|ui| {
let edit =
if *key == DESCRIPTION { egui::TextEdit::multiline(field).desired_rows(3) } else { egui::TextEdit::singleline(field) };
ui.add(edit.desired_width(280.0));
// Saved by another tool as something other than text: kept unless typed over.
if metadata.get(key).is_some() && metadata.text(key).is_none() && d.opened.get(i).is_some_and(String::is_empty) {
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.not_text")).small().color(t.text_dim));
}
});
ui.end_row();
}
});
ui.add_space(4.0);
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.exports")).small().color(t.text_dim));
let others: Vec<(&String, &Value)> = metadata.others().collect();
if !others.is_empty() {
ui.add_space(4.0);
egui::CollapsingHeader::new(format!("{} ({})", crate::i18n::tr("ui.design_properties.others"), others.len()))
.id_salt("sc_design_properties_others")
.show(ui, |ui| {
ui.label(RichText::new(crate::i18n::tr("ui.design_properties.others_note")).small().color(t.text_dim));
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
for (k, v) in others {
ui.label(RichText::new(k).monospace().size(12.0).color(t.text));
ui.label(RichText::new(preview(v)).monospace().small().color(t.text_dim));
}
});
});
}
if let Some(e) = &d.error {
ui.label(RichText::new(e).color(t.error));
}
ui.separator();
// OK and Cancel flush right, as in every dialog.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel = ui.button(crate::i18n::tr("ui.cancel")).clicked();
ok = ui.button(crate::i18n::tr("ui.heading_ok")).clicked();
});
});
});
if !open || cancel || (ok && apply(app, &mut d)) {
return;
}
app.design_properties = Some(d);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::scenario::Harness;
#[test]
fn the_window_edits_properties_in_one_undo_step_and_keeps_other_tools_data() {
let mut h = Harness::new();
h.app.run("document.metadata.set", json!({"values": {"author": "Jane", "com.acme.tool": {"id": 7}}})).unwrap();
// A well-known key another tool saved as a number.
h.app.session.doc_mut().metadata.0.insert("revision".into(), json!(3));
let undo = h.app.session.undo.len();
h.step(&json!({"start": "document.metadata.set"})).unwrap();
assert!(h.app.design_properties.is_some(), "ui.start opens the window, it doesn't run the command");
assert!(h.widget("Jane").is_some(), "the author's field");
assert!(h.widget("Other tools' data (1)").is_some());
let d = h.app.design_properties.as_mut().unwrap();
assert_eq!(d.opened[2], "", "a number is not a revision");
d.fields[0] = "ACME enclosures".into();
d.fields[3] = String::new();
h.frames(2);
h.step(&json!({"widget": "OK"})).unwrap();
assert!(h.app.design_properties.is_none());
let m = &h.app.session.doc.metadata;
assert_eq!(m.project(), Some("ACME enclosures"));
assert_eq!(m.author(), None, "an emptied field removes the key");
assert_eq!(m.get("revision"), Some(&json!(3)), "an untouched field keeps what another tool saved");
assert_eq!(m.get("com.acme.tool"), Some(&json!({"id": 7})));
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
// Cancel changes nothing; a refused value keeps the window open with the reason.
h.step(&json!({"start": "document.metadata"})).unwrap();
h.app.design_properties.as_mut().unwrap().fields[0] = "Other".into();
h.frames(2);
h.step(&json!({"widget": "Cancel"})).unwrap();
assert!(h.app.design_properties.is_none());
assert_eq!(h.app.session.doc.metadata.project(), Some("ACME enclosures"));
h.step(&json!({"start": "document.metadata.set"})).unwrap();
h.app.design_properties.as_mut().unwrap().fields[1] = "bad\u{7}".into();
h.frames(2);
h.step(&json!({"widget": "OK"})).unwrap();
let d = h.app.design_properties.as_ref().expect("still open");
assert!(d.error.as_deref().is_some_and(|e| e.contains("control character")), "{:?}", d.error);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn the_file_menu_browser_and_root_component_open_the_window() {
let mut h = Harness::new();
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 });
let item = items.iter().find(|i| i.id == "ui.designProperties").expect("root component menu item");
assert_eq!(item.label, "Design Properties");
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
assert!(h.app.design_properties.is_some());
h.app.design_properties = None;
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), true));
h.frames(2);
h.step(&json!({"widget": "Design Properties…"})).unwrap();
assert!(h.app.design_properties.is_some());
h.app.design_properties = None;
// Browser → Document Settings → Design Properties.
h.step(&json!({"click": {"chevron": "Document Settings"}})).unwrap();
h.step(&json!({"click": {"browser": "Design Properties"}})).unwrap();
assert!(h.app.design_properties.is_some());
}
}