From a7fc1fe532f09b6216d8aaee65abe872994d065b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 09:28:44 +0000 Subject: [PATCH] S box: command search at the cursor (fuzzy over name, id, tab and panel; recent first; pinned favourites as a mini toolbar; kept between runs); Named Views in the browser (save the camera, restore animated, rename, update, delete; Top/Front/Right/Home listed; view.save/rename/delete/list, saved in the design); Keyboard Shortcuts (File menu): rebind any command, conflicts reported, clear, reset, kept between runs; ui.shortcut control method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 17 ++ crates/engine/src/cmd/browser.rs | 79 ++++++++ crates/ui-egui/src/browser.rs | 74 ++++++++ crates/ui-egui/src/context_menu.rs | 32 +++- crates/ui-egui/src/control.rs | 11 ++ crates/ui-egui/src/keymap.rs | 267 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/menu_tests.rs | 22 +++ crates/ui-egui/src/shortcut_box.rs | 287 +++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 24 ++- docs/control-protocol.md | 1 + 11 files changed, 825 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/keymap.rs create mode 100644 crates/ui-egui/src/shortcut_box.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ac02ef58..a7c5aa79 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1052,6 +1052,22 @@ pub struct Document { /// Browser item order per component folder (`"/"` → item keys). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub browser_order: std::collections::BTreeMap>, + /// Named views: saved cameras. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub named_views: Vec, +} + +/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct NamedView { + #[serde(default)] + pub name: String, + pub target: solvecraft_geom::Vec3, + pub yaw: f64, + pub pitch: f64, + pub distance: f64, + #[serde(default)] + pub fov: f64, } /// A browser group: items of one folder (`bodies`, `sketches` or `construction`) of a @@ -1103,6 +1119,7 @@ impl Document { param_comments: Default::default(), browser_groups: Vec::new(), browser_order: Default::default(), + named_views: Vec::new(), } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index a675daf4..cdaa7bcc 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -24,6 +24,12 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"), CommandSpec::new("browser.move", "Move Browser Items", move_items) .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), + CommandSpec::new("view.save", "New Named View", view_save) + .icon("camera") + .params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"), + CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"), + CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"), + CommandSpec::new("view.list", "List Named Views", view_list).noundo(), CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), ]; @@ -146,6 +152,59 @@ fn order(s: &mut Session, p: &Value) -> Result { Ok(json!({})) } +fn view_save(s: &mut Session, p: &Value) -> Result { + let cmd = "view.save"; + let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?; + let mut v: solvecraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?; + let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0; + if !ok { + return Err(bad(cmd, "the camera must be finite with a positive distance")); + } + let n = s.doc.named_views.len(); + v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) if n.len() <= 128 => n.to_string(), + Some(_) => return Err(bad(cmd, "`name` is too long")), + None => (n + 1..).map(|k| format!("Named View{k}")).find(|x| !s.doc.named_views.iter().any(|v| v.name == *x)).unwrap_or_default(), + }; + if n >= 1000 { + return Err(bad(cmd, "too many named views")); + } + let name = v.name.clone(); + let d = s.doc_mut(); + d.named_views.retain(|x| x.name != name); + d.named_views.push(v); + Ok(json!({ "view": name })) +} + +fn view_index(s: &Session, p: &Value, cmd: &str) -> Result { + let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?; + s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`"))) +} + +fn view_rename(s: &mut Session, p: &Value) -> Result { + let cmd = "view.rename"; + let i = view_index(s, p, cmd)?; + let name = + str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if s.doc.named_views.iter().enumerate().any(|(j, v)| j != i && v.name == name) { + return Err(bad(cmd, format!("another view is called `{name}`"))); + } + if let Some(v) = s.doc_mut().named_views.get_mut(i) { + v.name = name.to_string(); + } + Ok(json!({ "view": name })) +} + +fn view_delete(s: &mut Session, p: &Value) -> Result { + let i = view_index(s, p, "view.delete")?; + let v = s.doc_mut().named_views.remove(i); + Ok(json!({ "deleted": v.name })) +} + +fn view_list(s: &mut Session, _p: &Value) -> Result { + Ok(json!({ "views": s.doc.named_views })) +} + /// Rename an item key in the browser's groups and orders of a folder. fn rename_key(d: &mut solvecraft_doc::Document, folder: &str, old: &str, new: &str) { for g in d.browser_groups.iter_mut().filter(|g| g.folder == folder) { @@ -303,6 +362,26 @@ mod tests { assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err()); } + #[test] + fn named_views_are_saved_renamed_and_deleted() { + let mut s = Session::default(); + let cam = json!({"target": [1, 2, 3], "yaw": 0.5, "pitch": 0.3, "distance": 100}); + assert_eq!(run(&mut s, "view.save", json!({"camera": cam}))["view"], "Named View1"); + run(&mut s, "view.save", json!({"camera": cam, "name": "Detail"})); + run(&mut s, "view.rename", json!({"view": "Named View1", "name": "Overview"})); + assert!(s.execute("view.rename", &json!({"view": "Overview", "name": "Detail"})).is_err()); + let back: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap(); + assert_eq!(back.named_views.len(), 2); + assert_eq!(back.named_views[0].target.z, 3.0); + run(&mut s, "view.delete", json!({"view": "Detail"})); + assert_eq!(run(&mut s, "view.list", json!({}))["views"].as_array().unwrap().len(), 1); + for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] { + assert!(s.execute("view.save", &p).is_err(), "{p}"); + } + run(&mut s, "UndoCommand", json!({})); + assert_eq!(s.doc.named_views.len(), 2); + } + #[test] fn groups_hold_items_and_follow_renames() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 7eef8f97..62158ef2 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -530,6 +530,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, let d = depth + 1; if id == 0 { doc_settings(app, ui, d); + named_view_rows(app, ui, d, acts); origin_rows(app, ui, d); canvas_rows(app, ui, d, acts); } @@ -621,6 +622,79 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { } } +/// The standard views listed under Named Views: (label, view). +pub const STANDARD_VIEWS: [(&str, &str); 4] = [("Top", "top"), ("Front", "front"), ("Right", "right"), ("Home", "home")]; + +/// Named Views: the standard views, then the design's saved cameras. A double-click restores +/// one (animated). +fn named_view_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) { + let key = "namedviews"; + let open = is_open(app, key, false); + let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Named Views", ..Default::default() }); + if r.fold || r.clicked { + toggle(app, key, false); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::NamedViews)); + } + if !open { + return; + } + for (label, view) in STANDARD_VIEWS { + let r = draw_row(ui, ui.id().with(("stdview", view)), &Row { depth: depth + 1, icon: "home", label, ..Default::default() }); + if r.double { + app.animate_view(view); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::NamedView { name: label.to_string() })); + } + } + let names: Vec = app.session.doc.named_views.iter().map(|v| v.name.clone()).collect(); + for name in names { + let r = draw_row(ui, ui.id().with(("view", &name)), &Row { depth: depth + 1, icon: "perspective", label: &name, ..Default::default() }); + if r.double { + restore_view(app, &name); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::NamedView { name })); + } + } +} + +/// Turn the camera to a named view (a saved one or a standard one), animated. +pub fn restore_view(app: &mut SolveApp, name: &str) { + if let Some((_, v)) = STANDARD_VIEWS.iter().find(|(l, _)| *l == name) { + app.animate_view(v); + return; + } + let Some(v) = app.session.doc.named_views.iter().find(|v| v.name == name) else { return }; + let j = json!({ "target": v.target, "yaw": v.yaw, "pitch": v.pitch, "distance": v.distance, "fov": v.fov }); + if let Ok(cam) = serde_json::from_value::(j) { + app.animate_to(cam); + } +} + +/// Save the current camera as a named view. +pub fn save_view(app: &mut SolveApp, name: Option<&str>) -> Option { + let cam = serde_json::to_value(app.cam).ok()?; + let r = app.run("view.save", json!({ "name": name, "camera": cam })).ok()?; + app.tree.collapsed.remove("namedviews"); + app.tree.expanded.insert("namedviews".into()); + r.get("view").and_then(Value::as_str).map(str::to_string) +} + +/// The menu of a named view. +pub fn view_items(app: &SolveApp, name: &str) -> Vec { + let saved = app.session.doc.named_views.iter().any(|v| v.name == name); + vec![ + Item::action("ui.restoreView", "Restore", "home").with(json!({ "view": name })), + Item::action("ui.updateView", "Update to Current View", "perspective").with(json!({ "view": name })).on_if(saved), + Item::sep(), + Item::action("ui.rename", "Rename", "").with(json!({ "view": name })).on_if(saved), + Item::action("view.delete", "Delete", "delete").with(json!({ "view": name })).on_if(saved), + ] +} + /// The Canvases folder (reference images) with an eye per canvas. fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) { let canvases: Vec<(u64, String, bool)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect(); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 06b25191..c0ad274a 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -40,6 +40,11 @@ pub enum Target { }, /// The Origin folder. Origin, + /// The Named Views folder, and one view (saved or standard) in it. + NamedViews, + NamedView { + name: String, + }, /// A canvas (reference image). Canvas { id: u64, @@ -85,6 +90,9 @@ impl Item { self.params = Some(params); self } + pub fn on_if(self, enabled: bool) -> Item { + self.on(enabled) + } fn on(mut self, enabled: bool) -> Item { self.enabled &= enabled; self @@ -101,7 +109,7 @@ fn cmd(app: &SolveApp, id: &str, label: &str) -> Item { let shortcut = match id { "UndoCommand" => "Ctrl+Z".to_string(), "RedoCommand" => "Ctrl+Y".to_string(), - _ => info.as_ref().and_then(|i| i.shortcut).map(str::to_string).unwrap_or_default(), + _ => crate::keymap::effective(app, id).unwrap_or_default(), }; Item { id: id.into(), @@ -145,6 +153,7 @@ pub enum RenameWhat { Component(u64), Group(u64), Canvas(u64), + View(String), } #[derive(Clone, Debug)] @@ -204,7 +213,9 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), Target::Group { id } => crate::browser::group_bodies(app, *id), - Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin => Vec::new(), + Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin | Target::NamedViews | Target::NamedView { .. } => { + Vec::new() + } } } @@ -263,6 +274,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), Target::Canvas { id } => crate::browser::canvas_items(app, *id), Target::Origin => crate::browser::origin_items(app), + Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")], + Target::NamedView { name } => crate::browser::view_items(app, name), }; // Several items of the folder selected: they can be grouped. if let Some(p) = crate::browser::selection_group(app, target) { @@ -642,6 +655,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { RenameWhat::Group(g) } else if let Some(c) = id_of(&p, "canvas") { RenameWhat::Canvas(c) + } else if let Some(v) = p.get("view").and_then(Value::as_str) { + RenameWhat::View(v.to_string()) } else { return; }; @@ -723,6 +738,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } } "ui.canvasVisible" => drop(app.run("canvas.edit", p)), + "ui.newView" => drop(crate::browser::save_view(app, None)), + "ui.restoreView" => { + if let Some(v) = p.get("view").and_then(Value::as_str) { + crate::browser::restore_view(app, v); + } + } + "ui.updateView" => { + if let Some(v) = p.get("view").and_then(Value::as_str) { + crate::browser::save_view(app, Some(v)); + } + } "ui.folderVisible" => { if let Some(f) = p.get("folder").and_then(Value::as_str) { crate::browser::toggle_folder(app, id_of(&p, "component").unwrap_or(0), f); @@ -883,6 +909,7 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { RenameWhat::Component(c) => doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::Group(g) => crate::browser::group_name(app, *g).unwrap_or_default(), RenameWhat::Canvas(c) => doc.canvases.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), + RenameWhat::View(v) => v.clone(), }; app.menu.rename = Some(Rename { what, text, at, focused: false }); } @@ -906,6 +933,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) { RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))), + RenameWhat::View(v) => drop(app.run("view.rename", json!({ "view": v, "name": name }))), } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 63502fd3..8b45271d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -22,6 +22,7 @@ //! `ui.rename {text?, commit?}` finishes the rename box //! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do //! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists) +//! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears) //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -364,6 +365,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } None => ok(json!({"pending": app.menu.confirm.as_ref().map(|c| c.report.clone())})), }, + "ui.shortcut" => { + let Some(id) = s("command") else { return err("missing `command`") }; + match s("key") { + Some(k) => match crate::keymap::bind(app, id, k, b("replace")) { + Ok(()) => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})), + Err(e) => err(e), + }, + None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})), + } + } "ui.menuPick" => { let Some(key) = s("item") else { return err("missing `item` (id or label)") }; wrap(crate::context_menu::pick(app, key)) diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs new file mode 100644 index 00000000..80656bdf --- /dev/null +++ b/crates/ui-egui/src/keymap.rs @@ -0,0 +1,267 @@ +//! Keyboard shortcuts: every command's default key comes from its `CommandSpec`; the user can +//! rebind or clear any of them (Preferences › Keyboard Shortcuts). A new key that another command +//! already uses is reported and only taken over on request; keys the app itself handles (Esc, S, +//! V, Delete, Ctrl+Z…) can't be bound. Changes are kept between runs. + +use std::collections::BTreeMap; + +use egui::RichText; +use serde_json::{Value, json}; +use solvecraft_engine::command_specs; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// Keys the application handles before any command shortcut. +pub const RESERVED: &[&str] = &["Escape", "S", "V", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"]; + +#[derive(Default)] +pub struct Keymap { + /// Command id → key (`""`: no key). Only the changed ones. + pub custom: BTreeMap, + /// The Keyboard Shortcuts window. + pub open: bool, + filter: String, + /// The command waiting for a key press. + capturing: Option, + /// A key that another command uses: (command, key, the other commands). + conflict: Option<(String, String, Vec)>, + message: Option, +} + +impl Keymap { + pub fn prefs(&self) -> Value { + json!(self.custom) + } + pub fn load(&mut self, v: &Value) { + self.custom = + v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()).unwrap_or_default(); + } +} + +/// A key press as a shortcut name: `E`, `Shift+E`, `Ctrl+Alt+K`, `F5`, `Delete`. +pub fn key_name(key: egui::Key, mods: egui::Modifiers) -> String { + let mut s = String::new(); + if mods.command || mods.ctrl { + s.push_str("Ctrl+"); + } + if mods.alt { + s.push_str("Alt+"); + } + if mods.shift { + s.push_str("Shift+"); + } + s.push_str(key.name()); + s +} + +/// The key a command runs from now. +pub fn effective(app: &SolveApp, id: &str) -> Option { + match app.keymap.custom.get(id) { + Some(k) if k.is_empty() => None, + Some(k) => Some(k.clone()), + None => solvecraft_engine::find_command(id).and_then(|c| c.shortcut).map(str::to_string), + } +} + +/// Commands bound to `key` now. +pub fn commands_for(app: &SolveApp, key: &str) -> Vec<&'static str> { + command_specs().into_iter().filter(|c| effective(app, c.id).as_deref() == Some(key)).map(|c| c.id).collect() +} + +/// The enabled command a key press runs, if any. +pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> { + commands_for(app, key).into_iter().find(|id| solvecraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled)) +} + +/// Bind `key` to a command (`""` clears it). Refused for reserved keys, and when other commands +/// use the key unless `replace` (they lose it). +pub fn bind(app: &mut SolveApp, id: &str, key: &str, replace: bool) -> Result<(), String> { + let spec = solvecraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?; + if RESERVED.contains(&key) { + return Err(format!("{key} is used by SolveCraft itself")); + } + if !key.is_empty() { + let others: Vec<&'static str> = commands_for(app, key).into_iter().filter(|o| *o != id).collect(); + if !others.is_empty() && !replace { + let names: Vec<&str> = others.iter().filter_map(|o| solvecraft_engine::find_command(o).map(|c| c.label)).collect(); + return Err(format!("{key} is already used by {}", names.join(", "))); + } + for o in others { + app.keymap.custom.insert(o.to_string(), String::new()); + } + } + if spec.shortcut.unwrap_or("") == key { + app.keymap.custom.remove(id); + } else { + app.keymap.custom.insert(id.to_string(), key.to_string()); + } + Ok(()) +} + +pub fn reset(app: &mut SolveApp, id: Option<&str>) { + match id { + Some(id) => drop(app.keymap.custom.remove(id)), + None => app.keymap.custom.clear(), + } +} + +/// The Keyboard Shortcuts window. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if !app.keymap.open { + return; + } + // A key press while waiting for one binds it (Esc cancels). + if let Some(id) = app.keymap.capturing.clone() { + let press = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)), + _ => None, + }) + }); + if let Some((k, m)) = press { + app.keymap.capturing = None; + if k != egui::Key::Escape { + let name = key_name(k, m); + match bind(app, &id, &name, false) { + Ok(()) => app.keymap.message = None, + Err(e) if e.contains("already used") => { + app.keymap.conflict = Some((id.clone(), name.clone(), commands_for(app, &name).iter().map(|s| s.to_string()).collect())); + app.keymap.message = Some(e); + } + Err(e) => app.keymap.message = Some(e), + } + } + } + } + let t = Tokens::get(); + let mut open = true; + let mut action: Option<(String, &str)> = None; + egui::Window::new("Keyboard Shortcuts") + .id(egui::Id::new("sc_keymap")) + .default_pos(egui::pos2(270.0, 140.0)) + .open(&mut open) + .default_size([520.0, 480.0]) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0)); + if ui.button("Reset All").clicked() { + action = Some((String::new(), "reset_all")); + } + }); + if let Some(m) = &app.keymap.message { + ui.label(RichText::new(m).color(t.warning)); + } + if let Some((id, key, _)) = app.keymap.conflict.clone() { + ui.horizontal(|ui| { + if ui.button(format!("Use {key} here anyway")).clicked() { + action = Some((format!("{id}\n{key}"), "replace")); + } + if ui.button("Cancel").clicked() { + action = Some((String::new(), "cancel")); + } + }); + } + ui.separator(); + let q = app.keymap.filter.to_lowercase(); + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| { + for c in command_specs() { + let key = effective(app, c.id).unwrap_or_default(); + if !q.is_empty() && ![c.label, c.id, key.as_str()].iter().any(|s| s.to_lowercase().contains(&q)) { + continue; + } + ui.label(c.label); + ui.label(RichText::new(c.id).size(10.5).color(t.text_dim)); + let capturing = app.keymap.capturing.as_deref() == Some(c.id); + let changed = app.keymap.custom.contains_key(c.id); + let text = if capturing { + "press a key…".to_string() + } else if key.is_empty() { + "—".to_string() + } else { + key.clone() + }; + let b = + egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0)); + if ui.add(b).on_hover_text("Click, then press the new key").clicked() { + action = Some((c.id.to_string(), "capture")); + } + ui.horizontal(|ui| { + if ui.add_enabled(!key.is_empty(), egui::Button::new("Clear").small()).clicked() { + action = Some((c.id.to_string(), "clear")); + } + if ui.add_enabled(changed, egui::Button::new("Reset").small()).clicked() { + action = Some((c.id.to_string(), "reset")); + } + }); + ui.end_row(); + } + }); + }); + }); + match action { + Some((id, "capture")) => { + app.keymap.capturing = Some(id); + app.keymap.conflict = None; + app.keymap.message = None; + } + Some((id, "clear")) => drop(bind(app, &id, "", false)), + Some((id, "reset")) => reset(app, Some(&id)), + Some((_, "reset_all")) => reset(app, None), + Some((pair, "replace")) => { + if let Some((id, key)) = pair.split_once('\n') { + let _ = bind(app, id, key, true); + } + app.keymap.conflict = None; + app.keymap.message = None; + } + Some((_, "cancel")) => { + app.keymap.conflict = None; + app.keymap.message = None; + } + _ => {} + } + if !open { + app.keymap.open = false; + app.keymap.capturing = None; + } +} + +/// Is the window waiting for a key (so the key must not also run a command)? +pub fn capturing(app: &SolveApp) -> bool { + app.keymap.open && app.keymap.capturing.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> SolveApp { + SolveApp::new(solvecraft_engine::Session::default(), Default::default()) + } + + #[test] + fn rebinding_detects_conflicts_and_resets() { + let mut a = app(); + assert_eq!(effective(&a, "Extrude").as_deref(), Some("E")); + // Shift+E is free. + bind(&mut a, "Extrude", "Shift+E", false).unwrap(); + assert_eq!(commands_for(&a, "Shift+E"), vec!["Extrude"]); + assert!(commands_for(&a, "E").is_empty()); + // M belongs to Move/Copy: refused, then taken over on request. + let e = bind(&mut a, "Revolve", "M", false).unwrap_err(); + assert!(e.contains("already used by Move/Copy"), "{e}"); + bind(&mut a, "Revolve", "M", true).unwrap(); + assert_eq!(commands_for(&a, "M"), vec!["Revolve"]); + assert_eq!(effective(&a, "FusionMoveCommand"), None); + assert!(bind(&mut a, "Revolve", "Ctrl+Z", false).is_err(), "reserved"); + // Kept between runs. + let mut b = app(); + b.keymap.load(&a.keymap.prefs()); + assert_eq!(effective(&b, "Revolve").as_deref(), Some("M")); + reset(&mut b, None); + assert_eq!(effective(&b, "FusionMoveCommand").as_deref(), Some("M")); + assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9d22e6b..4539f7d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod gpu; pub mod home; pub mod icons; pub mod inference; +pub mod keymap; #[cfg(test)] mod menu_tests; pub mod palette; @@ -34,6 +35,7 @@ pub mod preview; pub mod ref_images; pub mod scenario; pub mod selection; +pub mod shortcut_box; pub mod sketch3d; pub mod sketch_dims; #[cfg(test)] @@ -149,6 +151,10 @@ pub struct SolveApp { pub menu: context_menu::MenuState, /// Browser tree state (reveal, groups being edited, occurrence moves). pub tree: browser::TreeState, + /// The S box (command search at the cursor): recent and pinned commands. + pub sbox: shortcut_box::ShortcutBox, + /// Keyboard shortcuts the user changed. + pub keymap: keymap::Keymap, pub preview: preview::PreviewState, /// The last command started interactively (id, label), for Repeat. pub last_command: Option<(String, String)>, @@ -192,6 +198,8 @@ impl SolveApp { palette: palette::Palette::default(), menu: context_menu::MenuState::default(), tree: browser::TreeState::default(), + sbox: shortcut_box::ShortcutBox::default(), + keymap: keymap::Keymap::default(), preview: preview::PreviewState::default(), last_command: None, esc_handled: false, @@ -230,6 +238,8 @@ impl SolveApp { "browser_collapsed": self.tree.collapsed, "browser_expanded": self.tree.expanded, "recent": self.home.recent, + "shortcut_box": self.sbox.prefs(), + "shortcuts": self.keymap.prefs(), }) .to_string() } @@ -270,6 +280,12 @@ impl SolveApp { .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).take(home::MAX_RECENT).map(str::to_string).collect()) .unwrap_or_default(); + if let Some(s) = v.get("shortcut_box") { + self.sbox.load(s); + } + if let Some(s) = v.get("shortcuts") { + self.keymap.load(s); + } } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -651,7 +667,8 @@ impl SolveApp { if self.custom_titlebar { titlebar::resize_zones(ui); } - palette::popup(self, ui.ctx()); + shortcut_box::show(self, ui.ctx()); + keymap::show(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 46f6ff7d..a5ec7a28 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -380,3 +380,25 @@ fn v_toggles_what_is_selected() { assert_eq!(app.ui.hidden_bodies, vec![b]); assert_eq!(find(&context_menu::items(&app, &Target::Body { name: body(&app) }), "Show/Hide").shortcut, "V"); } + +#[test] +fn named_views_save_restore_rename_and_delete() { + let mut app = sample_app(); + act(&mut app, &Target::NamedViews, "New Named View"); + assert_eq!(app.session.doc.named_views.len(), 1); + let saved = app.cam; + app.cam.set_view(solvecraft_engine::render::StandardView::Top); + let t = Target::NamedView { name: "Named View1".into() }; + act(&mut app, &t, "Restore"); + assert_eq!(app.cam_anim.map(|a| a.to), Some(saved), "animates back to the saved camera"); + act(&mut app, &t, "Rename"); + assert!(context_menu::finish_rename(&mut app, Some("Front detail"), true)); + let t = Target::NamedView { name: "Front detail".into() }; + act(&mut app, &t, "Delete"); + assert!(app.session.doc.named_views.is_empty()); + // Standard views restore but can't be renamed or deleted. + let top = context_menu::items(&app, &Target::NamedView { name: "Top".into() }); + assert!(!find(&top, "Delete").enabled); + context_menu::run_item(&mut app, &find(&top, "Restore"), pos2(0.0, 0.0)); + assert!(app.cam_anim.is_some()); +} diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs new file mode 100644 index 00000000..f9bdee88 --- /dev/null +++ b/crates/ui-egui/src/shortcut_box.rs @@ -0,0 +1,287 @@ +//! The S box: a command search that opens at the cursor (the S key). It searches every command by +//! name, id, toolbar tab and panel with a forgiving (fuzzy) match. With nothing typed it lists +//! the recent commands first. Pinned commands sit in a row of icons on top, a small toolbar of +//! favourites. Up/Down choose, Enter runs, Esc closes. Recent and pinned commands are kept +//! between runs. + +use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::command_specs; + +use crate::theme::Tokens; +use crate::{SolveApp, icons}; + +/// How many recent commands are kept. +const RECENT: usize = 12; +const W: f32 = 340.0; + +#[derive(Default)] +pub struct ShortcutBox { + /// Where it opened (the cursor), while open. + at: Option, + /// The highlighted row. + index: usize, + /// What is typed. + pub text: String, + /// Most recent first. + pub recent: Vec, + pub pinned: Vec, +} + +impl ShortcutBox { + pub fn prefs(&self) -> Value { + json!({ "recent": self.recent, "pinned": self.pinned }) + } + pub fn load(&mut self, v: &Value) { + let list = |k: &str| -> Vec { + v.get(k).and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + self.recent = list("recent"); + self.pinned = list("pinned"); + } + /// A command was run from the box: it goes to the front of the recent list. + pub fn used(&mut self, id: &str) { + self.recent.retain(|r| r != id); + self.recent.insert(0, id.to_string()); + self.recent.truncate(RECENT); + } + pub fn toggle_pin(&mut self, id: &str) { + if self.pinned.iter().any(|p| p == id) { + self.pinned.retain(|p| p != id); + } else { + self.pinned.push(id.to_string()); + } + } +} + +/// How well `q` matches `s` (0 = not at all): a prefix beats a word start beats a substring +/// beats the letters in order with gaps. +pub fn score(q: &str, s: &str) -> u32 { + if q.is_empty() { + return 1; + } + let s = s.to_lowercase(); + if s.starts_with(q) { + return 1000 - s.len().min(500) as u32; + } + if s.split(|c: char| !c.is_alphanumeric()).any(|w| w.starts_with(q)) { + return 800; + } + if s.contains(q) { + return 600; + } + // Letters in order: fewer gaps score higher. + let mut gaps = 0u32; + let mut it = s.chars(); + let mut last_hit = true; + for qc in q.chars() { + let mut found = false; + for c in it.by_ref() { + if c == qc { + found = true; + break; + } + if last_hit { + gaps += 1; + } + last_hit = false; + } + if !found { + return 0; + } + last_hit = true; + } + 300u32.saturating_sub(gaps * 20).max(10) +} + +/// Commands matching `q`, best first: (id, label, icon, where it lives on the toolbar). With an +/// empty query, recent commands come first, then everything by name. +pub fn search(app: &SolveApp, q: &str) -> Vec<(&'static str, &'static str, &'static str, String)> { + let q = q.trim().to_lowercase(); + let mut v: Vec<(u32, &'static str, &'static str, &'static str, String)> = command_specs() + .into_iter() + .filter_map(|c| { + let place = if c.tab.is_empty() { String::new() } else { format!("{} › {}", c.tab, c.panel) }; + let s = score(&q, c.label).max(score(&q, c.id) * 9 / 10).max(score(&q, c.tab) / 3).max(score(&q, c.panel) / 3); + let recent = app.sbox.recent.iter().position(|r| r == c.id).map_or(0, |i| 2000 - i as u32); + // Commands that can run now come before those that can't. + let usable = if c.info(&app.session).enabled { 150 } else { 0 }; + (s > 0).then_some((s + usable + if q.is_empty() { recent } else { recent / 20 }, c.id, c.label, c.icon, place)) + }) + .collect(); + v.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(b.2))); + v.into_iter().map(|(_, id, label, icon, place)| (id, label, icon, place)).collect() +} + +/// Open the box at a point (the cursor), or at the middle of the window. +pub fn open(app: &mut SolveApp, at: Option) { + app.ui.palette_open = true; + app.sbox.at = at; + app.sbox.index = 0; + app.sbox.text.clear(); +} + +fn run(app: &mut SolveApp, id: &str) { + app.sbox.used(id); + app.ui.palette_open = false; + app.sbox.at = None; + app.sbox.text.clear(); + app.start(id); +} + +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if !app.ui.palette_open { + app.sbox.at = None; + return; + } + let t = Tokens::get(); + let screen = ctx.content_rect(); + let at = *app.sbox.at.get_or_insert_with(|| ctx.input(|i| i.pointer.latest_pos()).unwrap_or(screen.center())); + let list = search(app, &app.sbox.text); + let (up, down, enter, esc) = ctx.input(|i| { + (i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)) + }); + if down { + app.sbox.index = (app.sbox.index + 1).min(list.len().saturating_sub(1)); + } + if up { + app.sbox.index = app.sbox.index.saturating_sub(1); + } + let mut picked: Option<&'static str> = None; + let mut pin: Option<&'static str> = None; + let h = 380.0; + let pos = pos2( + at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())), + at.y.clamp(screen.top() + 4.0, (screen.bottom() - h).max(screen.top())), + ); + let resp = egui::Area::new(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + crate::context_menu::menu_frame(ui, |ui| { + ui.set_width(W - 10.0); + // Pinned commands: a mini toolbar. + if !app.sbox.pinned.is_empty() { + ui.horizontal_wrapped(|ui| { + for id in app.sbox.pinned.clone() { + let Some(c) = solvecraft_engine::find_command(&id) else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + icons::paint(ui.painter(), r.shrink(5.0), c.icon, t.icon, t.icon_fill, t.accent); + if resp.on_hover_text(c.label).clicked() { + picked = Some(c.id); + } + } + }); + ui.separator(); + } + let te = ui.add( + egui::TextEdit::singleline(&mut app.sbox.text).hint_text("Search commands").desired_width(W - 20.0).id(egui::Id::new("sc_sbox_text")), + ); + te.request_focus(); + if te.changed() { + app.sbox.index = 0; + } + ui.add_space(4.0); + if app.sbox.text.trim().is_empty() && !app.sbox.recent.is_empty() { + ui.label(egui::RichText::new("RECENT").size(10.0).color(t.text_dim)); + } + egui::ScrollArea::vertical().max_height(h - 90.0).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for (i, (id, label, icon, place)) in list.iter().enumerate().take(80) { + let enabled = solvecraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled); + let (r, resp) = ui.allocate_exact_size(vec2(W - 20.0, 26.0), Sense::click()); + let lit = i == app.sbox.index || resp.hovered(); + if lit { + ui.painter().rect_filled(r, 4.0, t.hover); + if i == app.sbox.index && (up || down) { + ui.scroll_to_rect(r, None); + } + } + let ink = if enabled { t.icon } else { t.border }; + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.left() + 14.0, r.center().y), vec2(16.0, 16.0)), + icon, + ink, + t.icon_fill, + t.accent, + ); + let col = if enabled { t.text } else { t.text_dim }; + ui.painter().text(pos2(r.left() + 30.0, r.center().y), Align2::LEFT_CENTER, *label, FontId::proportional(13.0), col); + let key = crate::keymap::effective(app, id).unwrap_or_default(); + let right = if key.is_empty() { place.clone() } else { format!("{key} {place}") }; + ui.painter().text(pos2(r.right() - 26.0, r.center().y), Align2::RIGHT_CENTER, right, FontId::proportional(10.5), t.text_dim); + // The pin. + let pinned = app.sbox.pinned.iter().any(|p| p == id); + let pr = Rect::from_center_size(pos2(r.right() - 11.0, r.center().y), vec2(16.0, 16.0)); + let presp = ui.interact(pr, ui.id().with(("pin", *id)), Sense::click()); + if pinned || lit { + let c = pr.center(); + let pts: Vec = (0..10) + .map(|k| { + let a = std::f32::consts::PI * (k as f32 / 5.0) - std::f32::consts::FRAC_PI_2; + let rr = if k % 2 == 0 { 6.0 } else { 2.6 }; + c + vec2(a.cos() * rr, a.sin() * rr) + }) + .collect(); + if pinned { + ui.painter().add(egui::Shape::convex_polygon(pts.clone(), t.warning, Stroke::NONE)); + } + ui.painter().add(egui::Shape::closed_line(pts, Stroke::new(1.0, if pinned { t.warning } else { t.text_dim }))); + } + if presp.on_hover_text(if pinned { "Unpin" } else { "Pin to the top of the S box" }).clicked() { + pin = Some(id); + } else if resp.clicked() && enabled { + picked = Some(id); + } + } + }); + }); + }); + if let Some(id) = pin { + app.sbox.toggle_pin(id); + } + if enter + && picked.is_none() + && let Some((id, ..)) = list.get(app.sbox.index) + { + picked = Some(id); + } + let outside = ctx.input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer(); + if let Some(id) = picked { + run(app, id); + } else if esc || outside { + app.ui.palette_open = false; + app.sbox.at = None; + app.sbox.text.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_scores_prefixes_over_words_over_letters() { + assert!(score("ext", "Extrude") > score("ext", "Offset Text")); + assert!(score("pres", "Press Pull") > score("ppl", "Press Pull")); + assert!(score("ppl", "Press Pull") > 0); + assert_eq!(score("xyz", "Extrude"), 0); + } + + #[test] + fn search_finds_by_name_id_and_panel_and_puts_recent_first() { + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + assert_eq!(search(&app, "extr").first().map(|x| x.0), Some("Extrude")); + assert_eq!(search(&app, "FusionFilletEdges").first().map(|x| x.0), Some("FusionFilletEdgesCommand")); + assert!(search(&app, "constraints").iter().any(|x| x.0 == "ConstraintParallel"), "by panel"); + app.sbox.used("FusionShellBodyCommand"); + app.sbox.used("Revolve"); + let all = search(&app, ""); + assert_eq!((all[0].0, all[1].0), ("Revolve", "FusionShellBodyCommand")); + app.sbox.toggle_pin("Extrude"); + let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + other.sbox.load(&app.sbox.prefs()); + assert_eq!((other.sbox.pinned.clone(), other.sbox.recent.len()), (vec!["Extrude".to_string()], 2)); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5b00cb0c..9d362e8e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -178,6 +178,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { app.dialog = Some(crate::dialogs::Dialog::preferences()); close = true; } + if item(ui, "Keyboard Shortcuts…", "") { + app.keymap.open = true; + close = true; + } if item(ui, "Quit", "") { app.quit_requested = true; close = true; @@ -391,6 +395,10 @@ fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) { /// Global keyboard shortcuts (when no text field has focus). pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { + // The Keyboard Shortcuts window is waiting for a key: it is not a shortcut. + if crate::keymap::capturing(app) { + return; + } if ctx.egui_wants_keyboard_input() { return; } @@ -412,7 +420,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { app.open_path(&p); } } - _ => {} + _ => { + if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) { + app.start(id); + } + } } continue; } @@ -430,14 +442,16 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { app.finish_sketch(); } } - Key::S => app.ui.palette_open = true, + Key::S if !mods.shift && !mods.alt => { + let at = ctx.input(|i| i.pointer.latest_pos()); + crate::shortcut_box::open(app, at); + } Key::V => crate::context_menu::toggle_visibility(app), Key::F6 => app.animate_view("fit"), Key::Delete | Key::Backspace => crate::viewport::delete_selection(app), _ => { - let name = format!("{k:?}"); - if let Some(c) = command_specs().into_iter().find(|c| c.shortcut == Some(name.as_str()) && c.info(&app.session).enabled) { - app.start(c.id); + if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) { + app.start(id); } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 55c26f17..12d95ad5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,6 +46,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt | | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | +| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | | `ui.resize` | `width`, `height` | | | `app.quit` | — | |