//! 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", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"]; #[derive(Default)] pub struct Keymap { /// Command id → key (`""`: no key). Only the changed ones. pub custom: BTreeMap, /// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus). pub open: bool, /// Opened from Help: a list to read, not to edit. pub read_only: 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((current(k), v.as_str()?.to_string()))).collect()).unwrap_or_default(); } } /// Saved before the command ids were renamed: the current id. fn current(id: &str) -> String { solvecraft_engine::legacy_ids::current_id(id).to_string() } /// 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 SolidCraft 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 page of Preferences: search, rebind (click, then press the key), clear, /// reset; a key another command uses is reported and taken over only on request. pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); // 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 action: Option<(String, &str)> = None; { { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0)); if !app.keymap.read_only && ui.button("Reset All").clicked() { action = Some((String::new(), "reset_all")); } if ui.button("Export…").on_hover_text("Keys, S box favourites and the toolbar, as a file").clicked() { action = Some((String::new(), "export")); } if !app.keymap.read_only && ui.button("Import…").clicked() { action = Some((String::new(), "import")); } }); 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::both().max_height(330.0).auto_shrink([false, true]).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() }; if app.keymap.read_only { ui.label(RichText::new(text).strong()); ui.end_row(); continue; } let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.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((_, "export")) => { let text = crate::toolbar_custom::export(app).to_string(); if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolidCraft shortcuts.json", &["json"])) { app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}")); } } Some((_, "import")) => { if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) { let r = std::fs::read_to_string(&path) .map_err(|e| e.to_string()) .and_then(|t| serde_json::from_str::(&t).map_err(|e| e.to_string())) .and_then(|v| crate::toolbar_custom::import(app, &v)); app.keymap.message = r.err().map(|e| format!("import: {e}")); } } 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; } _ => {} } } /// Stop waiting for a key (the dialog closed). pub fn stop_capture(app: &mut SolveApp) { app.keymap.capturing = None; app.keymap.conflict = None; app.keymap.message = None; } /// Is the page waiting for a key (so the key must not also run a command)? pub fn capturing(app: &SolveApp) -> bool { app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && 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, "solid.extrude").as_deref(), Some("E")); // Shift+E is free. bind(&mut a, "solid.extrude", "Shift+E", false).unwrap(); assert_eq!(commands_for(&a, "Shift+E"), vec!["solid.extrude"]); assert!(commands_for(&a, "E").is_empty()); // M belongs to Move/Copy: refused, then taken over on request. let e = bind(&mut a, "solid.revolve", "M", false).unwrap_err(); assert!(e.contains("already used by Move/Copy"), "{e}"); bind(&mut a, "solid.revolve", "M", true).unwrap(); assert_eq!(commands_for(&a, "M"), vec!["solid.revolve"]); assert_eq!(effective(&a, "solid.move"), None); assert!(bind(&mut a, "solid.revolve", "Ctrl+Z", false).is_err(), "reserved"); // Kept between runs. let mut b = app(); b.keymap.load(&a.keymap.prefs()); assert_eq!(effective(&b, "solid.revolve").as_deref(), Some("M")); reset(&mut b, None); assert_eq!(effective(&b, "solid.move").as_deref(), Some("M")); assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K"); } }