Files
solidcraft-upstream/crates/ui-egui/src/keymap.rs
T
bherbruckandClaude Opus 5.5 2d069ca186 In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102)
* Agent chat prototype: drive an ACP agent the user already has from a side panel

A desktop-only chat panel (off by default) that starts a coding agent over the
Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open
window through the token-protected control channel with the default folder
limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI,
Copilot, Qwen, OpenCode, goose) with version and login state, streams text,
tool calls and plans, shows the agent's permission requests, and when an agent
isn't signed in shows its own login methods without handling any credential.

Design notes and findings: docs/design/in-app-agent-chat.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: say only what the end-to-end run showed

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: gate, one undo per message, docking, process groups, discovery polish

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: third-party licences for the keychain crates; clippy

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: badge text from the theme tokens

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: Auto-allow model edits is the default permission mode

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:22:38 -04:00

338 lines
14 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 solidcraft_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", "Ctrl+Shift+A"];
#[derive(Default)]
pub struct Keymap {
/// Command id → key (`""`: no key). Only the changed ones.
pub custom: BTreeMap<String, String>,
/// 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<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 => solidcraft_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| solidcraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled))
}
/// Run a shortcut key: its enabled command; outside a sketch, a sketch tool's key opens Create
/// Sketch (on the selected plane or planar face, else on the plane picked next) and the tool
/// starts in the new sketch.
pub fn press(app: &mut SolveApp, key: &str) {
if let Some(id) = command_for(app, key) {
app.start(id);
} else if let Some(id) = sketch_tool_for(app, key) {
app.start("sketch.create");
if let Some(d) = app.dialog.as_mut().filter(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)) {
d.then_start = Some(id);
}
}
}
/// Outside a sketch: the SKETCH-tab command bound to `key` that a new sketch would start. Not
/// Normal/Construction (a mode of the drawing in progress) nor the Finish Sketch panel.
fn sketch_tool_for(app: &SolveApp, key: &str) -> Option<&'static str> {
if app.session.active_sketch.is_some() {
return None;
}
commands_for(app, key).into_iter().find(|id| {
solidcraft_engine::find_command(id).is_some_and(|c| c.tab == "SKETCH" && c.panel != "FINISH SKETCH" && c.id != "sketch.construction")
})
}
/// 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 = solidcraft_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| solidcraft_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(crate::i18n::tr_label("Filter commands or keys"))
.desired_width(300.0),
);
if !app.keymap.read_only && ui.button(app.tr_label("Reset All")).clicked() {
action = Some((String::new(), "reset_all"));
}
if ui.button(app.tr_label("Export…")).on_hover_text(app.tr_label("Keys, S box favourites and the toolbar, as a file")).clicked() {
action = Some((String::new(), "export"));
}
if !app.keymap.read_only && ui.button(app.tr_label("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(app.tr_label("Cancel")).clicked() {
action = Some((String::new(), "cancel"));
}
});
}
ui.label(
RichText::new(
app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."),
)
.size(11.0)
.color(t.text_dim),
);
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();
let translated_label = app.tr_label(c.label);
if !q.is_empty() && ![c.label, c.id, key.as_str(), translated_label].iter().any(|s| s.to_lowercase().contains(&q)) {
continue;
}
ui.label(translated_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 {
app.tr_label("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(app.tr_label("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(app.tr_label("Clear")).small()).clicked() {
action = Some((c.id.to_string(), "clear"));
}
if ui.add_enabled(changed, egui::Button::new(app.tr_label("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();
app.pick_save("SolidCraft shortcuts.json", &["json"], move |app, path| {
if let Some(path) = path {
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
}
});
}
Some((_, "import")) => {
app.pick_open(|app, path| {
let Some(path) = path else { return };
let r = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| serde_json::from_str::<Value>(&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(solidcraft_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");
}
}
#[cfg(test)]
#[path = "sketch_shortcut_tests.rs"]
mod sketch_shortcut_tests;