forked from 1b-agent/opencadstudio-upstream
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match arms and CommandRegistration name arrays. Move them all into a single user-editable file: - assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format), embedded and copied to config/ocad.pgp on first launch; only that user copy is read after. AA→AREA default closes #286. - resolve_alias() rewrites the leading command token at the top of dispatch_command_inner, so aliases resolve for every path (families, plugins, Repeat menu). Args after the first space are untouched. - Autocomplete hides alias keys and, when the whole input is an alias, surfaces its target command as the top (highlighted) suggestion so the highlight matches what Enter runs (AA→AREA, L→LINE). - ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply button; Apply persists to ocad.pgp, closing discards unapplied edits. - Strip alias tokens from all 8 dispatch match-arm files and 71 module CommandRegistration arrays, keeping canonical/synonym/distinct-command names. ocad.pgp is now the sole source: deleting or remapping an alias there takes effect. CUI stays on the keyboard-shortcut panel. Co-Authored-By: Claude Opus 4.8 <[email protected]>
386 lines
19 KiB
Rust
386 lines
19 KiB
Rust
use super::*;
|
||
|
||
impl OpenCADStudio {
|
||
pub(super) fn dispatch_fileops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||
match cmd {
|
||
"NEW" => return Some(Task::done(Message::TabNew)),
|
||
"OPEN" => return Some(Task::done(Message::OpenFile)),
|
||
"SAVE" | "QSAVE" => return Some(Task::done(Message::SaveFile)),
|
||
// SAVEALL — write every open drawing that already has a file path.
|
||
// Tabs without a path (never saved) are skipped with a note.
|
||
"SAVEALL" => {
|
||
if self.read_only {
|
||
self.command_line
|
||
.push_error("Read-only session (--read-only): saving is disabled.");
|
||
return Some(Task::none());
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let mut saved = 0usize;
|
||
let mut skipped = 0usize;
|
||
for t in 0..self.tabs.len() {
|
||
if self.tabs[t].is_start {
|
||
continue;
|
||
}
|
||
self.sync_vport_display(t);
|
||
if let Some(path) = self.tabs[t].current_path.clone() {
|
||
self.tabs[t].scene.document.header.user_real1 =
|
||
self.tabs[t].scene.annotation_scale as f64;
|
||
match crate::io::save(&self.tabs[t].scene.document, &path) {
|
||
Ok(()) => {
|
||
self.tabs[t].dirty = false;
|
||
saved += 1;
|
||
}
|
||
Err(e) => self.command_line.push_error(&format!(
|
||
"SAVEALL: {} failed: {e}",
|
||
path.display()
|
||
)),
|
||
}
|
||
} else {
|
||
skipped += 1;
|
||
}
|
||
}
|
||
self.command_line.push_output(&format!(
|
||
"SAVEALL: saved {saved} drawing(s){}.",
|
||
if skipped > 0 {
|
||
format!("; {skipped} need SAVEAS (no file path yet)")
|
||
} else {
|
||
String::new()
|
||
}
|
||
));
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
self.command_line
|
||
.push_info("SAVEALL: save each tab individually in the web build.");
|
||
}
|
||
return Some(Task::none());
|
||
}
|
||
"SAVEAS" => return Some(Task::done(Message::SaveAs)),
|
||
// UNDO <n> — step back n operations at once; bare UNDO / U is one step.
|
||
cmd if cmd.starts_with("UNDO ") => {
|
||
let arg = cmd["UNDO ".len()..].trim();
|
||
match arg.parse::<usize>() {
|
||
Ok(0) => return Some(Task::none()),
|
||
Ok(n) => return Some(Task::done(Message::UndoMany(n))),
|
||
Err(_) => {
|
||
self.command_line
|
||
.push_error("Usage: UNDO [number of steps]");
|
||
return Some(Task::none());
|
||
}
|
||
}
|
||
}
|
||
"UNDO" => return Some(Task::done(Message::Undo)),
|
||
"REDO" => return Some(Task::done(Message::Redo)),
|
||
// OOPS — restore the objects removed by the most recent ERASE,
|
||
// without undoing any work done since.
|
||
"OOPS" => {
|
||
if self.oops_cache.is_empty() {
|
||
self.command_line.push_info("OOPS: nothing to restore.");
|
||
} else {
|
||
self.push_undo_snapshot(i, "OOPS");
|
||
let restored = std::mem::take(&mut self.oops_cache);
|
||
let n = restored.len();
|
||
for e in restored {
|
||
self.tabs[i].scene.add_entity_clone(e);
|
||
}
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.tabs[i].dirty = true;
|
||
self.refresh_properties();
|
||
self.command_line
|
||
.push_output(&format!("OOPS: restored {n} object(s)."));
|
||
}
|
||
}
|
||
"CLEAR" | "CLR" => return Some(Task::done(Message::ClearScene)),
|
||
"WIREFRAME" => return Some(Task::done(Message::SetWireframe(true))),
|
||
// Visual-style commands. OCS renders either a wireframe or a shaded
|
||
// view; the named styles map onto the closest of the two and the
|
||
// chosen style is reported so the mapping is explicit. (`SOLID` is
|
||
// intentionally NOT a visual-style verb — it is the 2D filled-polygon
|
||
// draw command; the shaded ribbon button drives `SetWireframe`.)
|
||
cmd if cmd == "VS"
|
||
|| cmd == "VSCURRENT"
|
||
|| cmd == "SHADEMODE"
|
||
|| cmd == "HIDDENLINE"
|
||
|| cmd == "XRAY"
|
||
|| cmd == "REALISTIC"
|
||
|| cmd == "CONCEPTUAL"
|
||
|| cmd == "2DWIREFRAME"
|
||
|| cmd == "3DWIREFRAME"
|
||
|| cmd.starts_with("VSCURRENT ")
|
||
|| cmd.starts_with("SHADEMODE ")
|
||
|| cmd.starts_with("VS ") =>
|
||
{
|
||
let style = match cmd {
|
||
"VS" | "VSCURRENT" | "SHADEMODE" => String::new(),
|
||
s if s.starts_with("VS ")
|
||
|| s.starts_with("VSCURRENT ")
|
||
|| s.starts_with("SHADEMODE ") =>
|
||
{
|
||
cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase()
|
||
}
|
||
other => other.to_string(),
|
||
};
|
||
let (wireframe, label) = match style.as_str() {
|
||
"" | "SHADED" | "S" | "REALISTIC" | "CONCEPTUAL" => (false, "Shaded"),
|
||
"2DWIREFRAME" | "3DWIREFRAME" | "WIREFRAME" | "W" => (true, "Wireframe"),
|
||
"HIDDENLINE" | "HIDDEN" | "H" => (false, "Hidden (shown shaded)"),
|
||
"XRAY" | "X" => (true, "X-Ray (shown as wireframe)"),
|
||
_ => {
|
||
self.command_line.push_error(
|
||
"Usage: VSCURRENT <2dwireframe|wireframe|hidden|realistic|conceptual|shaded|xray>",
|
||
);
|
||
return Some(Task::none());
|
||
}
|
||
};
|
||
self.command_line
|
||
.push_output(&format!("Visual style: {label}."));
|
||
return Some(Task::done(Message::SetWireframe(wireframe)));
|
||
}
|
||
// CLOSE — close the active drawing tab (with the unsaved-changes
|
||
// prompt the tab-close handler already runs).
|
||
"CLOSE" => {
|
||
return Some(Task::done(Message::TabClose(self.active_tab)));
|
||
}
|
||
|
||
// ARCHIVE / ETRANSMIT — package the drawing and its referenced files
|
||
// (xrefs + raster images) into a sibling "<name>_archive" folder.
|
||
"ARCHIVE" | "ETRANSMIT" => {
|
||
use std::path::PathBuf;
|
||
if let Some(src) = self.tabs[i].current_path.clone() {
|
||
let stem = src
|
||
.file_stem()
|
||
.map(|s| s.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "drawing".into());
|
||
let parent = src
|
||
.parent()
|
||
.map(|p| p.to_path_buf())
|
||
.unwrap_or_else(|| PathBuf::from("."));
|
||
let folder = parent.join(format!("{stem}_archive"));
|
||
match std::fs::create_dir_all(&folder) {
|
||
Ok(()) => {
|
||
let mut copied = 0usize;
|
||
if let Some(fname) = src.file_name() {
|
||
if std::fs::copy(&src, folder.join(fname)).is_ok() {
|
||
copied += 1;
|
||
}
|
||
}
|
||
let mut deps: Vec<String> = Vec::new();
|
||
for br in self.tabs[i].scene.document.block_records.iter() {
|
||
if !br.xref_path.trim().is_empty() {
|
||
deps.push(br.xref_path.clone());
|
||
}
|
||
}
|
||
for e in self.tabs[i].scene.document.entities() {
|
||
if let acadrust::EntityType::RasterImage(img) = e {
|
||
if !img.file_path.trim().is_empty() {
|
||
deps.push(img.file_path.clone());
|
||
}
|
||
}
|
||
}
|
||
for d in deps {
|
||
let dp = PathBuf::from(&d);
|
||
let resolved = if dp.is_absolute() { dp } else { parent.join(&dp) };
|
||
if resolved.exists() {
|
||
if let Some(fname) = resolved.file_name() {
|
||
if std::fs::copy(&resolved, folder.join(fname)).is_ok() {
|
||
copied += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.command_line.push_output(&format!(
|
||
"{cmd}: packaged {copied} file(s) into {}",
|
||
folder.display()
|
||
));
|
||
}
|
||
Err(e) => self
|
||
.command_line
|
||
.push_error(&format!("{cmd}: cannot create folder ({e}).")),
|
||
}
|
||
} else {
|
||
self.command_line
|
||
.push_error("ARCHIVE: save the drawing first (it has no file path yet).");
|
||
}
|
||
}
|
||
|
||
"EXIT" | "QUIT" => {
|
||
// Funnel through the OS close path so the unsaved-changes
|
||
// dialog runs before `iced::exit()`. Falls back to a hard
|
||
// exit if there's no main window registered yet.
|
||
if let Some(id) = self.main_window {
|
||
return Some(Task::done(Message::WindowCloseRequested(id)));
|
||
}
|
||
return Some(self.exit_app());
|
||
}
|
||
|
||
// ── Frame-budget HUD (Phase 5.3) ───────────────────────────────
|
||
// Toggle the per-rebuild wire-tessellation readout overlay.
|
||
"PERF" => {
|
||
self.perf_hud = !self.perf_hud;
|
||
self.command_line.push_info(if self.perf_hud {
|
||
"PERF HUD on — shows last wire re-tessellation cost"
|
||
} else {
|
||
"PERF HUD off"
|
||
});
|
||
return Some(Task::none());
|
||
}
|
||
|
||
// ── Background color ───────────────────────────────────────────
|
||
// Usage: BACKGROUND <r> <g> <b> (0–255 each)
|
||
// BACKGROUND DEFAULT|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE (preset)
|
||
// BACKGROUND DEFAULT (restore the app default, rgb 33,40,48)
|
||
// The chosen colour is also stored as the persisted default
|
||
// (`default_bg_color` / `default_paper_bg_color`) so it survives
|
||
// restarts and applies to new drawings (#188).
|
||
// Bare BACKGROUND enters an interactive prompt for the colour, so
|
||
// the command works both as a one-shot (`BACKGROUND BLACK`) and as a
|
||
// type-then-choose flow (`BACKGROUND` ⏎, then `BLACK`). The prompt
|
||
// delegates back to the inline handler below via `Dispatch`.
|
||
"BACKGROUND" => {
|
||
use crate::command::ValuePromptCommand;
|
||
let c = ValuePromptCommand::new(
|
||
"BACKGROUND",
|
||
"BACKGROUND colour [Default/Black/DarkGray/Gray/LightGray/White] or R G B (0–255):",
|
||
);
|
||
self.command_line.push_info(&c.prompt());
|
||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||
}
|
||
cmd if cmd.starts_with("BACKGROUND ") => {
|
||
let args = cmd.split_whitespace().skip(1).collect::<Vec<_>>();
|
||
let is_paper = self.tabs[i].scene.current_layout != "Model";
|
||
if args
|
||
.first()
|
||
.map(|s| s.eq_ignore_ascii_case("DEFAULT") || s.eq_ignore_ascii_case("RESET"))
|
||
.unwrap_or(false)
|
||
{
|
||
if is_paper {
|
||
self.tabs[i].paper_bg_color = None;
|
||
self.tabs[i].scene.paper_bg_color = [1.0, 1.0, 1.0, 1.0];
|
||
self.default_paper_bg_color = None;
|
||
} else {
|
||
self.tabs[i].bg_color = None;
|
||
self.tabs[i].scene.bg_color = [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0];
|
||
self.default_bg_color = None;
|
||
}
|
||
// Wire colour adaptation (`adapt_to_bg`) reads the bg
|
||
// at tessellation time, so the cached wires need to
|
||
// refresh — otherwise a light→dark bg flip leaves
|
||
// black lines invisible against the new bg. Meshes
|
||
// bake colour into per-vertex GPU buffers at upload
|
||
// time; `recolor_meshes` rewrites the CPU side so
|
||
// the next epoch-driven re-upload picks up the new
|
||
// colour.
|
||
self.tabs[i].scene.recolor_meshes();
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.command_line
|
||
.push_output("Background reset to default.");
|
||
} else if let Some(rgba) = parse_background_color(&args) {
|
||
if is_paper {
|
||
self.tabs[i].paper_bg_color = Some(rgba);
|
||
self.tabs[i].scene.paper_bg_color = rgba;
|
||
self.default_paper_bg_color = Some(rgba);
|
||
} else {
|
||
self.tabs[i].bg_color = Some(rgba);
|
||
self.tabs[i].scene.bg_color = rgba;
|
||
self.default_bg_color = Some(rgba);
|
||
}
|
||
self.tabs[i].scene.recolor_meshes();
|
||
self.tabs[i].scene.bump_geometry();
|
||
let [r, g, b, _] = rgba;
|
||
self.command_line.push_output(&format!(
|
||
"Background: rgb({}, {}, {})",
|
||
(r * 255.0).round() as u8,
|
||
(g * 255.0).round() as u8,
|
||
(b * 255.0).round() as u8
|
||
));
|
||
// Persisted centrally after this message via
|
||
// `persist_settings_if_changed()`.
|
||
} else {
|
||
self.command_line.push_info(
|
||
"Usage: BACKGROUND <r> <g> <b> (0–255) | DEFAULT|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE",
|
||
);
|
||
}
|
||
}
|
||
// ORTHO toggles the orthogonal cursor constraint — the standard
|
||
// drafting aid, the same state the status-bar pill drives. Camera
|
||
// projection is a separate concern: PARALLEL / PERSP, driven by the
|
||
// Projection ribbon group.
|
||
"ORTHO" => return Some(Task::done(Message::ToggleOrtho)),
|
||
"PARALLEL" => return Some(Task::done(Message::SetProjection(true))),
|
||
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
|
||
"LAYERS" => return Some(Task::done(Message::ToggleLayers)),
|
||
|
||
// SCRIPT <path> — run a command script: each non-blank, non-comment
|
||
// line is fed through the same command path the `--script` startup
|
||
// flag uses, so the behaviour matches headless automation exactly.
|
||
cmd if cmd == "SCRIPT"
|
||
|| cmd == "SCR"
|
||
|| cmd.starts_with("SCRIPT ")
|
||
|| cmd.starts_with("SCR ") =>
|
||
{
|
||
let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string());
|
||
match path {
|
||
Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) {
|
||
Ok(text) => {
|
||
let cmds: Vec<Task<Message>> = text
|
||
.lines()
|
||
.map(str::trim)
|
||
.filter(|l| {
|
||
!l.is_empty() && !l.starts_with('#') && !l.starts_with(';')
|
||
})
|
||
.map(|l| Task::done(Message::Command(l.to_string())))
|
||
.collect();
|
||
self.command_line.push_output(&format!(
|
||
"SCRIPT: running {} command(s) from {p}.",
|
||
cmds.len()
|
||
));
|
||
return Some(Task::batch(cmds));
|
||
}
|
||
Err(e) => {
|
||
self.command_line
|
||
.push_error(&format!("SCRIPT: cannot read {p}: {e}"));
|
||
}
|
||
},
|
||
_ => {
|
||
self.command_line
|
||
.push_info("Usage: SCRIPT <path to .scr file>");
|
||
}
|
||
}
|
||
}
|
||
|
||
_ => return None,
|
||
}
|
||
Some(self.finish_dispatch(cmd))
|
||
}
|
||
}
|
||
|
||
/// Parse the argument list of the `BACKGROUND` command into an `[r,g,b,a]`
|
||
/// colour (channels 0.0–1.0, `a` always 1.0). Accepts:
|
||
/// * three whitespace-separated 0–255 values: `255 255 255`
|
||
/// * a named preset: WHITE / BLACK / GRAY|GREY / DARKGRAY|DARKGREY / LTGRAY
|
||
/// Returns `None` if the arguments don't match either form.
|
||
fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
|
||
let to_rgba = |[r, g, b]: [u8; 3]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0];
|
||
// Single token: a named preset.
|
||
if args.len() == 1 {
|
||
let preset = match args[0].to_ascii_uppercase().as_str() {
|
||
"WHITE" => [255, 255, 255],
|
||
"BLACK" => [0, 0, 0],
|
||
"GRAY" | "GREY" => [128, 128, 128],
|
||
"DARKGRAY" | "DARKGREY" | "DKGRAY" => [64, 64, 64],
|
||
"LTGRAY" | "LIGHTGRAY" | "LIGHTGREY" => [192, 192, 192],
|
||
_ => return None,
|
||
};
|
||
return Some(to_rgba(preset));
|
||
}
|
||
// Three separate tokens: `r g b`.
|
||
if args.len() >= 3 {
|
||
let r = args[0].parse::<u8>().ok()?;
|
||
let g = args[1].parse::<u8>().ok()?;
|
||
let b = args[2].parse::<u8>().ok()?;
|
||
return Some(to_rgba([r, g, b]));
|
||
}
|
||
None
|
||
}
|