From 08f669acadf5f4a9c86310c39ff1ec60b3a08245 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:38:07 -0400 Subject: [PATCH] Browser and viewport: item menu, per-item eyes, Delete key with confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click on any browser item (body, surface, datum, partition, set, material, section, step, boundary condition, load, reference point, coupling, contact, fluid domain) opens its menu: Edit…, Hide/Show, Isolate, Show All, Active in step (boundary conditions and loads) and Delete…. A right click on a datum or a partition's cut face in the viewport opens the same menu. Each item the viewport draws on its own has an eye; hidden items are neither drawn nor picked. Datums and partitions are selectable with properties. Delete… and the Delete key ask first (the prompt lists what uses the item: a datum's partitions are deleted with it, other definitions in use are refused); ui.prompt answers it. Every entry runs the same command as the ribbon and forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/lib.rs | 1 + crates/engine/src/view.rs | 8 +- crates/render/src/glyphs.rs | 3 +- crates/ui-egui/src/browser.rs | 209 ++++++++++++++++------ crates/ui-egui/src/context.rs | 15 +- crates/ui-egui/src/control.rs | 37 +++- crates/ui-egui/src/files.rs | 29 +++ crates/ui-egui/src/lib.rs | 29 ++- crates/ui-egui/src/menu.rs | 317 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/scene.rs | 7 +- crates/ui-egui/src/viewport.rs | 66 ++++++- docs/control-protocol.md | 3 +- 12 files changed, 646 insertions(+), 78 deletions(-) create mode 100644 crates/ui-egui/src/menu.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e2569c8..ea20e5c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod cmd; pub use cmd::analytic_surfaces; pub use cmd::replace::{MESHERS, Replacement, replacement}; +pub use fieldcraft_io::{Item as ViewItem, ItemKind}; pub mod history; pub mod progress; pub mod requirement; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 36bb3cf..a354a73 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -186,7 +186,13 @@ fn picture(s: &Session, p: &Palette) -> Picture { let Some((view, result)) = plot else { let mut scene = model_scene(&base, p); let mut bounds = base.bounds; - bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &|n| s.shows(fieldcraft_io::ItemKind::Datum, n), &p.glyphs, &mut scene)); + bounds.union(fieldcraft_render::glyphs::datums( + &s.model, + &base.bounds, + &|n| s.shows(fieldcraft_io::ItemKind::Datum, n), + &p.glyphs, + &mut scene, + )); return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs }; }; shown.step = Some(view.step.clone()); diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index 9a443f1..8a9c758 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -1148,7 +1148,8 @@ mod tests { assert!(s.tris.is_empty() && s.on_top.is_empty()); assert_eq!(s.lines.len(), 4 * 3 * 6); // A hidden definition draws nothing; hiding another leaves it. - let (hidden, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, n| d == Definition::BoundaryCondition && n == "Clamp"), &m, &view(GlyphShow::ALL, u)); + let (hidden, _) = + drawn_at(&Glyphs::from_model_hiding(&m, &|d, n| d == Definition::BoundaryCondition && n == "Clamp"), &m, &view(GlyphShow::ALL, u)); assert!(hidden.lines.is_empty()); let (other, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, _| d == Definition::Load), &m, &view(GlyphShow::ALL, u)); assert_eq!(other.lines.len(), s.lines.len()); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 697e40c..6bc9e79 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -2,8 +2,11 @@ //! partitions) and Mesh with eye toggles, then the model's //! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions) //! with counts (Loads & BCs and Interactions with eye toggles for their viewport symbols), and -//! the solved steps' results. Clicking a definition selects it (its properties show on the -//! right), a double click opens its edit form, and clicking a result step shows it. +//! the solved steps' results. Clicking a definition, datum or partition selects it (its +//! properties show on the right), a double click opens its edit form, and clicking a result step +//! shows it. Each item the viewport draws on its own (bodies, surfaces, datums, boundary +//! conditions, loads, reference points, couplings) has its own eye, and a right click on an item +//! opens its menu ([`crate::menu`]: edit, hide, isolate, active in step, delete). //! //! The tree also shows where the model is in the analysis workflow //! ([`fieldcraft_engine::progress`]): each top-level node that is a setup stage carries a mark at @@ -24,6 +27,7 @@ use serde_json::{Value, json}; use crate::ModelApp; use crate::icons::{self, Look}; +use crate::menu::{self, Action, Target}; use crate::theme::Tokens; /// The browser's width. @@ -161,9 +165,27 @@ pub enum Def { Coupling, Contact, FluidDomain, + /// A datum plane, axis or point. + Datum, + Partition, } impl Def { + pub const ALL: [Def; 12] = [ + Def::Set, + Def::Material, + Def::Section, + Def::Step, + Def::BoundaryCondition, + Def::Load, + Def::ReferencePoint, + Def::Coupling, + Def::Contact, + Def::FluidDomain, + Def::Datum, + Def::Partition, + ]; + /// The kind of setup symbols a definition of this kind draws, if any. pub fn symbols(self) -> Option { use fieldcraft_render::glyphs::Definition; @@ -174,7 +196,7 @@ impl Def { Def::Coupling => Definition::Coupling, Def::Contact => Definition::Contact, Def::Step => Definition::Step, - Def::Set | Def::Material | Def::Section | Def::FluidDomain => return None, + Def::Set | Def::Material | Def::Section | Def::FluidDomain | Def::Datum | Def::Partition => return None, }) } @@ -191,6 +213,8 @@ impl Def { Def::Coupling => Collection::Coupling, Def::Contact => Collection::Contact, Def::FluidDomain => Collection::FluidDomain, + Def::Datum => Collection::Datum, + Def::Partition => Collection::Partition, } } @@ -207,6 +231,8 @@ impl Def { Def::Coupling => "coupling", Def::Contact => "contact", Def::FluidDomain => "fluid_domain", + Def::Datum => "datum", + Def::Partition => "partition", } } @@ -222,6 +248,8 @@ impl Def { Def::Coupling => "Coupling", Def::Contact => "Contact", Def::FluidDomain => "Fluid Domain", + Def::Datum => "Datum", + Def::Partition => "Partition", } } @@ -238,19 +266,75 @@ impl Def { Def::Coupling => "model.coupling.create", Def::Contact => "interaction.contact.pair", Def::FluidDomain => "model.fluid_domain.create", + Def::Datum => "geometry.datum", + Def::Partition => "geometry.partition", } } /// The command of this kind with `verb` (`create`, `edit`, `delete`); contacts are - /// interactions, created as pairs or general contact. + /// interactions, created as pairs or general contact; datums and partitions are geometry + /// (a datum's edit command depends on its kind: [`Def::edit`]). pub fn command(self, verb: &str) -> String { match (self, verb) { (Def::Contact, "create") => "interaction.contact.pair".into(), (Def::Contact, _) => format!("interaction.contact.{verb}"), + (Def::Datum | Def::Partition, "create") => self.icon().into(), + (Def::Datum | Def::Partition, _) => format!("geometry.{}.{verb}", self.stem()), _ => format!("model.{}.{verb}", self.stem()), } } + /// The command that edits definition `name` (without building its parameters); `None` when + /// it is gone or cannot be edited. + pub fn edit_command(self, m: &Model, name: &str) -> Option { + match self { + Def::Partition => None, + Def::Datum => m.datum(name).map(|d| fieldcraft_engine::cmd::datum_edit_params(name, d).0.to_string()), + _ => self.exists(m, name).then(|| self.command("edit")), + } + } + + /// The command that edits definition `name` and its parameters as it is (its edit form, + /// filled in); `None` when it is gone or cannot be edited (a partition). + pub fn edit(self, m: &Model, name: &str) -> Option<(String, Value)> { + match self { + Def::Partition => None, + Def::Datum => m.datum(name).map(|d| { + let (command, params) = fieldcraft_engine::cmd::datum_edit_params(name, d); + (command.to_string(), params) + }), + _ => definition(m, self, name).map(|v| (self.command("edit"), v)), + } + } + + /// The viewport item it is, for the kinds the viewport draws one by one (and can hide). + pub fn item_kind(self) -> Option { + use fieldcraft_engine::ItemKind as K; + match self { + Def::BoundaryCondition => Some(K::BoundaryCondition), + Def::Load => Some(K::Load), + Def::ReferencePoint => Some(K::ReferencePoint), + Def::Coupling => Some(K::Coupling), + Def::Datum => Some(K::Datum), + _ => None, + } + } + + /// The definitions that refer to `name`, as `kind \`name\`` strings: what refuses its + /// deletion (a datum's partitions are deleted with it instead, when confirmed). + pub fn users(self, m: &Model, name: &str) -> Vec { + match self { + Def::Set => m.set_users(name), + Def::Material => m.material_users(name), + Def::BoundaryCondition => m.boundary_condition_users(name), + Def::Load => m.load_users(name), + Def::ReferencePoint => m.reference_point_users(name), + Def::FluidDomain => m.fluid_domain_users(name), + Def::Datum => m.datum_users(name), + Def::Section | Def::Step | Def::Coupling | Def::Contact | Def::Partition => Vec::new(), + } + } + /// How many definitions of this kind the model has. pub fn count(self, m: &Model) -> usize { match self { @@ -264,6 +348,8 @@ impl Def { Def::Coupling => m.couplings().len(), Def::Contact => m.contacts().len(), Def::FluidDomain => m.fluid_domains().len(), + Def::Datum => m.datums().len(), + Def::Partition => m.partitions().len(), } } @@ -280,6 +366,8 @@ impl Def { Def::Coupling => m.couplings().contains_key(name), Def::Contact => m.contacts().contains_key(name), Def::FluidDomain => m.fluid_domain(name).is_some(), + Def::Datum => m.datum(name).is_some(), + Def::Partition => m.partitions().iter().any(|p| p.name == name), } } @@ -296,6 +384,8 @@ impl Def { Def::Coupling => m.couplings().keys().cloned().collect(), Def::Contact => m.contacts().keys().cloned().collect(), Def::FluidDomain => m.fluid_domains().keys().cloned().collect(), + Def::Datum => m.datums().keys().cloned().collect(), + Def::Partition => m.partitions().iter().map(|p| p.name.clone()).collect(), } } } @@ -313,6 +403,8 @@ pub fn definition(m: &Model, kind: Def, name: &str) -> Option { Def::Coupling => serde_json::to_value(m.couplings().get(name)?), Def::Contact => serde_json::to_value(m.contacts().get(name)?), Def::FluidDomain => serde_json::to_value(m.fluid_domain(name)?), + Def::Datum => serde_json::to_value(m.datum(name)?), + Def::Partition => serde_json::to_value(m.partitions().iter().find(|p| p.name == name)?), } .ok()?; let mut v = if v.is_object() { v } else { json!({"value": v}) }; @@ -351,6 +443,8 @@ struct RowResp { double: bool, fold: bool, eye: bool, + /// The row's response, for its context menu (`None` when the row was not drawn). + response: Option, } fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { @@ -446,9 +540,39 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, row.label)); out.clicked = resp.clicked() && click_on == 0; out.double = resp.double_clicked() && click_on == 0; + out.response = Some(resp); out } +/// A row for one item: its eye when the viewport draws it on its own, selection by a click and +/// its edit form by a double click (definitions, datums, partitions), and its menu by a right +/// click. +fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, target: Target, acts: &mut Vec) { + if let Some(item) = target.item() { + row.eye = Some(app.session.shows(item.kind, &item.name)); + } + if let Target::Def(kind, name) = &target { + row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name); + } + let r = draw_row(app, ui, id, &row); + if let Some(resp) = &r.response { + resp.context_menu(|ui| { + if let Some(a) = menu::show(app, ui, &target) { + acts.push(Act::Menu(target.clone(), a)); + } + }); + } + if r.eye { + acts.push(Act::Menu(target, if row.eye == Some(true) { Action::Hide } else { Action::Show })); + } else if let Target::Def(kind, name) = target { + if r.double { + acts.push(Act::Edit(kind, name)); + } else if r.clicked { + acts.push(Act::Select(kind, name)); + } + } +} + /// ✓ done (success), → next (accent), ⚠ blocked (muted); nothing for the rest. fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) { let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height()); @@ -513,6 +637,8 @@ enum Act { ShowResult(String), /// A ghost row's command. Start(&'static str), + /// An item menu's entry, or an item's eye. + Menu(Target, Action), } /// A seed in a few words: `8 exact`, `2.5 min, bias 4 end`. @@ -557,11 +683,12 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) { app.selected = if app.selected.as_ref() == Some(&(kind, name.clone())) { None } else { Some((kind, name)) }; } Act::Edit(kind, name) => { - if let Some(v) = definition(&app.session.model, kind, &name) { + if let Some((command, params)) = kind.edit(&app.session.model, &name) { app.selected = Some((kind, name)); - app.open_form(&kind.command("edit"), v); + app.open_form(&command, params); } } + Act::Menu(target, action) => menu::apply(app, target, action), Act::ShowResult(step) => { // Errors show in the status bar. let _ = app.run("results.deformed", json!({"step": step})); @@ -641,40 +768,29 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { &Row { depth: 2, icon: Some("geometry.open"), label: &label, note: Some(format!("{bodies} bodies")), ..Default::default() }, ); for (i, b) in d.bodies.iter().enumerate() { - draw_row( - app, - ui, - egui::Id::new(("fc_row_body", i)), - &Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() }, - ); + let row = Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() }; + item_row(app, ui, egui::Id::new(("fc_row_body", i)), row, Target::Body(b.name.clone()), acts); } } for (i, s) in surfaces.iter().enumerate() { - draw_row( - app, - ui, - egui::Id::new(("fc_row_surface", i)), - &Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() }, - ); + let row = Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() }; + item_row(app, ui, egui::Id::new(("fc_row_surface", i)), row, Target::Surface(s.clone()), acts); } - // Datum planes and axes, then the partitions in the order they cut. + // Datum planes, axes and points, then the partitions in the order they cut. let datums: Vec<(String, &'static str)> = app.session.model.datums().iter().map(|(n, d)| (n.clone(), d.kind())).collect(); for (i, (n, kind)) in datums.iter().enumerate() { - draw_row( - app, - ui, - egui::Id::new(("fc_row_datum", i)), - &Row { depth: 2, icon: Some("geometry.datum"), label: n, note: Some((*kind).into()), ..Default::default() }, - ); + let icon = match *kind { + "axis" => "geometry.datum.axis", + "point" => "geometry.datum.point", + _ => "geometry.datum", + }; + let row = Row { depth: 2, icon: Some(icon), label: n, note: Some((*kind).into()), ..Default::default() }; + item_row(app, ui, egui::Id::new(("fc_row_datum", i)), row, Target::Def(Def::Datum, n.clone()), acts); } let partitions: Vec<(String, String)> = app.session.model.partitions().iter().map(|p| (p.name.clone(), p.datum.clone())).collect(); for (i, (n, datum)) in partitions.iter().enumerate() { - draw_row( - app, - ui, - egui::Id::new(("fc_row_partition", i)), - &Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() }, - ); + let row = Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() }; + item_row(app, ui, egui::Id::new(("fc_row_partition", i)), row, Target::Def(Def::Partition, n.clone()), acts); } } ghost_rows(app, ui, &stages, "geometry", acts); @@ -808,18 +924,9 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { } if open(app, key) { for (kind, name) in items { - let selected = app.selected.as_ref() == Some(&(kind, name.clone())); - let r = draw_row( - app, - ui, - egui::Id::new(("fc_row_def", kind.stem(), &name)), - &Row { depth: 2, icon: Some(kind.icon()), label: &name, selected, ..Default::default() }, - ); - if r.double { - acts.push(Act::Edit(kind, name)); - } else if r.clicked { - acts.push(Act::Select(kind, name)); - } + let id = egui::Id::new(("fc_row_def", kind.stem(), &name)); + let row = Row { depth: 2, icon: Some(kind.icon()), label: &name, ..Default::default() }; + item_row(app, ui, id, row, Target::Def(kind, name.clone()), acts); } } ghost_rows(app, ui, &stages, key, acts); @@ -947,19 +1054,9 @@ mod tests { assert_eq!(steel["name"], "Steel"); assert!(steel.get("elastic").is_some(), "{steel}"); assert!(definition(m, Def::Load, "nope").is_none()); - for kind in [ - Def::Set, - Def::Material, - Def::Section, - Def::Step, - Def::BoundaryCondition, - Def::Load, - Def::ReferencePoint, - Def::Coupling, - Def::Contact, - Def::FluidDomain, - ] { - for verb in ["create", "edit", "delete"] { + for kind in Def::ALL { + let verbs: &[&str] = if kind == Def::Partition { &["create", "delete"] } else { &["create", "edit", "delete"] }; + for verb in verbs { assert!(fieldcraft_engine::find_command(&kind.command(verb)).is_some(), "{kind:?} {verb}"); } assert!(fieldcraft_engine::catalogue::find(kind.icon()).is_some()); diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index c19a570..884b9bd 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -253,16 +253,19 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name }); ui.add_space(6.0); let (mut edit, mut delete) = (false, false); + let editing = kind.edit_command(&app.session.model, name); ui.horizontal(|ui| { - edit = ui.button("Edit…").on_hover_text(kind.command("edit")).clicked(); - delete = ui.button("Delete").on_hover_text(kind.command("delete")).clicked(); + if let Some(command) = &editing { + edit = ui.button("Edit…").on_hover_text(command).clicked(); + } + delete = ui.button("Delete…").on_hover_text(format!("{} (Delete key)", kind.command("delete"))).clicked(); }); if deselect { app.selected = None; - } else if edit && let Some(v) = browser::definition(&app.session.model, kind, name) { - app.open_form(&kind.command("edit"), v); - } else if delete && app.run(&kind.command("delete"), json!({"name": name})).is_ok() { - app.selected = None; + } else if edit && let Some((command, params)) = kind.edit(&app.session.model, name) { + app.open_form(&command, params); + } else if delete { + app.ask_delete(kind, name); } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bf2ab00..e2c697d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -201,6 +201,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> crate::files::Prompt::Samples => json!("samples"), crate::files::Prompt::Design(p) => json!({"design": p}), crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}), + crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}), }), "recent": app.recent.paths, "version": crate::VERSION, @@ -303,6 +304,35 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> } ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error})))) } + "ui.item" => { + // An item menu's entry, as a right click on the item's browser row and a click on the + // entry do (crate::menu). + let kind = s("kind"); + let name = s("name").unwrap_or_default().to_string(); + let target = match kind { + Some("body") => crate::menu::Target::Body(name), + Some("surface") => crate::menu::Target::Surface(name), + Some(k) => match serde_json::from_value::(json!(k)) { + Ok(def) => crate::menu::Target::Def(def, name), + Err(_) => return err(format!("unknown item kind `{}`", k.chars().take(40).collect::())), + }, + None => return err("missing `kind`"), + }; + let action = match (s("action"), s("step")) { + (Some("edit"), _) => crate::menu::Action::Edit, + (Some("hide"), _) => crate::menu::Action::Hide, + (Some("show"), _) => crate::menu::Action::Show, + (Some("isolate"), _) => crate::menu::Action::Isolate, + (Some("show_all"), _) => crate::menu::Action::ShowAll, + (Some("delete"), _) => crate::menu::Action::Delete, + (Some(a @ ("activate" | "deactivate")), Some(step)) => { + crate::menu::Action::Active { step: step.to_string(), active: a == "activate" } + } + (a, _) => return err(format!("`action` {a:?}: edit, hide, show, isolate, show_all, delete, or activate/deactivate with `step`")), + }; + crate::menu::apply(app, target, action); + ok(json!({"status": app.status, "prompt": app.prompt.is_some(), "dialog": app.dialog.as_ref().map(|d| d.command)})) + } "ui.prompt" => { let Some(prompt) = app.prompt.clone() else { return err("no prompt is open") }; match (prompt, s("answer")) { @@ -328,6 +358,10 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> app.answer_replace_mesh(a == "replace"); ok(json!({"busy": app.busy()})) } + (crate::files::Prompt::Delete(_), Some(a @ ("delete" | "cancel"))) => { + app.answer_delete(a == "delete"); + ok(json!({"status": app.status})) + } (crate::files::Prompt::Samples, Some("open")) => match s("name") { Some(name) => { app.prompt = None; @@ -348,7 +382,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> None => err("missing `id` (from file.recovery.list)"), }, (_, a) => err(format!( - "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; recover: recover with id, close; samples: open with name, close)" + "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close)" )), } } @@ -853,6 +887,7 @@ mod tests { "ui.capture", "ui.toolbar", "ui.prompt", + "ui.item", "ui.select", "ui.selection", "ui.fullscreen", diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 61e7d4e..b6229f9 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -272,6 +272,9 @@ pub enum Prompt { /// A design (its path) has none of the bodies the model's mesh and sets are on: start a new /// model with it, or keep the model. The opened session waits in [`ModelApp::held`]. Design(String), + /// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and + /// the deletion refused or (a datum's partitions) done with it. + Delete(crate::menu::DeleteAsk), } /// The answer to [`Prompt::Design`]. @@ -871,6 +874,32 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { app.answer_design(a); } } + Some(Prompt::Delete(ask)) => { + let mut answer = None; + crate::frame::window(ctx, "Delete", crate::frame::Width::Normal).id(egui::Id::new("fc_delete")).show(ctx, |ui| { + ui.label(ask.question()); + if ask.allowed() && !ask.users.is_empty() { + ui.label(egui::RichText::new("They are deleted too; Undo brings everything back.").color(t.text_dim)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ask.allowed() && ui.button("Delete").clicked() { + answer = Some(true); + } + if ui.button(if ask.allowed() { "Cancel" } else { "Close" }).clicked() { + answer = Some(false); + } + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(false); + } else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(ask.allowed()); + } + if let Some(delete) = answer { + app.answer_delete(delete); + } + } Some(Prompt::ReplaceMesh(r)) => { let mut answer = None; crate::frame::window(ctx, "Replace mesh", crate::frame::Width::Normal).id(egui::Id::new("fc_replace_mesh")).show(ctx, |ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71732ec..1fcadc1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ pub mod frame; pub mod framing; pub mod gpu; pub mod icons; +pub mod menu; pub mod navigation; pub mod palette; pub mod reply; @@ -276,6 +277,8 @@ pub struct Viewport { pub drag: Option, /// The point the current orbit turns about (drawn as a marker while orbiting). pub orbit_pivot: Option, + /// The item the open context menu was opened on (a datum or a partition), if any. + pub menu_target: Option, } /// A probed node: its values as lines of text, for the step, mode and frame they were read from. @@ -518,7 +521,7 @@ impl ModelApp { /// label (the engine's message names its key, which agents need). fn run_form(&mut self, id: &str, params: Value, form: bool) -> Result { self.ensure_idle().map_err(|message| Failure { message, refused: None })?; - let revision = self.session.revision(); + let (revision, view) = (self.session.revision(), self.session.view.clone()); let label = command_label(id); let r = self.session.execute(id, ¶ms).map_err(|e| match e { EngineError::Param(p) => { @@ -529,7 +532,8 @@ impl ModelApp { e => Failure { message: e.to_string(), refused: None }, }); let changed = self.session.revision() != revision; - if changed { + // A view change (hide, show) draws the model again, as an edit does. + if changed || self.session.view != view { self.model_changed(); } // The results display commands reply with what is shown, which the viewport shows. @@ -835,13 +839,14 @@ impl ModelApp { } } self.pick.names.clear(); - for (names, mesh) in &self.bodies.1 { + // Hidden bodies and surfaces are neither drawn nor picked. + for (names, mesh) in self.bodies.1.iter().filter(|(n, _)| self.session.shows(fieldcraft_engine::ItemKind::Body, &n.name)) { d.add_curves(&mesh.edges); d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face); self.pick.names.push(names.clone()); } // An imported surface's faces are named by their tags. - for s in &self.session.surfaces { + for s in self.session.surfaces.iter().filter(|s| self.session.shows(fieldcraft_engine::ItemKind::Surface, &s.name)) { d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face); self.pick.names.push(selection::BodyNames { name: s.name.clone(), @@ -851,7 +856,7 @@ impl ModelApp { } let was_empty = self.display.1.bounds.is_empty(); self.display = (Some(self.revision), d); - self.glyphs = Glyphs::from_model(&self.session.model); + self.glyphs = self.session.glyphs(); if was_empty && !self.display.1.bounds.is_empty() { self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Fit); } @@ -915,7 +920,7 @@ impl ModelApp { }; let mut datums = Aabb3::EMPTY; if show.geometry && self.plot.1.is_none() { - datums = scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t); + datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, &t); scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t); } self.scene = (Some(key), s, datums); @@ -1337,8 +1342,8 @@ impl ModelApp { } /// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save - /// (Ctrl/Cmd+S) and save as (Ctrl/Cmd+Shift+S), unless a text field has the keyboard (it - /// undoes its own typing). + /// (Ctrl/Cmd+S), save as (Ctrl/Cmd+Shift+S) and delete the browser's selection (Delete), + /// unless a text field has the keyboard (it undoes its own typing). fn shortcuts(&mut self, ctx: &egui::Context) { if ctx.memory(|m| m.focused().is_some()) { return; @@ -1374,6 +1379,14 @@ impl ModelApp { if open { self.open_dialog(None); } + // Delete (or Backspace) deletes the definition selected in the browser, after asking. + if self.prompt.is_none() + && let Some((kind, name)) = self.selected.clone() + && ctx + .input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) + { + self.ask_delete(kind, &name); + } // The command search: S (as in SolidCraft) or Cmd/Ctrl+K. let search = ctx.input_mut(|i| { i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::K)) || i.consume_key(egui::Modifiers::NONE, egui::Key::S) diff --git a/crates/ui-egui/src/menu.rs b/crates/ui-egui/src/menu.rs new file mode 100644 index 0000000..4442c2f --- /dev/null +++ b/crates/ui-egui/src/menu.rs @@ -0,0 +1,317 @@ +//! 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`), and Delete… asks first ([`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), +} + +impl Target { + /// The viewport item it is, when the viewport draws it on its own. + pub fn item(&self) -> Option { + 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), + }; + Some(ViewItem { kind, name: name.clone() }) + } +} + +/// A menu entry chosen. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Edit, + 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 { + 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 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 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::>()}); + // 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.selected = Some((def, name)); + app.open_form(&command, params); + } + 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 => { + if let Target::Def(def, name) = target { + app.ask_delete(def, &name); + } + Ok(Value::Null) + } + }; +} + +/// 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, +} + +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(); + 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) + } + + /// 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, &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); + } + + /// 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); + } +} diff --git a/crates/ui-egui/src/scene.rs b/crates/ui-egui/src/scene.rs index b612e7f..8331819 100644 --- a/crates/ui-egui/src/scene.rs +++ b/crates/ui-egui/src/scene.rs @@ -82,9 +82,10 @@ pub fn symbols( glyphs.draw(pos, &view, s) } -/// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them. -pub fn datums(s: &mut Scene, model: &fieldcraft_model::Model, bounds: &Aabb3, t: &Tokens) -> Aabb3 { - fieldcraft_render::glyphs::datums(model, bounds, &|_| true, &t.glyph_colors(), s) +/// Draw the datums the session's view shows around `bounds` into `s`; returns the box around them. +pub fn datums(s: &mut Scene, session: &fieldcraft_engine::Session, bounds: &Aabb3, t: &Tokens) -> Aabb3 { + let shown = |name: &str| session.shows(fieldcraft_engine::ItemKind::Datum, name); + fieldcraft_render::glyphs::datums(&session.model, bounds, &shown, &t.glyph_colors(), s) } /// The scene for `display` drawn in `style`. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 4290e99..ff885d1 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -13,13 +13,16 @@ //! click selects it (Shift adds, Cmd/Ctrl adds or removes, a click on nothing clears), Escape //! clears. The selection bar (top centre) chooses what is picked and turns on box selection, //! where a left drag selects the visible nodes, element faces or elements inside the box. A -//! right click offers a set made from the selection. +//! right click offers a set made from the selection, and on a datum or a partition's cut face +//! that item's menu ([`crate::menu`]), as its browser row does. use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use fieldcraft_render::{StandardView, Vec3}; +use crate::browser::Def; use crate::gpu::{GpuScene, ViewportCallback}; use crate::icons::{self, Look}; +use crate::menu::{self, Target}; use crate::navigation::{self, NavButton}; use crate::selection::{Filter, Mode}; use crate::theme::Tokens; @@ -179,8 +182,69 @@ fn hover_label(app: &ModelApp, painter: &egui::Painter, t: &Tokens) { } /// The right-click menu: a set from the selection, clearing it, and the filter. +/// Most screen distance (points) from a datum's drawn lines at which a right click is on it. +const DATUM_REACH: f32 = 6.0; + +/// The datum drawn under window point `at`: the nearest shown datum whose lines pass within +/// [`DATUM_REACH`]. +fn datum_at(app: &mut ModelApp, at: Pos2) -> Option { + if app.plot().is_some() || !app.ui.show_geometry { + return None; + } + let bounds = app.display().bounds; + let colors = Tokens::get(app.ui.dark).glyph_colors(); + let names: Vec = + app.session.model.datums().keys().filter(|n| app.session.shows(fieldcraft_engine::ItemKind::Datum, n)).cloned().collect(); + let mut best: Option<(f32, String)> = None; + for name in names { + let mut drawn = fieldcraft_render::Scene::default(); + fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, &colors, &mut drawn); + for l in &drawn.lines { + let (Some(a), Some(b)) = (to_screen(app, l.a), to_screen(app, l.b)) else { continue }; + let xy = |p: Pos2| [f64::from(p.x), f64::from(p.y)]; + let d = fieldcraft_render::pick::segment_distance(xy(at), xy(a), xy(b)).0 as f32; + if d <= DATUM_REACH && best.as_ref().is_none_or(|(e, _)| d < *e) { + best = Some((d, name.clone())); + } + } + } + best.map(|(_, n)| n) +} + +/// The partition whose cut face is under window point `at` (`partition::a|b`). +fn partition_at(app: &mut ModelApp, at: Pos2) -> Option { + let rect = app.viewport.rect.filter(|r| r.contains(at))?; + let proj = app.projector()?; + let local = [f64::from(at.x - rect.left()), f64::from(at.y - rect.top())]; + let item = app.with_picker(|p| p.pick(&proj, local, Filter::Faces))?; + let crate::selection::Item::Geometry(g) = item else { return None }; + let name = g.entity.name()?.strip_prefix("partition:")?.split(':').next()?.to_string(); + app.session.model.partitions().iter().any(|p| p.name == name).then_some(name) +} + +/// What a right click at `at` offers its item menu for: a datum drawn there, else a partition +/// whose cut face is there. +pub(crate) fn menu_target(app: &mut ModelApp, at: Pos2) -> Option { + if let Some(name) = datum_at(app, at) { + return Some(Target::Def(Def::Datum, name)); + } + partition_at(app, at).map(|name| Target::Def(Def::Partition, name)) +} + fn context_menu(app: &mut ModelApp, response: &egui::Response) { + if response.secondary_clicked() { + app.viewport.menu_target = response.interact_pointer_pos().and_then(|at| menu_target(app, at)); + } response.context_menu(|ui| { + if let Some(target) = app.viewport.menu_target.clone() { + let Target::Def(def, name) = &target else { return }; + ui.label(egui::RichText::new(format!("{} {name}", def.label())).strong()); + if let Some(action) = menu::show(app, ui, &target) { + menu::apply(app, target, action); + return; + } + ui.separator(); + } let has = !app.pick.selection.is_empty(); if ui .add_enabled(has, egui::Button::new("Create set from selection…")) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a9cade9..2618aa4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,8 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply | | `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) | -| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.item` | `kind` (`body`, `surface`, or a definition kind as `ui.select` takes it, `datum`, `partition`), `name`, `action`: `edit`, `hide`, `show`, `isolate`, `show_all`, `delete`, or `activate`/`deactivate` with `step` | an item menu's entry, as a right click on the item and a click on the entry: runs the same command (`delete` opens the delete prompt, answered with `ui.prompt`) | | `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `submit?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | | `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | | `ui.move` | `x`, `y` | |