feat(cli): full command-line interface via clap

Replace the ad-hoc `--serve` arg sniff with a clap-parsed CLI:

- positional <file> to open at startup (also the OS file-association path)
- --new, --read-only, --backend, --safe-mode/--no-gpu
- --serve/--port (headless JSON automation, unchanged behaviour)
- --export IN OUT (headless format convert via the automation load/save infra)
- --script FILE (run command lines at startup)
- --log/RUST_LOG, plus --version/--help for free

GUI-only options pass to app::boot through a OnceLock config; read-only
gates the save paths. The web build skips parsing (no args).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-20 13:59:20 +03:00
1 parent 7e70a2a97c
commit 79bb1d67b1
8 files changed
+399 -30

No files matched your search

+23
View File
@@ -32,6 +32,29 @@ pub fn serve() {
}
}
/// Headless one-shot format conversion (`--export IN OUT`). Loads `input`,
/// writes `output` (format chosen from `output`'s extension), and returns a
/// process exit code (0 on success). No window is created.
pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 {
let doc = match crate::io::load_file(input) {
Ok(doc) => doc,
Err(e) => {
eprintln!("export: cannot read {}: {e}", input.display());
return 1;
}
};
match crate::io::save(&doc, output) {
Ok(()) => {
println!("Exported {} → {}", input.display(), output.display());
0
}
Err(e) => {
eprintln!("export: cannot write {}: {e}", output.display());
1
}
}
}
/// `--port <N>` if present on the command line.
fn port_arg() -> Option<u16> {
let mut args = std::env::args();
+39 -14
View File
@@ -1,7 +1,7 @@
#[cfg(not(target_arch = "wasm32"))]
mod automation;
#[cfg(not(target_arch = "wasm32"))]
pub use automation::serve;
pub use automation::{export_headless, serve};
mod cmd_result;
mod commands;
mod document;
@@ -332,6 +332,9 @@ pub(super) struct OpenCADStudio {
/// Whether the one-time default-association prompt has already been shown.
/// Persisted via [`settings::UserSettings`] so it survives restarts.
default_assoc_prompted: bool,
/// Read-only session (`--read-only`): editing is allowed but every save
/// path is refused. Set once at boot from the CLI config.
read_only: bool,
/// Tag of the latest available release (without the leading "v"),
/// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned.
update_notice_version: Option<String>,
@@ -1594,6 +1597,7 @@ impl OpenCADStudio {
point_size_buf: String::new(),
point_size_relative: true,
default_assoc_prompted: false,
read_only: false,
update_notice_version: None,
update_notice_body: None,
clipboard: Vec::new(),
@@ -1834,18 +1838,32 @@ impl OpenCADStudio {
Message::UpdateCheckResult,
);
let focus_cmd = s.focus_cmd_input();
// Open a file path passed on the command line. Used by the
// OS file association: double-clicking a .dwg in the file
// explorer launches the binary with the file path as the
// first positional argument. Unknown / flag-style args are
// ignored. `Message::OpenRecent` does the existence check
// and reports a clean error if the path is bogus.
let cli_open: Task<Message> = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().to_string_lossy().starts_with('-'))
.map(|p| Task::done(Message::OpenRecent(p)))
.unwrap_or_else(Task::none);
// Startup configuration from the command line (see `cli`). A file
// argument — also how the OS file association launches us when a .dwg
// is double-clicked — opens via `OpenRecent`, which existence-checks
// the path and reports a clean error if it is bogus. `--new` opens a
// fresh drawing tab instead of the welcome screen. `--read-only`
// disables saving. `--script` queues command lines to run once up.
let cfg = crate::cli::gui_config();
s.read_only = cfg.read_only;
let cli_open: Task<Message> = if let Some(p) = cfg.file {
Task::done(Message::OpenRecent(p))
} else if cfg.new {
Task::done(Message::TabNew)
} else {
Task::none()
};
// Startup command script: each line dispatched as if typed at the
// command line, in order, after any file open is requested.
let script: Task<Message> = if cfg.script_lines.is_empty() {
Task::none()
} else {
Task::batch(
cfg.script_lines
.into_iter()
.map(|line| Task::done(Message::Command(line))),
)
};
// One-time prompt offering to make Open CAD Studio the default app for
// .dwg / .dxf. Shown only on the first launch that hasn't answered it
// yet; the flag is persisted so we never ask twice.
@@ -1855,7 +1873,14 @@ impl OpenCADStudio {
}
(
s,
Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]),
Task::batch([
open_main,
check_update,
focus_cmd,
cli_open,
script,
assoc_prompt,
]),
)
}
+10
View File
@@ -947,6 +947,11 @@ impl OpenCADStudio {
Message::ObjImportPath(None) => Task::none(),
Message::SaveFile => {
if self.read_only {
self.command_line
.push_error("Read-only session (--read-only): saving is disabled.");
return Task::none();
}
let i = self.active_tab;
// Stamp the live grid/snap toggles onto the VPort so the file
// reflects them even if they came from settings with no
@@ -973,6 +978,11 @@ impl OpenCADStudio {
}
Message::SaveAs => {
if self.read_only {
self.command_line
.push_error("Read-only session (--read-only): saving is disabled.");
return Task::none();
}
let i = self.active_tab;
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)
+88
View File
@@ -0,0 +1,88 @@
//! Command-line interface.
//!
//! Parsing lives here; `main` interprets the result. Three run modes split out
//! of the parsed args:
//! - `--serve` headless JSON automation server (see `app::serve`)
//! - `--export IN OUT` one-shot headless format conversion, then exit
//! - otherwise launch the GUI editor, configured via [`GuiConfig`]
//!
//! GUI-only options (open-file, `--new`, `--read-only`, `--script`) are stashed
//! in [`GUI_CONFIG`] for `app::boot` to read, since the iced daemon boots with
//! no arguments of its own.
use std::path::PathBuf;
use std::sync::OnceLock;
use clap::Parser;
/// Open CAD Studio command-line options.
#[derive(Parser, Debug, Default)]
#[command(
name = "OpenCADStudio",
version,
about = "Open CAD Studio — 2D/3D CAD editor",
long_about = None,
)]
pub struct Cli {
/// CAD file to open at startup (.dwg / .dxf). Also used by the OS file
/// association when a drawing is double-clicked.
pub file: Option<PathBuf>,
/// Start with a new empty drawing, ignoring any file argument.
#[arg(long)]
pub new: bool,
/// Open read-only: editing is allowed but saving is disabled.
#[arg(long)]
pub read_only: bool,
/// Restrict the GPU backend (e.g. dx12, vulkan, gl, metal). Sets WGPU_BACKEND.
#[arg(long, value_name = "BACKEND")]
pub backend: Option<String>,
/// Safe mode: force the GL backend, for flaky/hybrid GPU drivers.
#[arg(long, visible_alias = "no-gpu")]
pub safe_mode: bool,
/// Run the headless JSON automation server (stdin/stdout, or --port).
#[arg(long)]
pub serve: bool,
/// TCP port for --serve (defaults to stdin/stdout).
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
/// Headless convert: read IN, write OUT (format from OUT's extension), exit.
#[arg(long, num_args = 2, value_names = ["IN", "OUT"])]
pub export: Option<Vec<PathBuf>>,
/// Run a command script at startup: one command line per line of FILE.
#[arg(long, value_name = "FILE")]
pub script: Option<PathBuf>,
/// Log level (error|warn|info|debug|trace). Also honours RUST_LOG.
#[arg(long, value_name = "LEVEL")]
pub log: Option<String>,
}
/// GUI startup configuration, handed from `main` to `app::boot` out-of-band
/// because the iced daemon's boot closure takes no arguments.
#[derive(Debug, Default, Clone)]
pub struct GuiConfig {
/// File to open on launch (`None` for a blank session).
pub file: Option<PathBuf>,
/// Open a fresh drawing tab on launch instead of the welcome screen.
pub new: bool,
/// Saving disabled for this session.
pub read_only: bool,
/// Command lines to run once the editor is up.
pub script_lines: Vec<String>,
}
/// Set once by `main` before the GUI boots; read by `app::boot`.
pub static GUI_CONFIG: OnceLock<GuiConfig> = OnceLock::new();
/// The GUI config, or a default empty one if `main` never set it (e.g. tests).
pub fn gui_config() -> GuiConfig {
GUI_CONFIG.get().cloned().unwrap_or_default()
}
+2
View File
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
pub mod app;
#[cfg(not(target_arch = "wasm32"))]
pub mod cli;
pub mod command;
pub mod entities;
pub mod io;
+64 -16
View File
@@ -5,6 +5,8 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod app;
#[cfg(not(target_arch = "wasm32"))]
mod cli;
mod command;
mod entities;
mod io;
@@ -20,32 +22,78 @@ mod sys;
mod update_check;
fn main() -> iced::Result {
// On some Windows hybrid-GPU laptops the AMD OpenGL driver (atio6axx.dll)
// access-violates the moment wgpu enumerates its GL backend at startup,
// killing the app before any window appears — even though DX12 would work
// fine (#55). Restrict wgpu to DX12/Vulkan so the GL ICD is never touched.
// An explicit user-set WGPU_BACKEND still wins.
#[cfg(target_os = "windows")]
if std::env::var_os("WGPU_BACKEND").is_none() {
std::env::set_var("WGPU_BACKEND", "dx12,vulkan");
}
// Web (wasm) uses the single-window entry; native uses the multi-window
// daemon. Trunk calls `main` from its generated JS bootstrap.
// daemon. Trunk calls `main` from its generated JS bootstrap. The web build
// takes no CLI args, so it skips parsing entirely.
#[cfg(target_arch = "wasm32")]
{
console_error_panic_hook::set_once();
app::run_web()
return app::run_web();
}
#[cfg(not(target_arch = "wasm32"))]
{
// Headless automation server — drive the app over JSON on stdin/stdout
// with no GUI (issue #29). `--serve` is the opt-in; everything else
// launches the editor.
if std::env::args().skip(1).any(|a| a == "--serve") {
use clap::Parser;
let args = cli::Cli::parse();
// Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
// surfaces wgpu / iced / winit diagnostics that are otherwise silent.
if let Some(level) = &args.log {
std::env::set_var("RUST_LOG", level);
}
if std::env::var_os("RUST_LOG").is_some() {
let _ = env_logger::try_init();
}
// GPU backend selection. Explicit `--backend` wins; `--safe-mode`
// forces GL for flaky drivers. On Windows, fall back to DX12/Vulkan so
// the AMD OpenGL ICD (atio6axx.dll) is never touched at startup — it
// access-violates on some hybrid-GPU laptops before any window appears
// (#55). An already-set WGPU_BACKEND always wins.
if let Some(backend) = &args.backend {
std::env::set_var("WGPU_BACKEND", backend);
} else if args.safe_mode {
std::env::set_var("WGPU_BACKEND", "gl");
}
#[cfg(target_os = "windows")]
if std::env::var_os("WGPU_BACKEND").is_none() {
std::env::set_var("WGPU_BACKEND", "dx12,vulkan");
}
// Headless modes exit without ever creating a window.
if args.serve {
// `app::serve` reads --port itself from the raw args.
app::serve();
return Ok(());
}
if let Some(io) = &args.export {
// clap enforces exactly two values for --export.
let code = app::export_headless(&io[0], &io[1]);
std::process::exit(code);
}
// GUI: stash the startup config for `app::boot` to pick up.
let script_lines = args
.script
.as_ref()
.map(|p| match std::fs::read_to_string(p) {
Ok(text) => text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.collect(),
Err(e) => {
eprintln!("--script: cannot read {}: {e}", p.display());
Vec::new()
}
})
.unwrap_or_default();
let _ = cli::GUI_CONFIG.set(cli::GuiConfig {
file: if args.new { None } else { args.file },
new: args.new,
read_only: args.read_only,
script_lines,
});
app::run()
}
}