Files
OneCAD-Simulation/crates/ui-egui/src/menu.rs
T

485 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The item menu: what a right click on a browser row offers, and on a datum or a partition's cut
//! face in the viewport. Edit… opens the item's edit form, Hide/Show and Isolate change the view
//! (`view.*`), Active in step switches a boundary condition or load on or off per step
//! (`model.step.activate`), Rename… opens `model.rename` (every reference follows the new
//! name), and Delete… asks first (a seed's Delete clears it at once: undo brings
//! it back) ([`crate::files::Prompt::Delete`]): it lists
//! the definitions that use the item, and refuses, or for a datum deletes its partitions with it.
//! Every entry runs the same command as the ribbon, the forms and scripts.
use fieldcraft_engine::{ItemKind, ViewItem};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::browser::Def;
/// What the menu is for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
/// A body of the open design.
Body(String),
/// An imported surface.
Surface(String),
/// A model definition, datum or partition.
Def(Def, String),
/// The seed on a design edge.
Seed(fieldcraft_model::GeometryRef),
}
impl Target {
/// The viewport item it is, when the viewport draws it on its own.
pub fn item(&self) -> Option<ViewItem> {
let (kind, name) = match self {
Target::Body(n) => (ItemKind::Body, n),
Target::Surface(n) => (ItemKind::Surface, n),
Target::Def(def, n) => (def.item_kind()?, n),
Target::Seed(_) => return None,
};
Some(ViewItem { kind, name: name.clone() })
}
}
/// A menu entry chosen.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Edit,
/// Rename a definition (`model.rename`'s form, filled in).
Rename,
/// Suppress a datum or partition, or bring it back.
Suppress(bool),
Hide,
Show,
Isolate,
ShowAll,
/// Switch a boundary condition or load on or off in a step.
Active {
step: String,
active: bool,
},
Delete,
}
/// Whether `target` is active in each step (boundary conditions and loads; empty for the rest).
fn activity(app: &ModelApp, target: &Target) -> Vec<(String, bool)> {
let steps = app.session.model.steps();
match target {
Target::Def(Def::BoundaryCondition, n) => steps.iter().map(|s| (s.name.clone(), s.boundary_conditions.contains(n))).collect(),
Target::Def(Def::Load, n) => steps.iter().map(|s| (s.name.clone(), s.loads.contains(n))).collect(),
_ => Vec::new(),
}
}
/// Draw the menu's entries for `target`; the entry clicked.
pub fn show(app: &ModelApp, ui: &mut egui::Ui, target: &Target) -> Option<Action> {
let mut chosen = None;
let mut entry = |ui: &mut egui::Ui, enabled: bool, label: &str, tip: &str, action: Action| {
if ui.add_enabled(enabled, egui::Button::new(label)).on_hover_text(tip).clicked() {
chosen = Some(action);
}
};
if let Target::Def(def, name) = target {
let edit = def.edit_command(&app.session.model, name);
let tip = edit.clone().unwrap_or_else(|| "Not editable: delete it and make it again".to_string());
entry(ui, edit.is_some(), "Edit…", &tip, Action::Edit);
if def.rename_kind().is_some() {
entry(ui, true, "Rename…", "model.rename: every definition that refers to it follows", Action::Rename);
}
if let Some((command, suppressed)) = suppression(app, *def, name) {
let (label, tip) = if suppressed {
("Unsuppress", format!("{command} {{\"suppressed\": false}}: back in the model"))
} else {
("Suppress", format!("{command}: keep it, but leave it out of the model"))
};
entry(ui, true, label, &tip, Action::Suppress(!suppressed));
}
}
if let Target::Seed(_) = target {
entry(ui, true, "Edit…", "mesh.seed.edge", Action::Edit);
}
if let Some(item) = target.item() {
if app.session.shows(item.kind, &item.name) {
entry(ui, true, "Hide", "view.hide", Action::Hide);
} else {
entry(ui, true, "Show", "view.show", Action::Show);
}
entry(ui, true, "Isolate", "view.isolate: show only this", Action::Isolate);
}
if !app.session.view.is_empty() {
entry(ui, true, "Show All", "view.show_all", Action::ShowAll);
}
let steps = activity(app, target);
if !steps.is_empty() {
ui.menu_button("Active in step", |ui| {
for (step, on) in steps {
let mut checked = on;
if ui.checkbox(&mut checked, &step).on_hover_text("model.step.activate").changed() {
chosen = Some(Action::Active { step, active: checked });
ui.close();
}
}
});
}
if let Target::Def(def, _) = target {
ui.separator();
let command = def.command("delete");
if ui.button("Delete…").on_hover_text(format!("{command} (Delete key)")).clicked() {
chosen = Some(Action::Delete);
}
}
if let Target::Seed(_) = target {
ui.separator();
if ui.button("Delete").on_hover_text("mesh.seed.clear (undo brings it back)").clicked() {
chosen = Some(Action::Delete);
}
}
if chosen.is_some() {
ui.close();
}
chosen
}
/// Do what the menu entry chosen asks.
pub fn apply(app: &mut ModelApp, target: Target, action: Action) {
let items = |t: &Target| json!({"items": t.item().into_iter().collect::<Vec<_>>()});
// Errors show in the status bar.
let _ = match action {
Action::Edit => {
if let Target::Def(def, name) = &target
&& let Some((command, params)) = def.edit(&app.session.model, name)
{
app.select_definition(Some((*def, name.clone())));
app.open_form(&command, params);
}
if let Target::Seed(edge) = target
&& let Some(params) = app.seed(&edge).and_then(seed_params)
{
app.select_seed(edge);
app.open_form("mesh.seed.edge", params);
}
Ok(Value::Null)
}
Action::Rename => {
if let Target::Def(def, name) = &target
&& let Some(kind) = def.rename_kind()
{
app.select_definition(Some((*def, name.clone())));
app.open_form("model.rename", json!({"kind": kind, "from": name, "to": name}));
}
Ok(Value::Null)
}
Action::Suppress(on) => match &target {
Target::Def(def, name) => match suppression(app, *def, name) {
Some((command, _)) => app.run(command, json!({"name": name, "suppressed": on})),
None => Ok(Value::Null),
},
_ => Ok(Value::Null),
},
Action::Hide => app.run("view.hide", items(&target)),
Action::Show => app.run("view.show", items(&target)),
Action::Isolate => app.run("view.isolate", items(&target)),
Action::ShowAll => app.run("view.show_all", json!({})),
Action::Active { step, active } => match target {
Target::Def(def @ (Def::BoundaryCondition | Def::Load), name) => {
let key = if def == Def::Load { "loads" } else { "boundary_conditions" };
app.run("model.step.activate", json!({"step": step, key: [name], "active": active}))
}
_ => Ok(Value::Null),
},
Action::Delete => match target {
Target::Def(def, name) => {
app.ask_delete(def, &name);
Ok(Value::Null)
}
Target::Seed(edge) => app.run("mesh.seed.clear", json!({"edges": [edge]})),
Target::Body(_) | Target::Surface(_) => Ok(Value::Null),
},
};
}
/// The command that suppresses `name` of `kind` (a datum or a partition), and whether it is
/// suppressed now.
fn suppression(app: &ModelApp, kind: Def, name: &str) -> Option<(&'static str, bool)> {
let model = &app.session.model;
match kind {
Def::Datum => Some(("geometry.datum.suppress", model.datum_suppressed(name))),
Def::Partition => Some(("geometry.partition.suppress", model.partitions().iter().find(|p| p.name == name)?.suppressed)),
_ => None,
}
}
/// `mesh.seed.edge`'s parameters that make `seed` as it is: its edit form, filled in.
pub fn seed_params(seed: &fieldcraft_model::Seed) -> Option<Value> {
let mut v = serde_json::to_value(seed).ok()?;
let o = v.as_object_mut()?;
let edge = o.remove("edge")?;
o.insert("edges".into(), json!([edge]));
Some(v)
}
/// The question a deletion asks ([`crate::files::Prompt::Delete`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeleteAsk {
pub kind: Def,
pub name: String,
/// The definitions that use it.
pub users: Vec<String>,
}
impl DeleteAsk {
/// Whether deleting goes ahead with its users: a datum's partitions are deleted with it; any
/// other definition in use is refused.
pub fn cascades(&self) -> bool {
self.kind == Def::Datum
}
/// Whether it can be deleted at all.
pub fn allowed(&self) -> bool {
self.users.is_empty() || self.cascades()
}
/// The question, in words.
pub fn question(&self) -> String {
let what = format!("{} `{}`", self.kind.label().to_lowercase(), self.name);
match (self.users.is_empty(), self.cascades()) {
(true, _) => format!("Delete {what}?"),
(false, true) => format!("Delete {what} and what uses it: {}?", self.users.join(", ")),
(false, false) => format!("{what} is used by {}: change or delete those first.", self.users.join(", ")),
}
}
}
impl ModelApp {
/// Ask before deleting definition `name` of `kind` (the item menu's Delete…, the Delete key).
pub fn ask_delete(&mut self, kind: Def, name: &str) {
if !kind.exists(&self.session.model, name) {
return;
}
let users = kind.users(&self.session.model, name);
self.prompt = Some(crate::files::Prompt::Delete(DeleteAsk { kind, name: name.to_string(), users }));
}
/// Answer [`crate::files::Prompt::Delete`]: delete (with its partitions, for a datum in use)
/// or leave it.
pub fn answer_delete(&mut self, delete: bool) {
let Some(crate::files::Prompt::Delete(ask)) = self.prompt.clone() else { return };
self.prompt = None;
if !delete || !ask.allowed() {
return;
}
let mut params = json!({"name": ask.name});
if ask.cascades() && !ask.users.is_empty() {
params["cascade"] = json!(true);
}
if self.run(&ask.kind.command("delete"), params).is_ok() && self.selected.as_ref() == Some(&(ask.kind, ask.name)) {
self.selected = None;
}
}
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use fieldcraft_render::StandardView;
use super::*;
use crate::viewport;
/// The plate design open in an app with a datum plane `Mid` cutting it (partition `Half`)
/// and a datum point `P`, framed in an 800 × 600 viewport.
fn plate() -> (ModelApp, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-menu-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("plate.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(Session::default());
app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap();
app.run("geometry.datum", json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
app.run("geometry.partition", json!({"name": "Half", "datum": "Mid"})).unwrap();
// The partition hides its plane (#295); shown again here to be picked.
app.run("view.show", json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
app.run("geometry.datum.point", json!({"name": "P", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0)));
app.set_view(StandardView::Iso);
app.fit_view();
(app, dir)
}
/// The Partition form highlights the bodies its plane would cut: a plane through the plate
/// cuts it, one beyond it nothing, and listed bodies are those listed (#347).
#[test]
fn the_partition_form_names_the_bodies_it_would_cut() {
let (mut app, dir) = plate();
app.run("geometry.datum", json!({"name": "Far", "by": {"type": "offset", "plane": "yz", "offset": 1000}})).unwrap();
app.run("geometry.datum", json!({"name": "Cut", "by": {"type": "offset", "plane": "xy", "offset": 1}})).unwrap();
assert!(app.partition_preview().is_empty(), "no form, nothing");
app.open_form("geometry.partition", json!({"name": "Q", "datum": "Cut"}));
let cut = app.partition_preview();
assert_eq!(cut.len(), 2, "both cells of Half: {cut:?}");
app.open_form("geometry.partition", json!({"name": "Q", "datum": "Far"}));
assert!(app.partition_preview().is_empty());
let one = cut.first().cloned().unwrap();
app.open_form("geometry.partition", json!({"name": "Q", "datum": "Cut", "bodies": [one.clone()]}));
assert_eq!(app.partition_preview(), vec![one]);
let _ = std::fs::remove_dir_all(dir);
}
/// Rename… opens `model.rename` filled in; running it renames the datum, its partition
/// follows and the browser keeps it selected under the new name (#290).
#[test]
fn rename_from_the_menu_follows_the_selection() {
let (mut app, dir) = plate();
apply(&mut app, Target::Def(Def::Datum, "Mid".into()), Action::Rename);
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.rename"));
let params: Value = serde_json::from_str(&app.dialog.as_ref().unwrap().text).unwrap();
assert_eq!((params["kind"].as_str(), params["from"].as_str()), (Some("datum"), Some("Mid")));
app.dialog = None;
app.run("model.rename", json!({"kind": "datum", "from": "Mid", "to": "Centre"})).unwrap();
assert_eq!(app.selected, Some((Def::Datum, "Centre".to_string())));
assert_eq!(app.session.model.partitions()[0].datum, "Centre");
assert!(Def::Partition.rename_kind().is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// Suppress and Unsuppress on a partition's and a datum's menu run their suppress commands:
/// the cells merge back and come again; a suppressed datum is not drawn (#291).
#[test]
fn suppress_from_the_menu_merges_the_cells_back() {
let (mut app, dir) = plate();
let bodies = |app: &ModelApp| app.session.design.as_ref().map_or(0, |d| d.bodies.len());
assert_eq!(bodies(&app), 2);
apply(&mut app, Target::Def(Def::Partition, "Half".into()), Action::Suppress(true));
assert_eq!(bodies(&app), 1, "{:?}", app.status);
assert_eq!(suppression(&app, Def::Partition, "Half"), Some(("geometry.partition.suppress", true)));
apply(&mut app, Target::Def(Def::Partition, "Half".into()), Action::Suppress(false));
assert_eq!(bodies(&app), 2);
apply(&mut app, Target::Def(Def::Datum, "Mid".into()), Action::Suppress(true));
assert_eq!(bodies(&app), 1);
let drawn = |app: &mut ModelApp| {
let b = app.display().bounds;
let mut s = fieldcraft_render::Scene::default();
fieldcraft_render::glyphs::datums(&app.session.model, &b, &|_| true, None, &crate::theme::Tokens::dark().glyph_colors(), &mut s);
s.labels.into_iter().map(|l| l.text).collect::<Vec<_>>()
};
assert_eq!(drawn(&mut app), ["P"], "the suppressed plane is not drawn");
assert_eq!(suppression(&app, Def::Set, "x"), None);
let _ = std::fs::remove_dir_all(dir);
}
/// A screen point on datum `name`'s drawn lines.
fn on_datum(app: &mut ModelApp, name: &str) -> egui::Pos2 {
let bounds = app.display().bounds;
let mut s = fieldcraft_render::Scene::default();
fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, None, &crate::theme::Tokens::dark().glyph_colors(), &mut s);
let l = s.lines[0];
viewport::to_screen(app, (l.a + l.b) * 0.5).unwrap()
}
/// The menu's entries run the commands the ribbon, forms and scripts run; hiding is view
/// state (nothing journalled) and a hidden item is not picked.
#[test]
fn menu_entries_run_the_same_commands_and_hidden_items_are_not_picked() {
let (mut app, dir) = plate();
let mid = Target::Def(Def::Datum, "Mid".into());
let at = on_datum(&mut app, "Mid");
assert_eq!(viewport::menu_target(&mut app, at), Some(mid.clone()));
let journal = app.session.journal.len();
apply(&mut app, mid.clone(), Action::Hide);
assert!(!app.session.shows(ItemKind::Datum, "Mid") && app.session.is_dirty());
assert_eq!(app.session.journal.len(), journal);
assert_ne!(viewport::menu_target(&mut app, at), Some(mid.clone()), "a hidden datum is not picked");
apply(&mut app, mid.clone(), Action::Show);
assert!(app.session.shows(ItemKind::Datum, "Mid"));
// A hidden body is neither drawn nor picked.
let cell = app.session.design.as_ref().unwrap().bodies[0].name.clone();
let centre = app.session.design.as_ref().unwrap().bodies[0].measure().unwrap().centroid;
let top = viewport::to_screen(&app, fieldcraft_render::Vec3::new(centre[0], centre[1], 10.0)).unwrap();
assert!(app.pick_at(top).is_some());
apply(&mut app, Target::Body(cell.clone()), Action::Isolate);
assert!(app.session.shows(ItemKind::Body, &cell) && !app.session.shows(ItemKind::Datum, "P"));
apply(&mut app, Target::Body(cell.clone()), Action::Hide);
app.display();
assert!(app.pick.names.iter().all(|n| n.name != cell));
assert!(app.pick_at(top).is_none_or(|i| !i.describe().contains(cell.as_str())));
apply(&mut app, Target::Body(cell), Action::ShowAll);
assert!(app.session.view.is_empty());
// Edit… opens the datum's edit form filled in; running it is the edit command.
apply(&mut app, Target::Def(Def::Datum, "P".into()), Action::Edit);
let d = app.dialog.as_mut().unwrap();
assert_eq!(d.command, "geometry.datum.point.edit");
assert!(d.text.contains("[0.0,0.0,0.0]"), "{}", d.text);
d.text = json!({"name": "P", "by": {"type": "coordinates", "point": [1, 0, 0]}}).to_string();
app.run_dialog().unwrap();
assert_eq!(app.session.journal.last().map(|(c, _)| c.as_str()), Some("geometry.datum.point.edit"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A seed is selected, described, edited and deleted from the browser like a definition
/// (#352).
#[test]
fn seeds_are_selected_edited_and_deleted() {
let (mut app, dir) = plate();
let bodies = app.run("geometry.bodies", json!({})).unwrap();
let edge = json!({"body": bodies[0]["name"], "edge": bodies[0]["edges"][0]["name"]});
app.run("mesh.seed.edge", json!({"edges": [edge], "count": 6, "bias": {"ratio": 4, "toward": "end"}})).unwrap();
let seed = app.session.model.seeds()[0].clone();
app.select_seed(seed.edge.clone());
assert!(app.selected.is_none() && crate::context::has_context(&mut app));
assert_eq!(app.pick.selection.len(), 1, "its edge is picked");
let rows = crate::describe::seed_rows(&app.session.model, &seed);
let text: Vec<String> = rows.iter().map(|(k, v)| format!("{k}: {v}")).collect();
assert!(text.iter().any(|r| r.contains('6')) && text.iter().any(|r| r.contains('4')), "{text:?}");
assert!(!text.iter().any(|r| r.contains('{')), "{text:?}");
apply(&mut app, Target::Seed(seed.edge.clone()), Action::Edit);
let d = app.dialog.as_ref().unwrap();
assert_eq!(d.command, "mesh.seed.edge");
let params: Value = serde_json::from_str(&d.text).unwrap();
assert_eq!((params["count"].clone(), params["edges"][0]["body"].clone()), (json!(6), bodies[0]["name"].clone()), "{params}");
app.close_dialog();
apply(&mut app, Target::Seed(seed.edge.clone()), Action::Delete);
assert!(app.session.model.seeds().is_empty());
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| app.logic(ui.ctx()));
out.textures_delta.clear();
assert!(app.selected_seed.is_none(), "a deleted seed is no longer selected");
let _ = std::fs::remove_dir_all(&dir);
}
/// Delete asks first: a datum a partition uses names it and deletes both on confirmation;
/// a definition in use elsewhere is refused.
#[test]
fn deleting_lists_what_uses_it_and_cascades_or_refuses() {
let (mut app, dir) = plate();
apply(&mut app, Target::Def(Def::Datum, "Mid".into()), Action::Delete);
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!("{:?}", app.prompt) };
assert_eq!(ask.users, ["partition `Half`"]);
assert!(ask.allowed() && ask.question().contains("partition `Half`"));
// Cancel changes nothing; Delete deletes the datum and its partition in one step.
app.answer_delete(false);
assert!(app.prompt.is_none() && app.session.model.datum("Mid").is_some());
app.selected = Some((Def::Datum, "Mid".into()));
app.ask_delete(Def::Datum, "Mid");
app.answer_delete(true);
assert_eq!(app.session.journal.last(), Some(&("geometry.datum.delete".to_string(), json!({"name": "Mid", "cascade": true}))));
assert!(app.session.model.partitions().is_empty() && app.selected.is_none());
// A material a section uses is refused: no Delete.
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
app.ask_delete(Def::Material, "Steel");
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!() };
assert!(!ask.allowed() && !ask.users.is_empty());
let journal = app.session.journal.len();
app.answer_delete(true);
assert!(app.session.model.materials().contains_key("Steel"));
assert_eq!(app.session.journal.len(), journal);
// Active in step switches a boundary condition per step.
let bc = app.session.model.boundary_conditions().keys().next().unwrap().clone();
let step = app.session.model.steps()[0].name.clone();
apply(&mut app, Target::Def(Def::BoundaryCondition, bc.clone()), Action::Active { step: step.clone(), active: false });
assert!(!app.session.model.step(&step).unwrap().boundary_conditions.contains(&bc));
let _ = std::fs::remove_dir_all(&dir);
}
}