mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
M0.9: allow literal dash-prefixed CLI filenames
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -101,15 +101,24 @@ impl From<String> for CliError {
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
|
||||
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
|
||||
if system_fonts {
|
||||
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
|
||||
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<ExitCode> {
|
||||
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<String> = 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<ExitCode> {
|
||||
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 <file>")?;
|
||||
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 <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
@@ -134,7 +143,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
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 <in> <out>".into())),
|
||||
@@ -142,19 +151,19 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
}
|
||||
"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 <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".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 <script.json|.jsonl>")?;
|
||||
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<ExitCode> {
|
||||
}
|
||||
"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<ExitCode> {
|
||||
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<ExitCode> {
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
let a = Args::parse(rest, &["layers", "instances"], &[])?;
|
||||
let a = parse_args(&rest, &["layers", "instances"], &[])?;
|
||||
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = 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}`"))),
|
||||
}
|
||||
|
||||
@@ -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::<serde_json::Value>(&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::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user