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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 10:29:11 +00:00
1 parent d0a9ef27ca
commit a7fc1fe532
11 files changed
+825 -8

No files matched your search

+267
View File
@@ -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<String, String>,
/// The Keyboard Shortcuts window.
pub open: bool,
filter: String,
/// The command waiting for a key press.
capturing: Option<String>,
/// A key that another command uses: (command, key, the other commands).
conflict: Option<(String, String, Vec<String>)>,
message: Option<String>,
}
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<String> {
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");
}
}