Files
OneCAD/crates/ui-egui/src/keymap.rs
T

275 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"];
#[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,
/// 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<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 !app.keymap.read_only && 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()
};
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(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");
}
}