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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d0a9ef27ca
commit
a7fc1fe532
11 files changed
+825
-8
No files matched your search
@@ -1052,6 +1052,22 @@ pub struct Document {
|
||||
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Named views: saved cameras.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
|
||||
@@ -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<String> = 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::<solvecraft_engine::render::Camera>(j) {
|
||||
app.animate_to(cam);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current camera as a named view.
|
||||
pub fn save_view(app: &mut SolveApp, name: Option<&str>) -> Option<String> {
|
||||
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<Item> {
|
||||
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();
|
||||
|
||||
@@ -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<String> {
|
||||
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<Item> {
|
||||
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 }))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<ControlRequest>) -> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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<Pos2>,
|
||||
/// The highlighted row.
|
||||
index: usize,
|
||||
/// What is typed.
|
||||
pub text: String,
|
||||
/// Most recent first.
|
||||
pub recent: Vec<String>,
|
||||
pub pinned: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<Pos2>) {
|
||||
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<Pos2> = (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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` | — | |
|
||||
|
||||
Reference in new issue
Block a user