From dcf03e337814e840476a9e6060d58a4f2223e4fd Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:58:51 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20Rename=E2=80=A6=20in=20a=20definition's?= =?UTF-8?q?=20item=20menu;=20the=20selection=20follows=20the=20new=20name?= =?UTF-8?q?=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/browser.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/control.rs | 5 ++++- crates/ui-egui/src/lib.rs | 10 ++++++++++ crates/ui-egui/src/menu.rs | 34 +++++++++++++++++++++++++++++++++- docs/control-protocol.md | 2 +- 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 92bb68a..761e1f1 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -190,6 +190,26 @@ impl Def { Def::Partition, ]; + /// The kind `model.rename` takes for it; `None` for a partition (its cut faces are named + /// after it). + pub fn rename_kind(self) -> Option<&'static str> { + Some(match self { + Def::Set => "set", + Def::Material => "material", + Def::Section => "section", + Def::Step => "step", + Def::BoundaryCondition => "boundary_condition", + Def::Load => "load", + Def::ReferencePoint => "reference_point", + Def::Coupling => "coupling", + Def::Contact => "contact", + Def::Tie => "tie", + Def::FluidDomain => "fluid_domain", + Def::Datum => "datum", + Def::Partition => return None, + }) + } + /// The kind of setup symbols a definition of this kind draws, if any. pub fn symbols(self) -> Option { use fieldcraft_render::glyphs::Definition; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 556f74c..cea0667 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -383,6 +383,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> }; let action = match (s("action"), s("step")) { (Some("edit"), _) => crate::menu::Action::Edit, + (Some("rename"), _) => crate::menu::Action::Rename, (Some("hide"), _) => crate::menu::Action::Hide, (Some("show"), _) => crate::menu::Action::Show, (Some("isolate"), _) => crate::menu::Action::Isolate, @@ -391,7 +392,9 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> (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`")), + (a, _) => { + return err(format!("`action` {a:?}: edit, rename, 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)})) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7b5657..7d21d67 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -601,6 +601,16 @@ impl ModelApp { e => Failure { message: e.to_string(), refused: None }, }); let changed = self.session.revision() != revision; + // The definition selected in the browser keeps its selection under its new name. + if id == "model.rename" + && let Ok(v) = &r + && let Some((def, name)) = &mut self.selected + && def.rename_kind() == v["kind"].as_str() + && v["from"].as_str() == Some(name.as_str()) + && let Some(to) = v["name"].as_str() + { + *name = to.to_string(); + } // A view change (hide, show) draws the model again, as an edit does. if changed || self.session.view != view { self.model_changed(); diff --git a/crates/ui-egui/src/menu.rs b/crates/ui-egui/src/menu.rs index c3aa574..d84eedd 100644 --- a/crates/ui-egui/src/menu.rs +++ b/crates/ui-egui/src/menu.rs @@ -1,7 +1,8 @@ //! 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 (a seed's Delete clears it at once: undo brings +//! (`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. @@ -42,6 +43,8 @@ impl Target { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Action { Edit, + /// Rename a definition (`model.rename`'s form, filled in). + Rename, Hide, Show, Isolate, @@ -76,6 +79,9 @@ pub fn show(app: &ModelApp, ui: &mut egui::Ui, target: &Target) -> Option { + 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::Hide => app.run("view.hide", items(&target)), Action::Show => app.run("view.show", items(&target)), Action::Isolate => app.run("view.isolate", items(&target)), @@ -280,6 +295,23 @@ mod tests { 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); + } + /// A screen point on datum `name`'s drawn lines. fn on_datum(app: &mut ModelApp, name: &str) -> egui::Pos2 { let bounds = app.display().bounds; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0fcd8a3..ba98be3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `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 `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`; for `crashed` (the start after a crash: `{crashed: [reports]}`) `report` (opens Report a Bug for the newest) or `close`; for `report_bug` (`{report_bug: draft}`) `set` (any of `title`, `observed`, `share_session`, `include_model`; replies with the draft) or `close` — the browser opens only from a click (docs/crash-reports.md); for `consent` (the first run's question about anonymous usage) `share` or `decline`; for `settings` (Help › Settings…) `set` (any of `usage_sharing`, `usage_automation`; or `update_check`, `update_skip` (a version, `""` for none)) or `close` (docs/privacy.md, docs/updates.md); for `update` (a downloaded and verified update: `{update: state}`) `restart` (saves or asks about unsaved changes first, then closes the window to install) or `later` (installs when the app quits) | answers the window's question (`ui.inspect`'s `prompt`) | | `ui.settings` | — | opens Help › Settings… (answered with `ui.prompt`); replies with the privacy state as `ui.inspect`'s `privacy` gives it: `available` (this run may share usage), `usage_sharing` (`null` until asked), `usage_automation`, `recording`, `queued` | -| `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.item` | `kind` (`body`, `surface`, or a definition kind as `ui.select` takes it, `datum`, `partition`), `name`, `action`: `edit`, `rename`, `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 (`rename` opens the `model.rename` form filled in; `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` | |