diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..f80b829d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..665f7ee0 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -18,6 +18,10 @@ impl Args { let mut a = Args::default(); let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..c5d7833c 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -101,15 +101,24 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -120,13 +129,13 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = @@ -134,7 +143,7 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -142,19 +151,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -163,7 +172,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -178,7 +187,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -188,10 +197,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index eb42b8d8..1928cab5 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -14,6 +14,38 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..37e4b594 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,10 @@ commands, serve MCP and measure performance. Build it with Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +Put options before `--` to pass a filename beginning with a dash, for example +`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal, +including a filename named `--no-system-fonts`. + ## Fonts Text lays out and renders with the same fonts as the desktop app: the fonts built into FigCraft